Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b6e5076ff8 | |||
| ffb66e195e | |||
| d4308ecd6d | |||
| a2d6feb7ec | |||
| c1ec61fa49 | |||
| a4e30622cf | |||
| 44296da292 | |||
| efd2c79eb2 | |||
| 1e74b2c95e | |||
| f9ce56813b | |||
| 4176fd77d2 | |||
| 87d7743107 | |||
| c046ead141 | |||
| accc1692e1 | |||
| 7498767a61 | |||
| 44ca3916b6 | |||
| a28618fd16 | |||
| e39f9928b1 | |||
| e02770fee9 | |||
| 3097054727 | |||
| 6d72f5b63b | |||
| 712b4c63c2 | |||
| bca63fc6ce | |||
| 03600acccc | |||
| 3073e0cbde | |||
| dc7fd5792f |
@@ -41,7 +41,7 @@ Symptom: `queue:work` стартует, через ~60 секунд процес
|
||||
|
||||
### Квирк 107 — `config:cache` не из-под `www-data` → 500 на всём портале (24.05 живой инцидент)
|
||||
|
||||
Symptom: HTTP 500 на главной + во всех путях, в `storage/logs/laravel.log` пусто или «file not found» для cache. Cause: PHP-FPM под `www-data` **не может прочитать** `bootstrap/cache/config.php` (напр. owner=root без доступа группе) → fallback на defaults → APP_KEY=NULL и DB=sqlite. **Критерий — читаемость www-data, а не строгий владелец:** штатный `ubuntu:www-data` mode `775` читаем группой и НЕ вызывает 500 (проверено 25.06: портал HTTP 200). Фикс при NOT_READABLE: `sudo -u www-data php artisan config:cache` (пере-кэш под www-data) либо `sudo chmod 775 bootstrap/cache/config.php` + группа www-data.
|
||||
Symptom: HTTP 500 на главной + во всех путях, в `storage/logs/laravel.log` пусто или «file not found» для cache. Cause: владелец `bootstrap/cache/config.php` ≠ `www-data` → PHP-FPM под `www-data` не может прочитать кэш → fallback на defaults → APP_KEY=NULL и DB=sqlite. Фикс: `sudo -u www-data php artisan config:cache`.
|
||||
|
||||
### Квирк 108 — NTFS junction для worktree node_modules
|
||||
|
||||
@@ -51,30 +51,22 @@ Symptom: HTTP 500 на главной + во всех путях, в `storage/lo
|
||||
|
||||
Каждая проверка — это одна SSH-команда + ожидаемый формат вывода + критерий зелёного. Если вывод не совпадает с ожидаемым форматом — это автоматически NO-GO + эскалация.
|
||||
|
||||
### П1 — `bootstrap/cache/config.php` читаемость www-data и свежесть (Квирк 107, самый важный)
|
||||
|
||||
**ВАЖНО (исправлено 25.06.2026):** реальный корень инцидента 24.05 — PHP-FPM под `www-data`
|
||||
**не смог ПРОЧИТАТЬ** cache-файл (был owner=root без доступа группе). Поэтому критерий —
|
||||
**читаемость www-data**, а НЕ строгое «владелец == www-data». redeploy.sh штатно оставляет
|
||||
config.php как `ubuntu:www-data` mode `775` — www-data читает его через группу, портал
|
||||
работает (HTTP 200). Прежняя строгая проверка владельца давала **ложный NO-GO** (квирк
|
||||
«лечили» зря несколько раз). Проверяем то, что реально важно: может ли www-data читать.
|
||||
### П1 — `bootstrap/cache/config.php` владелец и свежесть (Квирк 107, самый важный)
|
||||
|
||||
```bash
|
||||
ssh -o ConnectTimeout=10 liderra "sudo -u www-data test -r /var/www/liderra/app/bootstrap/cache/config.php && echo READABLE || echo NOT_READABLE; stat -c '%U:%G %a %Y' /var/www/liderra/app/bootstrap/cache/config.php 2>/dev/null; stat -c '%Y' /var/www/liderra/app/.env 2>/dev/null"
|
||||
ssh -o ConnectTimeout=10 liderra "stat -c '%U %Y' /var/www/liderra/app/bootstrap/cache/config.php 2>/dev/null; stat -c '%Y' /var/www/liderra/app/.env 2>/dev/null"
|
||||
```
|
||||
|
||||
Ожидаемый формат — 3 строки (1-я — вердикт читаемости, 2-я — владелец:группа режим mtime, 3-я — mtime .env):
|
||||
Ожидаемый формат — 2 строки:
|
||||
|
||||
```
|
||||
READABLE
|
||||
ubuntu:www-data 775 1234567890
|
||||
www-data 1234567890
|
||||
1234567880
|
||||
```
|
||||
|
||||
Зелёный = (1) `READABLE` (www-data может прочитать config.php) И (2) mtime config.php ≥ mtime .env.
|
||||
Зелёный = (1) владелец `www-data` И (2) mtime config.php ≥ mtime .env.
|
||||
|
||||
Красный = `NOT_READABLE` (www-data НЕ может прочитать — настоящий риск 500) ИЛИ mtime config.php < mtime .env (квирк 104 — stale cache) ИЛИ файл config.php отсутствует. Цитировать квирк 107 в reason. NB: владелец `ubuntu` сам по себе **НЕ** красный, если файл читаем группой www-data.
|
||||
Красный = владелец ≠ `www-data` ИЛИ mtime config.php < mtime .env ИЛИ файл config.php отсутствует. Цитировать квирк 107 в reason.
|
||||
|
||||
### П2 — `.env` line endings (квирк 105)
|
||||
|
||||
@@ -181,7 +173,7 @@ ssh liderra "cd /var/www/liderra/app && php artisan migrate:status 2>&1 | grep -
|
||||
=== PROD-DEPLOY-VALIDATOR RAPORT ===
|
||||
Brief: <из входных данных>
|
||||
Проверки:
|
||||
П1 config.php читаем www-data [GREEN / RED] — <вывод | причина>
|
||||
П1 config:cache владелец [GREEN / RED] — <вывод | причина>
|
||||
П2 .env line endings [GREEN / RED] — <вывод | причина>
|
||||
П3 свободное место [GREEN / RED] — <вывод | причина>
|
||||
П4 свежесть бэкапа БД [GREEN / RED] — <вывод | причина>
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/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);
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/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,
|
||||
},
|
||||
}));
|
||||
+77
-13
@@ -32,38 +32,102 @@
|
||||
"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(yc managed-postgresql database delete:*)",
|
||||
"PowerShell(yc managed-postgresql cluster delete:*)"
|
||||
"PowerShell(Set-ExecutionPolicy:* -Scope LocalMachine*)"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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'); }\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node .claude/hooks/prod-db-pointer.mjs",
|
||||
"command": "node tools/observer-stop-hook.mjs",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-stop-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-prehook.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "Bash|PowerShell",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node .claude/hooks/prod-db-guard.mjs",
|
||||
"timeout": 10,
|
||||
"statusMessage": "prod-db-guard"
|
||||
"command": "node tools/router-embedding-warmup.mjs",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,41 +21,13 @@ Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces can
|
||||
|
||||
## Procedure
|
||||
|
||||
> **MANDATORY DIGITAL ANALYSIS (added 2026-05-26 after retro #6 feedback; extended to 11 tables 2026-05-28; extended to 13 tables 2026-05-30 in Stream H Task 8).**
|
||||
> Каждый прогон /brain-retro ОБЯЗАН включать **количественные срезы**, не только causal narrative. Минимум 13 цифровых таблиц:
|
||||
>
|
||||
> 1. **Path-type breakdown** (regulated vs improvised, со счётчиками и %).
|
||||
> 2. **node_chosen distribution** (топ-15 узлов с count + %).
|
||||
> 3. **recommended_node distribution** (что классификатор предложил, count + %).
|
||||
> 4. **GAP «рекомендован но выбран direct»** (per-node count + rework rate этого подмножества).
|
||||
> 5. **outcome × node_chosen group**: 3 группы (skill_used / direct_no_rec / direct_ignored_rec) со счётчиками + rework rate per group.
|
||||
> 6. **classifier_output presence by source** (prefilter / llm / regex / cache / NULL) — даёт диагностику здоровья самого классификатора.
|
||||
> 7. **Per-classification trigger-match + via-skill** (analysis / planning / bugfix / feature / refactor / security).
|
||||
> 8. **Class × canon coverage** — таблица класс задач × канонические узлы из мозга (`observer-classification-map.json`) × роутер рекомендовал × я реально взял × попало ли в канон. Источник — `result.classCanonCoverage` из analyzer.
|
||||
> 9. **Router vs Opus** — три секции: A (роутер дал → Opus оценил, расхождение видно сразу), B (роутер молчал → Opus сказал «надо был скил»), C (роутер дал → Opus согласился что скил излишен). Источник — `result.routerVsOpus`.
|
||||
> 10. **Chain-ignore breakdown** — отдельный срез: сколько раз роутер рекомендовал цепочку vs одиночный узел, какой % я игнорировал, и rework-rate каждого; bucket по длине цепочки (1/2/3+). Источник — `result.chainIgnoreBreakdown`.
|
||||
> 11. **Chain-hook effectiveness** — парсит `~/.claude/runtime/hook-outcomes.jsonl` за период retro. Buckets: blocked / passed-with-skill / passed-inline-override / passed-global-override / passed-short-chain / passed-no-mutating. Источник — `result.chainHookEffectiveness` из analyzer. Источник правила — brain-retro #9 Candidate 2.
|
||||
> 12. **Router-gate hook effectiveness (per-rule)** — счётчики fires + blocks по каждому `hook_fired.rule` в эпизодах за период (path-deny / git-conditional / branch-switch / etc). Помогает увидеть, какие правила реально стреляли и какой % fires заканчивался блокировкой. Источник — `result.routerGateHookEffectiveness` (Stream H Task 8). Без таблицы — нет видимости качества защит router-gate v4.
|
||||
> 13. **Self-fabrication signals** — эпизоды, где `controller_claim` непустой (контроллер заявил действие) но `tool_uses` пуст или отсутствует (записи о реальном tool-call нет). 7 канонических паттернов фабрикации задокументированы в `docs/superpowers/runbooks/recovery-procedures.md` §5. Источник — `result.selfFabricationSignals` (Stream H Task 8).
|
||||
>
|
||||
> Без этих 13 таблиц retro считается недоделанным. Narrative-выводы должны опираться на цифры из них, не на «общие ощущения». **Если classifier_output=NULL > 30% эпизодов** — это сигнал, что классификатор сломан; в retro отдельным блоком отчитаться о состоянии классификатора (timeouts/errors/source distribution).
|
||||
>
|
||||
> Запрет на жаргон для блока «Report to user»: цифры остаются техническими, словесные выводы пользователю — простым языком (см. memory `feedback_plain_language.md`).
|
||||
|
||||
<!-- markdownlint-disable MD029 MD032 -->
|
||||
|
||||
1. **Determine period**: ask user «за какой период» or default to «since last brain-retro» (find latest `docs/observer/notes/YYYY-MM-DD-brain-retro-*.md`).
|
||||
2. **Read evidence**: glob `docs/observer/episodes-YYYY-MM.jsonl` for the period; read all lines as JSON.
|
||||
3. **Read optional notes**: glob `docs/observer/notes/*.md` filtered by date.
|
||||
4. **Update read-counter**: run `node tools/observer-of-observer.mjs record`. This atomically bumps `docs/observer/.read-counter.json` `last_read_at` to now and increments `read_count_last_period`. (Side-effect — used by C3 observer-of-observer for 54-week self-prune detection.)
|
||||
5. **Run the deterministic analyzer**: `node tools/brain-retro-analyzer.mjs docs/observer/episodes-YYYY-MM.jsonl` (pass every monthly file in the period). It returns JSON with `episodeCount`, `observerErrorCount`, `tasks` (episodes grouped into tasks), `causalChains` (error→fix candidates) and `factorMatrix` (outcome distribution per factor). The analyzer deduplicates the routing-gate double-write and infers the true `outcome` of each episode from the next episode's `prompt_signal` — never trust the stored `outcome` (it is `unknown` at write time).
|
||||
5a. **[Phase 3] Sanity questions (spec §4.7)** — `node tools/brain-retro-sanity-generator.mjs` (called as a module from analyzer-driven flow, OR direct via `import { generateCandidateQuestions } from '../../../tools/brain-retro-sanity-generator.mjs'`) returns up to 5 candidate questions. Pick 3-4, ask via AskUserQuestion (multiple-choice + free comment). **Вопросы заказчику — простым языком**, не «rework / wrong_skill / TDD pattern / self_assessment», а «переделки / выбор не того инструмента / самопроверка» (memory `feedback_plain_language.md`). Если первый раунд содержит жаргон — переформулировать и переспросить. **Before persist:** sanitize free comments with `tools/observer-pii-filter.mjs` (`sanitize` export, RU_PHONE / EMAIL / TOKEN strip). Write answers to `docs/observer/sanity-checks/YYYY-MM-DD.json` `{schema_version: 1, questions: [...]}`.
|
||||
5b. **Reviewer pass** — pragmatic two-mode policy (added 2026-05-26 after brain-retro #6, replacing original spec §4.6 «subagent only» which was unrealistic at retro scale):
|
||||
|
||||
- **Batch mode (default, fast)** — `node tools/brain-retro-batch-reviewer.mjs docs/observer/episodes-YYYY-MM.jsonl <cutoff-iso> [limit=30] [conc=5]`. Direct Opus API via `reviewViaDirectApi` from `tools/brain-retro-opus-reviewer.mjs` with concurrency 5. Use for **N ≥ 20 unreviewed episodes** — typical retro workload (retro #6 processed 132 episodes in 293s = ~2.2s/episode, well under per-subagent overhead).
|
||||
- **Subagent mode (per spec §4.6, deeper context)** — `Task(subagent_type='reviewer-agent', prompt=<episode JSON + sanity-answers context>)`. Use for **N < 20 episodes** OR when the reviewer needs access to other tools (read related files, grep history). Per-episode try/catch — on subagent crash/timeout, fall back to `reviewViaDirectApi`.
|
||||
|
||||
Both modes write the same payload back: `review.*` + `outcome_reviewed` + `outcome_reviewed_source` (`direct_api_batch` for batch, `subagent` for Task(), `direct_api_fallback` when subagent fails). If both fail, leave `review.reviewer_error: <msg>` for the next retro.
|
||||
5a. **[Phase 3] Sanity questions (spec §4.7)** — `node tools/brain-retro-sanity-generator.mjs` (called as a module from analyzer-driven flow, OR direct via `import { generateCandidateQuestions } from '../../../tools/brain-retro-sanity-generator.mjs'`) returns up to 5 candidate questions. Pick 3-4, ask via AskUserQuestion (multiple-choice + free comment). **Before persist:** sanitize free comments with `tools/observer-pii-filter.mjs` (`sanitize` export, RU_PHONE / EMAIL / TOKEN strip). Write answers to `docs/observer/sanity-checks/YYYY-MM-DD.json` `{schema_version: 1, questions: [...]}`.
|
||||
5b. **[Phase 3] Reviewer subagent pickup (spec §4.6)** — for each unreviewed episode in the period: `Task(subagent_type='reviewer-agent', prompt=<episode JSON + sanity-answers context>)`. Parse the returned JSON, write `review.*` + `outcome_reviewed` + `outcome_reviewed_source` into the episode. Per-episode try/catch — on subagent crash/timeout, fall back to `tools/brain-retro-opus-reviewer.mjs` `reviewViaDirectApi(episode)` (direct Opus API). If both fail, leave `review.reviewer_error: <msg>` for the next retro.
|
||||
6. **Aggregate** per `references/aggregation-template.md` — fill the Factor analysis matrix from the analyzer's `factorMatrix`, the task groups from `tasks`, the causal-chain candidates from `causalChains`, plus the new sections: sanity-check results, reviewer-agent outcomes distribution, self-retrospect trigger status.
|
||||
7. **Propose candidates** — clearly separated section «Candidates for owner review». Each candidate has rationale + suggested edit + rejection-option.
|
||||
8. **Save retro note**: `docs/observer/notes/YYYY-MM-DD-brain-retro.md` with full aggregation.
|
||||
@@ -64,8 +36,6 @@ Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces can
|
||||
10. **Cost report** — read `~/.claude/runtime/cost-daily.json`; include classifier + self_assessment + reviewer cost totals for the period in the retro note.
|
||||
11. **Report to user**: high-signal summary including sanity highlights, reviewer outcome distribution, and any escalations.
|
||||
|
||||
<!-- markdownlint-enable MD029 MD032 -->
|
||||
|
||||
## Output anatomy
|
||||
|
||||
See `references/aggregation-template.md`.
|
||||
|
||||
@@ -3,8 +3,3 @@
|
||||
# break vitest module loading (SyntaxError: Invalid or unexpected token,
|
||||
# no file:line). See memory quirk #100 (2026-05-19).
|
||||
*.mjs text eol=lf
|
||||
|
||||
# Shell scripts must stay LF. CRLF breaks `set -euo pipefail` on the server
|
||||
# (`set: pipefail: invalid option name`) when scp'd from a Windows working tree —
|
||||
# деплой обрывается до рестарта. Инцидент 24.06.2026 (deploy/redeploy.sh).
|
||||
*.sh text eol=lf
|
||||
|
||||
Binary file not shown.
@@ -1,119 +0,0 @@
|
||||
name: Run artisan command on liderra.ru
|
||||
|
||||
# Universal artisan-runner для прод-команд пока прямой SSH с dev-машины
|
||||
# заблокирован YC backbone-фильтром. Заказчик пишет команду строкой в
|
||||
# workflow_dispatch input, workflow проверяет её по whitelist, выполняет на
|
||||
# проде под sudo -u www-data, выводит результат в job summary.
|
||||
#
|
||||
# Whitelist охватывает read-only / dry-run / status команды без подтверждения
|
||||
# плюс несколько mutating команд с обязательным confirm_apply=true.
|
||||
#
|
||||
# Любая команда вне whitelist'а → fail before SSH.
|
||||
#
|
||||
# Использует тот же LIDERRA_SSH_KEY что и deploy.yml/ssh-diagnose.yml.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
command:
|
||||
description: 'artisan-команда (например: supplier:rekey-orphans --dry-run)'
|
||||
required: true
|
||||
type: string
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю выполнение mutating-команды (обязательно true для команд без --dry-run)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
run:
|
||||
name: ${{ github.event.inputs.command }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
CMD: ${{ github.event.inputs.command }}
|
||||
CONFIRM: ${{ github.event.inputs.confirm_apply }}
|
||||
|
||||
steps:
|
||||
- name: Whitelist check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CMD_TRIM=$(echo "$CMD" | sed 's/^ *//;s/ *$//')
|
||||
echo "Requested: '$CMD_TRIM'"
|
||||
|
||||
# Group 1 — read-only / dry-run / inspection: всегда разрешены
|
||||
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run|deals:backfill-region-city --dry-run)( *)$'
|
||||
|
||||
# Group 2 — mutating: требуют confirm_apply=true
|
||||
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?|deals:backfill-region-city)( *)$'
|
||||
|
||||
if [[ "$CMD_TRIM" =~ $READ_ONLY_RE ]]; then
|
||||
echo "::notice::Command in read-only whitelist — proceeding."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$CMD_TRIM" =~ $MUTATING_RE ]]; then
|
||||
if [[ "$CONFIRM" != "true" ]]; then
|
||||
echo "::error::Mutating command '$CMD_TRIM' requires confirm_apply=true. Re-run with confirm_apply checked."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::Mutating command authorized via confirm_apply=true."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::Command '$CMD_TRIM' is NOT in whitelist. Allowed read-only patterns: $READ_ONLY_RE. Allowed mutating: $MUTATING_RE. Add to whitelist if needed."
|
||||
exit 1
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run artisan on prod
|
||||
run: |
|
||||
set -o pipefail
|
||||
CMD_B64=$(printf '%s' "$CMD" | base64 -w0)
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"CMD_B64='$CMD_B64' bash -s" <<'REMOTE' | tee /tmp/artisan-output.log
|
||||
set +e
|
||||
CMD=$(echo "$CMD_B64" | base64 -d)
|
||||
cd /var/www/liderra/app
|
||||
echo "=== Running: php artisan $CMD on $(hostname) at $(date -u) ==="
|
||||
sudo -u www-data php artisan $CMD 2>&1
|
||||
RC=$?
|
||||
echo
|
||||
echo "=== Exit code: $RC ==="
|
||||
exit $RC
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## artisan \`$CMD\`"
|
||||
echo
|
||||
echo "- Host: $LIDERRA_HOST"
|
||||
echo "- Confirm: $CONFIRM"
|
||||
echo "- Triggered by: ${{ github.actor }}"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/artisan-output.log 2>/dev/null || echo "(no output captured)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload output as artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artisan-output
|
||||
path: /tmp/artisan-output.log
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,229 +0,0 @@
|
||||
name: Deploy to liderra.ru
|
||||
|
||||
# Запускается вручную через web-интерфейс GitHub или через `gh workflow run`.
|
||||
# Решает проблему «дев-машина не достучится по SSH до прод-сервера через YC backbone»:
|
||||
# GitHub Actions runner — внешний по отношению к YC, его IP не блокируется тем
|
||||
# фильтром что блокирует мой dev-IP `89.144.17.119`.
|
||||
#
|
||||
# Требуемые secrets (Settings → Secrets and variables → Actions):
|
||||
# LIDERRA_SSH_KEY — содержимое приватного ключа `~/.ssh/liderra_deploy`
|
||||
# (начинается с `-----BEGIN OPENSSH PRIVATE KEY-----`).
|
||||
# Host/user захардкожены — публичная информация, нет смысла в secrets.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Branch/tag/SHA для деплоя (по умолчанию main)'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
backfill_snapshot:
|
||||
description: 'Запустить snapshot:backfill за сегодня (default yes)'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy code + run redeploy.sh
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
concurrency:
|
||||
group: liderra-prod-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: app/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
# --legacy-peer-deps: Histoire 1.0-beta.1 заявляет peerDep vite ^7,
|
||||
# установлено vite 8 — известный квирк проекта (memory feedback_environment.md #74).
|
||||
working-directory: app
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: app
|
||||
run: npm run build
|
||||
|
||||
- name: Verify build artifacts present
|
||||
run: |
|
||||
test -f app/public/build/manifest.json
|
||||
ls app/public/build/assets/ | head -5
|
||||
du -sh app/public/build/
|
||||
|
||||
- name: Create deploy tarball
|
||||
run: |
|
||||
tar czf /tmp/deploy.tgz \
|
||||
--exclude='app/.env' \
|
||||
--exclude='app/.env.example' \
|
||||
--exclude='app/.env.production' \
|
||||
--exclude='app/storage' \
|
||||
--exclude='app/vendor' \
|
||||
--exclude='app/node_modules' \
|
||||
--exclude='app/bootstrap/cache' \
|
||||
app db
|
||||
ls -lh /tmp/deploy.tgz
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Upload tarball to prod
|
||||
run: |
|
||||
scp -i ~/.ssh/liderra_deploy -o StrictHostKeyChecking=accept-new \
|
||||
/tmp/deploy.tgz ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }}:/tmp/deploy.tgz
|
||||
|
||||
- name: Pre-apply partitioned migrations via postgres superuser
|
||||
# Workaround for partitioned-table migrations:
|
||||
# 2026_05_27_120000_create_project_routing_snapshots_table.php has SET ROLE crm_migrator
|
||||
# which fails when pgsql connection = crm_app_user (not a member of crm_migrator),
|
||||
# poisoning the transaction. Established prod pattern (memory: paused_at migration 26.05):
|
||||
# apply schema via sudo -u postgres psql + insert into migrations table.
|
||||
# Idempotent — skips if already applied.
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE'
|
||||
set -euo pipefail
|
||||
MIG_NAME='2026_05_27_120000_create_project_routing_snapshots_table'
|
||||
|
||||
ALREADY=$(sudo -u postgres psql -d liderra -tAc \
|
||||
"SELECT 1 FROM migrations WHERE migration = '${MIG_NAME}' LIMIT 1")
|
||||
if [ "${ALREADY}" = "1" ]; then
|
||||
echo "Migration ${MIG_NAME} already in migrations table — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TABLE_EXISTS=$(sudo -u postgres psql -d liderra -tAc \
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name='project_routing_snapshots' LIMIT 1")
|
||||
|
||||
if [ "${TABLE_EXISTS}" != "1" ]; then
|
||||
echo "Applying CREATE TABLE project_routing_snapshots via postgres superuser..."
|
||||
sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1 <<'PSQL'
|
||||
BEGIN;
|
||||
CREATE TABLE project_routing_snapshots (
|
||||
snapshot_date DATE NOT NULL,
|
||||
project_id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
daily_limit INT NOT NULL CHECK (daily_limit >= 0),
|
||||
delivery_days_mask INT NOT NULL CHECK (delivery_days_mask BETWEEN 0 AND 127),
|
||||
regions INT[] NOT NULL DEFAULT '{}',
|
||||
signal_type TEXT NOT NULL CHECK (signal_type IN ('call','site','sms')),
|
||||
signal_identifier TEXT,
|
||||
sms_senders JSONB,
|
||||
sms_keyword TEXT,
|
||||
expected_volume INT NOT NULL CHECK (expected_volume >= 0),
|
||||
delivered_count INT NOT NULL DEFAULT 0 CHECK (delivered_count >= 0),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (snapshot_date, project_id),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
) PARTITION BY RANGE (snapshot_date);
|
||||
ALTER TABLE project_routing_snapshots OWNER TO crm_migrator;
|
||||
CREATE INDEX project_routing_snapshots_tenant_date_idx
|
||||
ON project_routing_snapshots (tenant_id, snapshot_date);
|
||||
CREATE INDEX project_routing_snapshots_signal_idx
|
||||
ON project_routing_snapshots (snapshot_date, signal_type, lower(signal_identifier));
|
||||
ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY project_routing_snapshots_tenant_isolation
|
||||
ON project_routing_snapshots
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
|
||||
GRANT SELECT, INSERT, UPDATE ON project_routing_snapshots TO crm_app_user;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON project_routing_snapshots TO crm_supplier_worker;
|
||||
CREATE TABLE project_routing_snapshots_y2026_m05
|
||||
PARTITION OF project_routing_snapshots
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE project_routing_snapshots_y2026_m06
|
||||
PARTITION OF project_routing_snapshots
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
ALTER TABLE project_routing_snapshots_y2026_m05 OWNER TO crm_migrator;
|
||||
ALTER TABLE project_routing_snapshots_y2026_m06 OWNER TO crm_migrator;
|
||||
INSERT INTO system_settings (key, value, type, description, updated_at)
|
||||
VALUES ('partition_retention_months_project_routing_snapshots', '3', 'int',
|
||||
'Retention в месяцах для project_routing_snapshots (90 дней)', NOW())
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
COMMIT;
|
||||
PSQL
|
||||
else
|
||||
echo "Table project_routing_snapshots already exists but migration not marked — marking only."
|
||||
fi
|
||||
|
||||
# Mark migration as applied so Laravel migrate skips it.
|
||||
# Laravel's migrations table has no UNIQUE on `migration` column, so
|
||||
# ON CONFLICT doesn't work — use INSERT...SELECT WHERE NOT EXISTS for idempotency.
|
||||
NEXT_BATCH=$(sudo -u postgres psql -d liderra -tAc "SELECT COALESCE(MAX(batch),0)+1 FROM migrations")
|
||||
sudo -u postgres psql -d liderra -c \
|
||||
"INSERT INTO migrations (migration, batch) SELECT '${MIG_NAME}', ${NEXT_BATCH} WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration='${MIG_NAME}');"
|
||||
echo "Marked ${MIG_NAME} as applied (batch ${NEXT_BATCH})"
|
||||
REMOTE
|
||||
|
||||
- name: Extract + run redeploy.sh on prod
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE'
|
||||
set -euo pipefail
|
||||
TS=$(date -u +%Y%m%d-%H%M%S)
|
||||
echo "=== Backup current app ==="
|
||||
sudo tar czf /home/ubuntu/deploy-backups/app-pre-deploy-${TS}.tgz \
|
||||
--exclude='storage' --exclude='vendor' --exclude='node_modules' --exclude='public/build' \
|
||||
-C /var/www/liderra app
|
||||
ls -lh /home/ubuntu/deploy-backups/app-pre-deploy-${TS}.tgz
|
||||
|
||||
echo "=== Extract overlay ==="
|
||||
cd /var/www/liderra
|
||||
sudo tar xzf /tmp/deploy.tgz
|
||||
sudo chown -R www-data:www-data /var/www/liderra/app /var/www/liderra/db
|
||||
|
||||
echo "=== redeploy.sh (composer + migrate + optimize + restart) ==="
|
||||
sudo bash /var/www/liderra/redeploy.sh
|
||||
|
||||
rm -f /tmp/deploy.tgz
|
||||
REMOTE
|
||||
|
||||
- name: Backfill today's snapshot
|
||||
if: ${{ github.event.inputs.backfill_snapshot != 'false' }}
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE'
|
||||
set -e
|
||||
cd /var/www/liderra/app
|
||||
sudo -u www-data php artisan snapshot:backfill --date=$(date +%Y-%m-%d) || \
|
||||
echo "WARN: backfill returned non-zero — проверь вручную"
|
||||
REMOTE
|
||||
|
||||
- name: Smoke tests
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE'
|
||||
set -e
|
||||
cd /var/www/liderra/app
|
||||
echo '=== Migrations status (last 5) ==='
|
||||
sudo -u www-data php artisan migrate:status 2>&1 | tail -5
|
||||
echo '=== Snapshots count (last 3 dates) ==='
|
||||
sudo -u postgres psql -d liderra -c "SELECT snapshot_date, COUNT(*) AS rows FROM project_routing_snapshots GROUP BY 1 ORDER BY 1 DESC LIMIT 3;" || true
|
||||
echo '=== Service status ==='
|
||||
systemctl is-active nginx php8.3-fpm postgresql liderra-queue
|
||||
echo '=== Internal portal health ==='
|
||||
curl -sf -o /dev/null -w 'https=%{http_code} time=%{time_total}s\n' --max-time 8 https://127.0.0.1/ -k || true
|
||||
REMOTE
|
||||
|
||||
- name: External portal health (from runner)
|
||||
run: |
|
||||
curl -sf -o /dev/null -w 'external https=%{http_code} time=%{time_total}s\n' \
|
||||
--max-time 15 https://liderra.ru/ || echo "external health returned non-zero"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,213 +0,0 @@
|
||||
name: Disk-full recovery on liderra.ru
|
||||
|
||||
# Incident response: PG в PANIC loop из-за / диск 100%.
|
||||
# 1) Диагностика: что где лежит (top-20 крупных, du по /var/log)
|
||||
# 2) Безопасная чистка:
|
||||
# - truncate /var/log/postgresql/postgresql-16-main.log (PG в PANIC, не пишет, inode preserved)
|
||||
# - journalctl --vacuum-size=200M
|
||||
# - старые ротированные *.gz логи nginx >7 дней
|
||||
# - apt-get clean
|
||||
# - Laravel storage/logs *.log >7 дней
|
||||
# 3) Final df check + PG probe.
|
||||
#
|
||||
# Триггер: gh workflow run disk-recover.yml -f confirm_apply=true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю удаление логов на проде'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
CONFIRM: ${{ github.event.inputs.confirm_apply }}
|
||||
|
||||
steps:
|
||||
- name: Guard
|
||||
run: |
|
||||
if [[ "$CONFIRM" != "true" ]]; then
|
||||
echo "::error::confirm_apply=true required (this workflow mutates disk on prod)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Diagnose + cleanup
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"bash -s" <<'REMOTE' | tee /tmp/recover.log
|
||||
set +e
|
||||
|
||||
echo "=== A. BEFORE: df -h / ==="
|
||||
df -h / /var /var/lib/postgresql 2>&1 | head -10
|
||||
echo
|
||||
|
||||
echo "=== B. Top-20 largest files in /var (>50M) ==="
|
||||
sudo find /var -xdev -type f -size +50M -printf "%s %p\n" 2>/dev/null | sort -rn | head -20 | awk '{printf "%8.1f MB %s\n", $1/1024/1024, $2}'
|
||||
echo
|
||||
|
||||
echo "=== C. du /var/log/ top-15 directories ==="
|
||||
sudo du -sh /var/log/*/ 2>/dev/null | sort -rh | head -15
|
||||
echo
|
||||
|
||||
echo "=== D. du /var/log/postgresql/* (individual files) ==="
|
||||
sudo du -sh /var/log/postgresql/* 2>/dev/null | sort -rh | head -10
|
||||
echo
|
||||
|
||||
echo "=== E. journalctl disk usage ==="
|
||||
sudo journalctl --disk-usage 2>&1
|
||||
echo
|
||||
|
||||
echo "=== F. /var/lib/postgresql/16/main top-15 subdirs ==="
|
||||
sudo du -sh /var/lib/postgresql/16/main/*/ 2>/dev/null | sort -rh | head -15
|
||||
echo
|
||||
|
||||
echo "=== G. /var/www top-10 if exists ==="
|
||||
sudo du -sh /var/www/*/ 2>/dev/null | sort -rh | head -10
|
||||
sudo du -sh /var/www/lidpotok/storage/logs/ 2>/dev/null
|
||||
echo
|
||||
|
||||
echo "=== H. apt cache + tmp ==="
|
||||
sudo du -sh /var/cache/apt/archives/ /tmp/ /var/tmp/ 2>/dev/null
|
||||
echo
|
||||
|
||||
echo "=========================================="
|
||||
echo "=== STARTING CLEANUP (confirm_apply=true) ==="
|
||||
echo "=========================================="
|
||||
echo
|
||||
|
||||
echo "=== 1a. PRIORITY: Truncate laravel.log (8.7 GB!) and rotated copies ==="
|
||||
for f in /var/www/liderra/app/storage/logs/laravel.log /var/www/liderra/app/storage/logs/laravel.log.1; do
|
||||
if [[ -f "$f" ]]; then
|
||||
BEFORE=$(sudo du -m "$f" | cut -f1)
|
||||
echo "BEFORE: $f = $BEFORE MB"
|
||||
sudo bash -c ": > '$f'" 2>&1 || sudo truncate -s 0 "$f"
|
||||
AFTER=$(sudo du -m "$f" | cut -f1)
|
||||
echo "AFTER: $f = $AFTER MB"
|
||||
fi
|
||||
done
|
||||
# Старые laravel-* (если daily-rotated)
|
||||
sudo find /var/www/liderra/app/storage/logs -name "laravel-*.log" -mtime +3 -print -delete 2>&1 | head -10
|
||||
echo
|
||||
|
||||
echo "=== 1b. Truncate PG audit log via sudo bash redirect (workaround) ==="
|
||||
if [[ -f /var/log/postgresql/postgresql-16-main.log ]]; then
|
||||
BEFORE=$(sudo du -m /var/log/postgresql/postgresql-16-main.log | cut -f1)
|
||||
echo "BEFORE: $BEFORE MB"
|
||||
sudo bash -c ': > /var/log/postgresql/postgresql-16-main.log' 2>&1
|
||||
AFTER=$(sudo du -m /var/log/postgresql/postgresql-16-main.log | cut -f1)
|
||||
echo "AFTER: $AFTER MB"
|
||||
fi
|
||||
sudo find /var/log/postgresql -type f \( -name "*.gz" -o -name "*.log.[0-9]*" \) -delete 2>&1
|
||||
echo
|
||||
|
||||
echo "=== 1c. Truncate syslog (525M) ==="
|
||||
sudo bash -c ': > /var/log/syslog' 2>&1
|
||||
echo "syslog now: $(sudo du -m /var/log/syslog 2>/dev/null | cut -f1) MB"
|
||||
echo
|
||||
|
||||
echo "=== 1d. Remove playwright dev cache (~440M, не нужен в проде) ==="
|
||||
if [[ -d /var/www/.cache/ms-playwright ]]; then
|
||||
sudo du -sh /var/www/.cache/ms-playwright 2>&1
|
||||
sudo rm -rf /var/www/.cache/ms-playwright
|
||||
echo "removed"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== 2. journalctl vacuum --size=200M ==="
|
||||
sudo journalctl --vacuum-size=200M 2>&1 | tail -10
|
||||
echo
|
||||
|
||||
echo "=== 3. nginx old rotated logs (gz files >3 days) ==="
|
||||
sudo find /var/log/nginx -name "*.gz" -mtime +3 -print -delete 2>&1 | head -20
|
||||
echo
|
||||
# current access.log если >500M — truncate (nginx переоткрывает по reopen signal)
|
||||
for f in /var/log/nginx/access.log /var/log/nginx/error.log; do
|
||||
if [[ -f "$f" ]]; then
|
||||
SIZE_MB=$(sudo du -m "$f" | cut -f1)
|
||||
if [[ $SIZE_MB -gt 500 ]]; then
|
||||
echo "Truncating $f ($SIZE_MB MB)"
|
||||
sudo truncate -s 0 "$f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== 4. apt-get clean ==="
|
||||
sudo apt-get clean 2>&1 | tail -5
|
||||
echo
|
||||
|
||||
echo "=== 5. Laravel storage/logs *.log older 7 days ==="
|
||||
if [[ -d /var/www/lidpotok ]]; then
|
||||
sudo find /var/www/lidpotok -path '*/storage/logs/*.log' -mtime +7 -print -delete 2>&1 | head -20
|
||||
fi
|
||||
for d in /var/www/*/; do
|
||||
if [[ -d "$d/storage/logs" ]]; then
|
||||
for f in "$d"/storage/logs/laravel.log "$d"/storage/logs/worker.log; do
|
||||
if [[ -f "$f" ]]; then
|
||||
SIZE_MB=$(sudo du -m "$f" | cut -f1)
|
||||
if [[ $SIZE_MB -gt 200 ]]; then
|
||||
echo "Truncating $f ($SIZE_MB MB)"
|
||||
sudo truncate -s 0 "$f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
echo "=== 6. Old rotated *.1 *.2 *.gz logs >50M anywhere in /var/log ==="
|
||||
sudo find /var/log -type f \( -name "*.1" -o -name "*.2" -o -name "*.3" -o -name "*.gz" \) -size +50M -print -delete 2>&1 | head -20
|
||||
echo
|
||||
|
||||
echo "=========================================="
|
||||
echo "=== AFTER CLEANUP ==="
|
||||
echo "=========================================="
|
||||
echo "=== Z1. df -h / ==="
|
||||
df -h / /var /var/lib/postgresql 2>&1 | head -10
|
||||
echo
|
||||
|
||||
echo "=== Z2. PG status quick check ==="
|
||||
sudo systemctl status postgresql@16-main --no-pager 2>&1 | head -10
|
||||
echo
|
||||
|
||||
echo "=== Z3. PG probe ==="
|
||||
sleep 5
|
||||
sudo -u postgres psql -d liderra -c "SELECT 1 AS probe, NOW() AS ts" 2>&1
|
||||
echo
|
||||
|
||||
echo "=== Z4. HTTPS probe ==="
|
||||
curl -sI -o /dev/null -w "HTTP %{http_code}\nTotal: %{time_total}s\n" https://liderra.ru/ --max-time 10
|
||||
echo
|
||||
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## Disk recovery on liderra.ru"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/recover.log 2>/dev/null || echo "(no log captured)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,109 +0,0 @@
|
||||
name: Disk usage alert (prod liderra.ru)
|
||||
|
||||
# Incident prevention: 29.05.2026 диск заполнился до 100% за сутки → 4h prod downtime.
|
||||
# Этот workflow проверяет df -h / каждые 30 минут.
|
||||
# Threshold: 85% → создаёт row в incidents_log (read by ops monitoring).
|
||||
# 95% → marks как severity=critical для приоритетного alert'а.
|
||||
#
|
||||
# Ref: docs/incidents/2026-05-29-disk-full-pg-recovery.md §5
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 30 minutes (Mondays-Sundays). At :00 и :30 каждого часа UTC.
|
||||
- cron: '*/30 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
threshold:
|
||||
description: 'Override threshold % (default 85)'
|
||||
required: false
|
||||
default: '85'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
THRESHOLD: ${{ github.event.inputs.threshold || '85' }}
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Check disk usage on prod
|
||||
id: check
|
||||
run: |
|
||||
set -o pipefail
|
||||
OUTPUT=$(ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} "df -h / | awk 'NR==2 {gsub(\"%\",\"\",\$5); print \$2\" \"\$3\" \"\$4\" \"\$5}'")
|
||||
read SIZE USED AVAIL PCT <<< "$OUTPUT"
|
||||
echo "size=$SIZE used=$USED avail=$AVAIL pct=$PCT"
|
||||
echo "pct=$PCT" >> $GITHUB_OUTPUT
|
||||
echo "size=$SIZE" >> $GITHUB_OUTPUT
|
||||
echo "used=$USED" >> $GITHUB_OUTPUT
|
||||
echo "avail=$AVAIL" >> $GITHUB_OUTPUT
|
||||
|
||||
if [[ -z "$PCT" ]]; then
|
||||
echo "::error::Could not parse df output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$PCT" -ge 95 ]]; then
|
||||
echo "severity=critical" >> $GITHUB_OUTPUT
|
||||
echo "::error::Disk usage CRITICAL: $PCT% (size=$SIZE used=$USED avail=$AVAIL)"
|
||||
elif [[ "$PCT" -ge "$THRESHOLD" ]]; then
|
||||
echo "severity=warning" >> $GITHUB_OUTPUT
|
||||
echo "::warning::Disk usage HIGH: $PCT% (threshold $THRESHOLD%, size=$SIZE used=$USED avail=$AVAIL)"
|
||||
else
|
||||
echo "severity=ok" >> $GITHUB_OUTPUT
|
||||
echo "::notice::Disk usage OK: $PCT% (size=$SIZE used=$USED avail=$AVAIL)"
|
||||
fi
|
||||
|
||||
- name: Record incident if >= threshold
|
||||
if: steps.check.outputs.severity != 'ok'
|
||||
run: |
|
||||
PCT="${{ steps.check.outputs.pct }}"
|
||||
SIZE="${{ steps.check.outputs.size }}"
|
||||
USED="${{ steps.check.outputs.used }}"
|
||||
AVAIL="${{ steps.check.outputs.avail }}"
|
||||
SEVERITY="${{ steps.check.outputs.severity }}"
|
||||
|
||||
# Note: incidents_log table requires INSERT path through Laravel app.
|
||||
# GitHub Step Summary serves as primary alert; Telegram bot watches
|
||||
# GitHub Actions notifications. Future: extend sql-runner whitelist
|
||||
# для INSERT into incidents_log.
|
||||
{
|
||||
echo "## 🚨 Disk usage alert — severity=$SEVERITY ($PCT%)"
|
||||
echo
|
||||
echo "- Host: ${{ env.LIDERRA_HOST }}"
|
||||
echo "- Filesystem: /"
|
||||
echo "- Size: $SIZE"
|
||||
echo "- Used: $USED"
|
||||
echo "- Available: $AVAIL"
|
||||
echo "- Threshold: ${{ env.THRESHOLD }}%"
|
||||
echo "- Time UTC: $(date -u)"
|
||||
echo
|
||||
echo "**Action required:** Investigate via pg-diagnose.yml workflow."
|
||||
echo
|
||||
echo "Likely causes (from incident 2026-05-29):"
|
||||
echo "- /var/www/liderra/app/storage/logs/laravel.log — Laravel exception accumulation"
|
||||
echo "- /var/log/postgresql/postgresql-16-main.log — pg_audit verbose logging"
|
||||
echo "- /var/log/syslog — kernel + service logs"
|
||||
echo "- /var/www/.cache/ — dev caches leaked to prod"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Fail the job чтобы GitHub Actions подсветило red — это серфисится
|
||||
# через GitHub notifications (email/desktop/telegram bot).
|
||||
if [[ "$SEVERITY" == "critical" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,113 +0,0 @@
|
||||
name: Apply F1 audit-chain advisory-lock migration via postgres superuser
|
||||
|
||||
# Incident response: redeploy.yml fails on F1 migration because crm_migrator role
|
||||
# lacks privilege to CREATE OR REPLACE FUNCTION в schema public.
|
||||
# This workflow applies F1 migration SQL directly via sudo -u postgres psql,
|
||||
# then INSERTs the migration row so subsequent `php artisan migrate` skips it.
|
||||
#
|
||||
# Ref: docs/superpowers/plans/2026-05-29-audit-chain-race-fix.md Task 2
|
||||
# Migration file: app/database/migrations/2026_05_30_000001_add_advisory_lock_to_audit_chain_hash.php
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю применение F1 миграции на проде'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
apply:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
CONFIRM: ${{ github.event.inputs.confirm_apply }}
|
||||
|
||||
steps:
|
||||
- name: Guard
|
||||
run: |
|
||||
if [[ "$CONFIRM" != "true" ]]; then
|
||||
echo "::error::confirm_apply=true required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Apply F1 SQL + register migration
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"bash -s" <<'REMOTE' | tee /tmp/f1-apply.log
|
||||
set +e
|
||||
|
||||
echo "=== 1. BEFORE: current audit_chain_hash function source ==="
|
||||
sudo -u postgres psql -d liderra -c "\df+ public.audit_chain_hash" 2>&1 | head -20
|
||||
|
||||
echo
|
||||
echo "=== 2. Apply F1 advisory-lock migration via sudo -u postgres ==="
|
||||
sudo -u postgres psql -d liderra <<'SQL'
|
||||
CREATE OR REPLACE FUNCTION public.audit_chain_hash() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
prev_hash BYTEA;
|
||||
lock_key BIGINT;
|
||||
BEGIN
|
||||
lock_key := ('x' || lpad(to_hex(TG_RELID::int), 16, '0'))::bit(64)::bigint;
|
||||
PERFORM pg_advisory_xact_lock(lock_key);
|
||||
|
||||
EXECUTE format(
|
||||
'SELECT log_hash FROM %I ORDER BY id DESC LIMIT 1',
|
||||
TG_TABLE_NAME
|
||||
) INTO prev_hash;
|
||||
|
||||
NEW.log_hash := digest(
|
||||
COALESCE(prev_hash, ''::bytea) || NEW::text::bytea,
|
||||
'sha256'
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
SQL
|
||||
APPLY_RC=$?
|
||||
echo "Apply RC: $APPLY_RC"
|
||||
|
||||
echo
|
||||
echo "=== 3. Verify function now contains pg_advisory_xact_lock ==="
|
||||
sudo -u postgres psql -d liderra -c "SELECT pg_get_functiondef('public.audit_chain_hash'::regproc) LIKE '%pg_advisory_xact_lock%' AS has_lock"
|
||||
|
||||
echo
|
||||
echo "=== 4. Register migration row (skip if already exists) ==="
|
||||
sudo -u postgres psql -d liderra <<'SQL'
|
||||
INSERT INTO migrations (migration, batch)
|
||||
SELECT '2026_05_30_000001_add_advisory_lock_to_audit_chain_hash', COALESCE(MAX(batch),0)+1 FROM migrations
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM migrations WHERE migration = '2026_05_30_000001_add_advisory_lock_to_audit_chain_hash'
|
||||
);
|
||||
SELECT migration, batch FROM migrations WHERE migration LIKE '%advisory_lock%';
|
||||
SQL
|
||||
|
||||
echo
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## F1 migration apply"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/f1-apply.log 2>/dev/null || echo "(no log)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,221 +0,0 @@
|
||||
name: Rebuild audit hash chain via postgres superuser (F1 cleanup)
|
||||
|
||||
# Closes deferred F1 item from docs/incidents/2026-05-29-disk-full-pg-recovery.md §4.1.
|
||||
# Sequential hash recomputation в plpgsql DO-блоке через sudo -u postgres psql.
|
||||
# Identical алгоритм с trigger audit_chain_hash() (post-F1 advisory-lock version),
|
||||
# но применённый к existing rows.
|
||||
#
|
||||
# Использование:
|
||||
# gh workflow run f1-rebuild-via-superuser.yml \
|
||||
# -f partition=activity_log_y2026_m05 -f from_id=599 -f confirm_apply=true
|
||||
#
|
||||
# Safety:
|
||||
# - Partition name whitelist (только заранее известные сломанные партиции).
|
||||
# - dry_run=true mode показывает count + anchor prev_hash без UPDATE.
|
||||
# - Trigger audit_chain_hash отключён через SET LOCAL session_replication_role=replica
|
||||
# (постоянный disable невозможен — после COMMIT триггер опять активен).
|
||||
# - audit_block_mutation также подавлен через session_replication_role=replica.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
partition:
|
||||
description: 'Partition name (whitelist: activity_log_y2026_m05, balance_transactions_y2026_m05)'
|
||||
required: true
|
||||
type: string
|
||||
from_id:
|
||||
description: 'First broken id (rebuild from here onward)'
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry-run (показать count + anchor без UPDATE)'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю rebuild на проде (требуется если dry_run=false)'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
rebuild:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
PARTITION: ${{ github.event.inputs.partition }}
|
||||
FROM_ID: ${{ github.event.inputs.from_id }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
CONFIRM: ${{ github.event.inputs.confirm_apply }}
|
||||
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Whitelist partition names (защита от arbitrary table names)
|
||||
ALLOWED='^(activity_log_y2026_m05|balance_transactions_y2026_m05)$'
|
||||
if ! [[ "$PARTITION" =~ $ALLOWED ]]; then
|
||||
echo "::error::partition '$PARTITION' not in whitelist: $ALLOWED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# from_id is positive integer
|
||||
if ! [[ "$FROM_ID" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::from_id must be positive integer, got '$FROM_ID'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" != "true" && "$CONFIRM" != "true" ]]; then
|
||||
echo "::error::Either dry_run=true OR confirm_apply=true must be set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Inputs OK: partition=$PARTITION, from_id=$FROM_ID, dry_run=$DRY_RUN, confirm_apply=$CONFIRM"
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run rebuild on prod
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"PARTITION='$PARTITION' FROM_ID='$FROM_ID' DRY_RUN='$DRY_RUN' bash -s" <<'REMOTE' | tee /tmp/f1-rebuild.log
|
||||
set +e
|
||||
|
||||
echo "=== 1. Anchor + count preview ==="
|
||||
sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1 <<SQL
|
||||
\set partition $PARTITION
|
||||
\set from_id $FROM_ID
|
||||
|
||||
-- Anchor: log_hash of row right BEFORE from_id (=> prev_hash for from_id)
|
||||
SELECT
|
||||
(SELECT id FROM :"partition" WHERE id < :from_id ORDER BY id DESC LIMIT 1) AS anchor_id,
|
||||
encode((SELECT log_hash FROM :"partition" WHERE id < :from_id ORDER BY id DESC LIMIT 1), 'hex') AS anchor_log_hash,
|
||||
(SELECT COUNT(*) FROM :"partition" WHERE id >= :from_id) AS rows_to_rebuild,
|
||||
(SELECT MIN(id) FROM :"partition" WHERE id >= :from_id) AS first_id,
|
||||
(SELECT MAX(id) FROM :"partition" WHERE id >= :from_id) AS last_id;
|
||||
SQL
|
||||
PRE_RC=$?
|
||||
if [[ $PRE_RC -ne 0 ]]; then
|
||||
echo "::error::Pre-check failed (RC=$PRE_RC)"
|
||||
exit $PRE_RC
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo
|
||||
echo "=== DRY RUN — no changes applied ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== 2. APPLY: rebuild hash chain on $PARTITION from id=$FROM_ID ==="
|
||||
# Canonical algorithm (mirrors app/app/Console/Commands/AuditRebuildChain.php):
|
||||
# builds explicit ROW(col1, col2, ..., NULL::bytea on log_hash position, ..., coln)::text::bytea
|
||||
# so hash matches what audit:verify-chains computes (which uses same COLUMN_CONFIG).
|
||||
case "$PARTITION" in
|
||||
activity_log_*)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.user_id, t.deal_id, t.event, t.old_value, t.new_value, t.context, t.ip_address, t.user_agent, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
balance_transactions_*)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.type, t.amount_rub, t.amount_leads, t.balance_rub_after, t.balance_leads_after, t.description, t.related_type, t.related_id, t.user_id, t.admin_user_id, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown partition family — add ROW_EXPR mapping"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "Using ROW expression: $ROW_EXPR"
|
||||
|
||||
sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1 <<SQL
|
||||
BEGIN;
|
||||
SET LOCAL session_replication_role = 'replica';
|
||||
|
||||
DO \$rebuild\$
|
||||
DECLARE
|
||||
cur_id BIGINT;
|
||||
prev_hash BYTEA;
|
||||
new_hash BYTEA;
|
||||
cnt INTEGER := 0;
|
||||
partition_name TEXT := '$PARTITION';
|
||||
start_id BIGINT := $FROM_ID;
|
||||
row_expr TEXT := '$ROW_EXPR';
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'SELECT log_hash FROM %I WHERE id < \$1 ORDER BY id DESC LIMIT 1',
|
||||
partition_name
|
||||
)
|
||||
INTO prev_hash
|
||||
USING start_id;
|
||||
|
||||
RAISE NOTICE 'Anchor prev_hash: %', COALESCE(encode(prev_hash, 'hex'), '<NULL — start of chain>');
|
||||
|
||||
FOR cur_id IN
|
||||
EXECUTE format(
|
||||
'SELECT id FROM %I WHERE id >= \$1 ORDER BY id',
|
||||
partition_name
|
||||
)
|
||||
USING start_id
|
||||
LOOP
|
||||
-- Compute new_hash with explicit ROW(...) expression (canonical, matches verify-chains)
|
||||
EXECUTE format(
|
||||
'SELECT digest(COALESCE(\$1, ''''::bytea) || %s::text::bytea, ''sha256'') FROM %I t WHERE id = \$2',
|
||||
row_expr, partition_name
|
||||
)
|
||||
INTO new_hash
|
||||
USING prev_hash, cur_id;
|
||||
|
||||
EXECUTE format('UPDATE %I SET log_hash = \$1 WHERE id = \$2', partition_name)
|
||||
USING new_hash, cur_id;
|
||||
|
||||
prev_hash := new_hash;
|
||||
cnt := cnt + 1;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Rebuilt % rows. Last log_hash: %', cnt, encode(prev_hash, 'hex');
|
||||
END
|
||||
\$rebuild\$;
|
||||
|
||||
COMMIT;
|
||||
SQL
|
||||
APPLY_RC=$?
|
||||
|
||||
echo
|
||||
echo "=== 3. Verify: no NULL log_hash в обновлённых строках ==="
|
||||
sudo -u postgres psql -d liderra <<SQL
|
||||
\set partition $PARTITION
|
||||
\set from_id $FROM_ID
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE log_hash IS NULL) AS null_count,
|
||||
COUNT(*) AS total,
|
||||
MIN(id) AS first_id,
|
||||
MAX(id) AS last_id
|
||||
FROM :"partition"
|
||||
WHERE id >= :from_id;
|
||||
SQL
|
||||
|
||||
echo
|
||||
echo "=== Apply RC: $APPLY_RC ==="
|
||||
exit $APPLY_RC
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## F1 chain rebuild — $PARTITION (from_id=$FROM_ID, dry_run=$DRY_RUN)"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/f1-rebuild.log 2>/dev/null || echo "(no log)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,393 +0,0 @@
|
||||
name: Lead region — prod ops
|
||||
|
||||
# Самодостаточный launch-инструмент фичи lead-region-resolution.
|
||||
# Один воркфлоу, переключатель op. НЕ трогает deploy.yml / artisan-run.yml.
|
||||
#
|
||||
# op:
|
||||
# pre-migrate — пред-применить миграцию 2026_05_31_100000 через postgres
|
||||
# superuser (crm_app_user не член crm_migrator → обычный migrate
|
||||
# падает) + пометить применённой, чтобы deploy её пропустил.
|
||||
# set-env — записать DADATA-ключи (из secrets) + LEAD_REGION_RESOLVER_ENABLED
|
||||
# (input flag) в боевой .env, перекэшировать config, рестарт очереди.
|
||||
# fetch-rossvyaz — скачать файл/архив реестра (input url) на прод в /var/www/liderra/rossvyaz.
|
||||
# import — phone-ranges:import (input dry_run) под www-data (DDL-свап идёт
|
||||
# через pgsql_supplier = crm_supplier_worker, член crm_migrator).
|
||||
# smoke — phone-region:smoke --phone=<input phone> под www-data (нужны ключи).
|
||||
#
|
||||
# Secrets: LIDERRA_SSH_KEY, DADATA_API_KEY, DADATA_SECRET.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
op:
|
||||
description: 'Операция'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- pre-migrate
|
||||
- set-env
|
||||
- fetch-rossvyaz
|
||||
- fetch-via-runner
|
||||
- deliver-from-repo
|
||||
- import
|
||||
- smoke
|
||||
flag:
|
||||
description: 'set-env: LEAD_REGION_RESOLVER_ENABLED'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
url:
|
||||
description: 'fetch-rossvyaz: прямая ссылка на CSV/ZIP реестра Россвязи'
|
||||
required: false
|
||||
type: string
|
||||
dir:
|
||||
description: 'import: каталог с CSV на проде'
|
||||
required: false
|
||||
default: '/var/www/liderra/rossvyaz'
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'import: только staging без swap'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
phone:
|
||||
description: 'smoke: телефон'
|
||||
required: false
|
||||
default: '79161234567'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
op:
|
||||
name: ${{ github.event.inputs.op }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: liderra-prod-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
APP_DIR: /var/www/liderra/app
|
||||
OP: ${{ github.event.inputs.op }}
|
||||
FLAG: ${{ github.event.inputs.flag }}
|
||||
URL: ${{ github.event.inputs.url }}
|
||||
DIR: ${{ github.event.inputs.dir }}
|
||||
DRY: ${{ github.event.inputs.dry_run }}
|
||||
PHONE: ${{ github.event.inputs.phone }}
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H "${LIDERRA_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Checkout repo (for deliver-from-repo)
|
||||
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: op=pre-migrate (superuser DDL + mark applied)
|
||||
if: ${{ github.event.inputs.op == 'pre-migrate' }}
|
||||
run: |
|
||||
SQL_B64=$(cat <<'SQLEOF' | base64 -w0
|
||||
BEGIN;
|
||||
-- 1. phone_ranges_imports (FK target — создаём первым)
|
||||
CREATE TABLE phone_ranges_imports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source_url TEXT NOT NULL,
|
||||
rows_inserted INTEGER NOT NULL DEFAULT 0,
|
||||
rows_updated INTEGER NOT NULL DEFAULT 0,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
|
||||
error TEXT,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE phone_ranges_imports IS
|
||||
'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';
|
||||
|
||||
-- 2. phone_ranges (реестр диапазонов; SaaS-level, без RLS — публичные данные)
|
||||
CREATE TABLE phone_ranges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
def_code SMALLINT NOT NULL,
|
||||
from_num BIGINT NOT NULL,
|
||||
to_num BIGINT NOT NULL,
|
||||
operator TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
region_normalized TEXT,
|
||||
subject_code SMALLINT,
|
||||
imported_at TIMESTAMPTZ NOT NULL,
|
||||
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
|
||||
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
|
||||
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
|
||||
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
|
||||
);
|
||||
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
|
||||
COMMENT ON TABLE phone_ranges IS
|
||||
'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver.';
|
||||
|
||||
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
|
||||
|
||||
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at)
|
||||
CREATE TABLE lead_region_resolution_log (
|
||||
id BIGSERIAL,
|
||||
supplier_lead_id BIGINT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL,
|
||||
phone_masked TEXT NOT NULL,
|
||||
subject_code_resolved SMALLINT,
|
||||
subject_code_from_tag SMALLINT,
|
||||
region_source TEXT NOT NULL
|
||||
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
dadata_qc SMALLINT,
|
||||
dadata_provider TEXT,
|
||||
dadata_type TEXT,
|
||||
dadata_response_masked JSONB,
|
||||
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
actual_subject_code SMALLINT
|
||||
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
|
||||
substituted_subject_code SMALLINT
|
||||
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
|
||||
routing_step SMALLINT
|
||||
CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
|
||||
phone_operator TEXT,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
duration_ms INTEGER,
|
||||
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, received_at)
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
|
||||
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
|
||||
COMMENT ON TABLE lead_region_resolution_log IS
|
||||
'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно.';
|
||||
|
||||
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
|
||||
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
|
||||
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m05
|
||||
PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m06
|
||||
PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
|
||||
-- 4. supplier_leads: +4 колонки
|
||||
ALTER TABLE supplier_leads
|
||||
ADD COLUMN resolved_subject_code SMALLINT
|
||||
CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
|
||||
ADD COLUMN region_source TEXT
|
||||
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
ADD COLUMN dadata_qc SMALLINT,
|
||||
ADD COLUMN phone_operator TEXT;
|
||||
|
||||
-- 5. deals: +2 колонки
|
||||
ALTER TABLE deals
|
||||
ADD COLUMN phone_operator TEXT,
|
||||
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- ownership как у миграции (она шла бы под crm_migrator)
|
||||
ALTER TABLE phone_ranges_imports OWNER TO crm_migrator;
|
||||
ALTER TABLE phone_ranges OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log_y2026_m05 OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log_y2026_m06 OWNER TO crm_migrator;
|
||||
|
||||
-- retention (system_settings, 12 мес)
|
||||
INSERT INTO system_settings (key, value, type, description, updated_at)
|
||||
SELECT 'partition_retention_months_lead_region_resolution_log', '12', 'int',
|
||||
'Retention в месяцах для lead_region_resolution_log (~365 дней)', NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_settings
|
||||
WHERE key = 'partition_retention_months_lead_region_resolution_log');
|
||||
COMMIT;
|
||||
SQLEOF
|
||||
)
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" "SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
MIG_NAME='2026_05_31_100000_create_phone_ranges_and_resolution_log'
|
||||
ALREADY=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM migrations WHERE migration='${MIG_NAME}' LIMIT 1")
|
||||
if [ "${ALREADY}" = "1" ]; then
|
||||
echo "Migration ${MIG_NAME} уже применена — пропускаю."
|
||||
exit 0
|
||||
fi
|
||||
TABLE_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM information_schema.tables WHERE table_name='phone_ranges' LIMIT 1")
|
||||
if [ "${TABLE_EXISTS}" != "1" ]; then
|
||||
echo "Применяю lead-region DDL через postgres superuser..."
|
||||
echo "$SQL_B64" | base64 -d | sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1
|
||||
else
|
||||
echo "Таблица phone_ranges уже существует — только помечаю миграцию."
|
||||
fi
|
||||
NEXT_BATCH=$(sudo -u postgres psql -d liderra -tAc "SELECT COALESCE(MAX(batch),0)+1 FROM migrations")
|
||||
sudo -u postgres psql -d liderra -c \
|
||||
"INSERT INTO migrations (migration, batch) SELECT '${MIG_NAME}', ${NEXT_BATCH} WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration='${MIG_NAME}')"
|
||||
echo "Помечено ${MIG_NAME} применённой (batch ${NEXT_BATCH})."
|
||||
echo "=== Проверка таблиц ==="
|
||||
sudo -u postgres psql -d liderra -c "\dt phone_ranges|phone_ranges_imports|lead_region_resolution_log" || true
|
||||
REMOTE
|
||||
|
||||
- name: op=set-env (keys from secrets + flag → prod .env)
|
||||
if: ${{ github.event.inputs.op == 'set-env' }}
|
||||
env:
|
||||
DK: ${{ secrets.DADATA_API_KEY }}
|
||||
DS: ${{ secrets.DADATA_SECRET }}
|
||||
run: |
|
||||
DK_B64=$(printf '%s' "$DK" | base64 -w0)
|
||||
DS_B64=$(printf '%s' "$DS" | base64 -w0)
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"DK_B64='$DK_B64' DS_B64='$DS_B64' FLAG='$FLAG' APP_DIR='$APP_DIR' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
ENV="${APP_DIR}/.env"
|
||||
DK=$(echo "$DK_B64" | base64 -d)
|
||||
DS=$(echo "$DS_B64" | base64 -d)
|
||||
upsert() {
|
||||
local key="$1" val="$2"
|
||||
sudo sed -i "/^${key}=/d" "$ENV"
|
||||
echo "${key}=${val}" | sudo tee -a "$ENV" >/dev/null
|
||||
}
|
||||
upsert DADATA_API_KEY "$DK"
|
||||
upsert DADATA_SECRET "$DS"
|
||||
upsert LEAD_REGION_RESOLVER_ENABLED "$FLAG"
|
||||
cd "$APP_DIR"
|
||||
sudo -u www-data php artisan config:clear
|
||||
sudo -u www-data php artisan config:cache
|
||||
sudo systemctl restart liderra-queue
|
||||
echo "set-env готово: flag=${FLAG}, ключи записаны."
|
||||
echo "=== Проверка (значения скрыты) ==="
|
||||
sudo grep -E '^(DADATA_API_KEY|DADATA_SECRET|LEAD_REGION_RESOLVER_ENABLED)=' "$ENV" | sed -E 's/=(.).*/=\1***/'
|
||||
echo "=== queue status ==="
|
||||
systemctl is-active liderra-queue || true
|
||||
REMOTE
|
||||
|
||||
- name: op=fetch-rossvyaz (download registry on prod)
|
||||
if: ${{ github.event.inputs.op == 'fetch-rossvyaz' }}
|
||||
run: |
|
||||
# Пустой url → качаем все 4 официальных файла Минцифры за один прогон.
|
||||
# Непустой url → качаем только его (ручной режим).
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"URL='$URL' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
DEST=/var/www/liderra/rossvyaz
|
||||
sudo mkdir -p "$DEST"
|
||||
cd "$DEST"
|
||||
if [ -n "$URL" ]; then
|
||||
URLS="$URL"
|
||||
else
|
||||
URLS="https://opendata.digital.gov.ru/downloads/DEF-9xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-3xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-4xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-8xx.csv"
|
||||
fi
|
||||
for U in $URLS; do
|
||||
FNAME=$(basename "${U%%\?*}")
|
||||
[ -n "$FNAME" ] || FNAME="rossvyaz-download"
|
||||
echo "Скачиваю $U -> $FNAME"
|
||||
sudo curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,application/octet-stream,*/*' -H 'Accept-Language: ru-RU,ru;q=0.9' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FNAME" "$U"
|
||||
case "$FNAME" in
|
||||
*.zip|*.ZIP) echo "Распаковываю zip..."; sudo unzip -o "$FNAME" ;;
|
||||
esac
|
||||
done
|
||||
sudo chown -R www-data:www-data "$DEST"
|
||||
echo "=== Содержимое $DEST ==="
|
||||
ls -lh "$DEST"
|
||||
FIRST_CSV=$(ls "$DEST"/DEF-9xx.csv "$DEST"/*.csv "$DEST"/*.CSV 2>/dev/null | head -1 || true)
|
||||
if [ -n "$FIRST_CSV" ]; then
|
||||
echo "=== Первые строки $FIRST_CSV (cp1251→utf8) ==="
|
||||
sudo head -3 "$FIRST_CSV" | iconv -f cp1251 -t utf-8 2>/dev/null || sudo head -3 "$FIRST_CSV"
|
||||
fi
|
||||
REMOTE
|
||||
|
||||
- name: op=fetch-via-runner (download on runner, ship to prod)
|
||||
if: ${{ github.event.inputs.op == 'fetch-via-runner' }}
|
||||
run: |
|
||||
mkdir -p /tmp/rv && cd /tmp/rv && rm -f /tmp/rv/*.csv
|
||||
for U in https://opendata.digital.gov.ru/downloads/DEF-9xx.csv https://opendata.digital.gov.ru/downloads/ABC-3xx.csv https://opendata.digital.gov.ru/downloads/ABC-4xx.csv https://opendata.digital.gov.ru/downloads/ABC-8xx.csv; do
|
||||
FN=$(basename "${U%%\?*}")
|
||||
echo "runner: скачиваю $U -> $FN"
|
||||
curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,*/*' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FN" "$U"
|
||||
done
|
||||
echo "=== скачано на runner ==="
|
||||
ls -lh /tmp/rv | tee /tmp/op.log
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*.csv'
|
||||
scp -i ~/.ssh/liderra_deploy /tmp/rv/*.csv "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'sudo mkdir -p /var/www/liderra/rossvyaz && sudo mv /tmp/rvup/*.csv /var/www/liderra/rossvyaz/ && sudo chown -R www-data:www-data /var/www/liderra/rossvyaz && echo "=== на проде /var/www/liderra/rossvyaz ===" && ls -lh /var/www/liderra/rossvyaz' | tee -a /tmp/op.log
|
||||
|
||||
- name: op=deliver-from-repo (scp repo CSV/ZIP to prod, unzip there)
|
||||
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
|
||||
run: |
|
||||
# Ищем файлы реестра где угодно (корень или папка), .csv или .zip
|
||||
mapfile -t FILES < <(find . -maxdepth 2 -type f \( \( -iname 'DEF-9xx*' -o -iname 'ABC-3xx*' -o -iname 'ABC-4xx*' -o -iname 'ABC-8xx*' \) -iname '*.csv' -o -iname '*.zip' \) ! -path './.git/*')
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
echo "::error::Не нашёл файлов реестра (DEF-9xx/ABC-*.csv|zip) ни в корне, ни в rossvyaz-data/. Проверь, что они закоммичены в репозиторий."; exit 1
|
||||
fi
|
||||
echo "=== файлы в репозитории (rossvyaz-data/) ==="
|
||||
ls -lh "${FILES[@]}" | tee /tmp/op.log
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*'
|
||||
scp -i ~/.ssh/liderra_deploy "${FILES[@]}" "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" '
|
||||
cd /tmp/rvup
|
||||
for z in *.zip *.ZIP; do if [ -e "$z" ]; then echo "распаковываю $z"; unzip -o "$z"; rm -f "$z"; fi; done
|
||||
sudo mkdir -p /var/www/liderra/rossvyaz
|
||||
find . -iname "*.csv" -exec sudo mv {} /var/www/liderra/rossvyaz/ \;
|
||||
sudo chown -R www-data:www-data /var/www/liderra/rossvyaz
|
||||
echo "=== на проде /var/www/liderra/rossvyaz ==="
|
||||
ls -lh /var/www/liderra/rossvyaz
|
||||
' | tee -a /tmp/op.log
|
||||
|
||||
- name: op=import (phone-ranges:import)
|
||||
if: ${{ github.event.inputs.op == 'import' }}
|
||||
run: |
|
||||
DRY_FLAG=""
|
||||
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -e
|
||||
cd "$APP_DIR"
|
||||
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ==="
|
||||
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG 2>&1
|
||||
echo "=== Счётчики ==="
|
||||
sudo -u postgres psql -d liderra -c "SELECT count(*) AS phone_ranges FROM phone_ranges" 2>&1 || true
|
||||
# staging-счётчик: 2 отдельных запроса, чтобы Postgres не парсил
|
||||
# подзапрос к phone_ranges_staging, когда таблица уже свапнута (иначе
|
||||
# ERROR relation "phone_ranges_staging" does not exist даже в ветке CASE).
|
||||
STAGING_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT to_regclass('phone_ranges_staging') IS NOT NULL")
|
||||
if [ "$STAGING_EXISTS" = "t" ]; then
|
||||
sudo -u postgres psql -d liderra -c "SELECT count(*) AS staging_rows FROM phone_ranges_staging" 2>&1 || true
|
||||
else
|
||||
echo "staging: отсутствует (после свапа — норма)"
|
||||
fi
|
||||
echo "=== Последний импорт ==="
|
||||
sudo -u postgres psql -d liderra -c \
|
||||
"SELECT id, status, rows_inserted, rows_updated, imported_at FROM phone_ranges_imports ORDER BY id DESC LIMIT 3" 2>&1 || true
|
||||
REMOTE
|
||||
|
||||
- name: op=smoke (phone-region:smoke)
|
||||
if: ${{ github.event.inputs.op == 'smoke' }}
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"APP_DIR='$APP_DIR' PHONE='$PHONE' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -e
|
||||
cd "$APP_DIR"
|
||||
echo "=== phone-region:smoke --phone=${PHONE} ==="
|
||||
sudo -u www-data php artisan phone-region:smoke --phone="$PHONE" 2>&1
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## lead-region-ops: \`${OP}\`"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/op.log 2>/dev/null || echo "(нет вывода)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,96 +0,0 @@
|
||||
name: Diagnose PostgreSQL state on liderra.ru
|
||||
|
||||
# Read-only diagnostic для incident "PG не принимает connections".
|
||||
# Запускается вручную: gh workflow run pg-diagnose.yml --ref <branch>
|
||||
# Ничего не меняет на проде — только читает systemctl/journalctl/df/free/uptime
|
||||
# + tail последних 200 строк postgresql-16-main.log.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
diagnose:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run PG diagnostic on prod
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"bash -s" <<'REMOTE' | tee /tmp/pg-diagnose.log
|
||||
set +e
|
||||
echo "=== 1. hostname + UTC time ==="
|
||||
echo "host=$(hostname); utc=$(date -u)"
|
||||
echo
|
||||
echo "=== 2. uptime ==="
|
||||
uptime
|
||||
echo
|
||||
echo "=== 3. last reboot ==="
|
||||
who -b
|
||||
last reboot --time-format=iso | head -5
|
||||
echo
|
||||
echo "=== 4. df -h / and /var ==="
|
||||
df -h / /var /var/lib/postgresql 2>&1 | head -10
|
||||
echo
|
||||
echo "=== 5. free -h ==="
|
||||
free -h
|
||||
echo
|
||||
echo "=== 6. systemctl status postgresql ==="
|
||||
sudo systemctl status postgresql --no-pager 2>&1 | head -30
|
||||
echo
|
||||
echo "=== 7. systemctl status postgresql@16-main (cluster) ==="
|
||||
sudo systemctl status postgresql@16-main --no-pager 2>&1 | head -30
|
||||
echo
|
||||
echo "=== 8. nginx + php-fpm status (one-line each) ==="
|
||||
sudo systemctl is-active nginx php8.3-fpm liderra-queue 2>&1
|
||||
echo
|
||||
echo "=== 9. ps aux | postgres (top 15) ==="
|
||||
ps auxf | grep -E "(postgres|recovery)" | grep -v grep | head -15
|
||||
echo
|
||||
echo "=== 10. journalctl postgresql last 80 lines ==="
|
||||
sudo journalctl -u postgresql -n 80 --no-pager 2>&1 | tail -80
|
||||
echo
|
||||
echo "=== 11. journalctl postgresql@16-main last 80 lines ==="
|
||||
sudo journalctl -u postgresql@16-main -n 80 --no-pager 2>&1 | tail -80
|
||||
echo
|
||||
echo "=== 12. tail -100 /var/log/postgresql/postgresql-16-main.log ==="
|
||||
sudo tail -100 /var/log/postgresql/postgresql-16-main.log 2>&1
|
||||
echo
|
||||
echo "=== 13. WAL size and count ==="
|
||||
sudo du -sh /var/lib/postgresql/16/main/pg_wal 2>&1
|
||||
sudo ls /var/lib/postgresql/16/main/pg_wal 2>&1 | wc -l
|
||||
echo
|
||||
echo "=== 14. dmesg tail (kernel events, OOM, IO errors) ==="
|
||||
sudo dmesg -T 2>&1 | tail -40
|
||||
echo
|
||||
echo "=== 15. liderra.ru HTTPS probe ==="
|
||||
curl -sI -o /dev/null -w "HTTP %{http_code}\nTotal: %{time_total}s\n" https://liderra.ru/ --max-time 10
|
||||
echo
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## PG diagnostic on liderra.ru"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/pg-diagnose.log 2>/dev/null || echo "(no log captured)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,192 +0,0 @@
|
||||
name: Pre-deploy validation (8 checks)
|
||||
|
||||
# Цель: воспроизвести 8 проверок project-local агента `prod-deploy-validator`
|
||||
# (#85) через GitHub Actions Azure runner — обход YC backbone-фильтра,
|
||||
# который блокирует direct SSH с dev-IP 89.144.17.119.
|
||||
#
|
||||
# Запускается вручную: gh workflow run pre-deploy-checks.yml
|
||||
# Read-only — ничего не меняет на проде.
|
||||
#
|
||||
# 8 checks (per Pravila §2.4 / agent .claude/agents/prod-deploy-validator.md):
|
||||
# 1. config:cache владелец (quirk 107 — должен быть www-data:www-data, не root)
|
||||
# 2. .env line endings (CRLF → артефакты)
|
||||
# 3. свободное место (< 80% использовано)
|
||||
# 4. свежесть бэкапа БД (≤ 24ч)
|
||||
# 5. health очереди liderra-queue (active + queue length < 1000)
|
||||
# 6. nginx syntax (nginx -t)
|
||||
# 7. fail2ban active (service running)
|
||||
# 8. pending миграции (php artisan migrate:status — для текущего deploy ожидается 0)
|
||||
#
|
||||
# Использует тот же LIDERRA_SSH_KEY что и deploy.yml.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
APP_DIR: /var/www/liderra/app
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run 8 pre-flight checks on prod
|
||||
id: checks
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"APP_DIR='${APP_DIR}' bash -s" <<'REMOTE' | tee /tmp/preflight.log
|
||||
set +e
|
||||
FAILS=0
|
||||
|
||||
echo "=== Check 1: config:cache file owner (quirk 107) ==="
|
||||
CFG_FILE="${APP_DIR}/bootstrap/cache/config.php"
|
||||
if sudo test -f "$CFG_FILE"; then
|
||||
OWNER=$(sudo stat -c '%U:%G' "$CFG_FILE")
|
||||
echo " Owner: $OWNER"
|
||||
if [ "$OWNER" = "www-data:www-data" ]; then
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
echo " ✗ FAIL — expected www-data:www-data (quirk 107: prod incident 24.05.2026)"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
else
|
||||
echo " ~ SKIP — config.php не существует (будет создан deploy'ем)"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 2: .env line endings (no CRLF) ==="
|
||||
ENV_FILE="${APP_DIR}/.env"
|
||||
if sudo test -f "$ENV_FILE"; then
|
||||
CRLF_COUNT=$(sudo grep -c $'\r' "$ENV_FILE" 2>/dev/null || echo "0")
|
||||
echo " CRLF chars: $CRLF_COUNT"
|
||||
if [ "$CRLF_COUNT" = "0" ]; then
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
echo " ✗ FAIL — .env содержит CRLF ($CRLF_COUNT строк)"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
else
|
||||
echo " ✗ FAIL — .env not found"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 3: free disk space (< 80% used) ==="
|
||||
DF_USED=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
|
||||
echo " Used: ${DF_USED}%"
|
||||
if [ "$DF_USED" -lt 80 ]; then
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
echo " ✗ FAIL — корневой раздел ${DF_USED}% (>=80%)"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 4: pre-deploy backup freshness (≤ 24h) ==="
|
||||
# deploy.yml saves app pre-deploy backups to /home/ubuntu/deploy-backups/
|
||||
BACKUP_DIR="/home/ubuntu/deploy-backups"
|
||||
if sudo test -d "$BACKUP_DIR"; then
|
||||
LATEST=$(sudo find "$BACKUP_DIR" -name 'app-pre-deploy-*.tgz' -mmin -1440 2>/dev/null | sort -r | head -1)
|
||||
if [ -n "$LATEST" ]; then
|
||||
MTIME=$(sudo stat -c '%y' "$LATEST" 2>/dev/null)
|
||||
echo " Latest: $LATEST ($MTIME)"
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
ANY_LATEST=$(sudo find "$BACKUP_DIR" -name 'app-pre-deploy-*.tgz' 2>/dev/null | sort -r | head -1)
|
||||
if [ -n "$ANY_LATEST" ]; then
|
||||
ANY_MTIME=$(sudo stat -c '%y' "$ANY_LATEST" 2>/dev/null)
|
||||
echo " i NOTE — backups exist но >24h ($ANY_LATEST, $ANY_MTIME). Не блокер deploy'а — deploy.yml сам делает свежий backup перед раскаткой."
|
||||
else
|
||||
echo " i NOTE — нет pre-deploy бэкапов в $BACKUP_DIR. Не блокер — deploy.yml создаст backup сам."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " i NOTE — backup dir $BACKUP_DIR не существует (первый deploy?). deploy.yml создаст dir."
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 5: queue health (liderra-queue active + depth) ==="
|
||||
QUEUE_STATUS=$(systemctl is-active liderra-queue 2>&1)
|
||||
echo " Service: $QUEUE_STATUS"
|
||||
if [ "$QUEUE_STATUS" = "active" ]; then
|
||||
echo " ✓ PASS (service active)"
|
||||
else
|
||||
echo " ✗ FAIL — liderra-queue не active"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
# NB: queue depth check would need Redis access; skipped (not critical for this deploy)
|
||||
echo
|
||||
|
||||
echo "=== Check 6: nginx syntax ==="
|
||||
NGINX_TEST=$(sudo nginx -t 2>&1)
|
||||
echo "$NGINX_TEST" | sed 's/^/ /'
|
||||
if echo "$NGINX_TEST" | grep -q "syntax is ok" && echo "$NGINX_TEST" | grep -q "test is successful"; then
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
echo " ✗ FAIL — nginx syntax error"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 7: fail2ban active ==="
|
||||
F2B_STATUS=$(systemctl is-active fail2ban 2>&1)
|
||||
echo " Service: $F2B_STATUS"
|
||||
if [ "$F2B_STATUS" = "active" ]; then
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
echo " ✗ FAIL — fail2ban не active"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 8: pending migrations ==="
|
||||
cd "${APP_DIR}"
|
||||
MIG_STATUS=$(sudo -u www-data php artisan migrate:status 2>&1)
|
||||
PENDING=$(echo "$MIG_STATUS" | grep -c "Pending")
|
||||
echo " Pending count: $PENDING"
|
||||
if [ "$PENDING" = "0" ]; then
|
||||
echo " ✓ PASS — 0 pending migrations"
|
||||
else
|
||||
echo " i NOTE — $PENDING pending migrations (deploy.yml runs them automatically)"
|
||||
# NB: Pending miграции — это НЕ FAIL для этого deploy (план не включает миграции;
|
||||
# deploy.yml выполнит их сам). Помечается как INFO, не FAIL.
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== SUMMARY ==="
|
||||
echo "Total failures: $FAILS"
|
||||
if [ "$FAILS" = "0" ]; then
|
||||
echo "VERDICT: GO"
|
||||
exit 0
|
||||
else
|
||||
echo "VERDICT: NO-GO ($FAILS check(s) failed)"
|
||||
exit 1
|
||||
fi
|
||||
REMOTE
|
||||
REMOTE_EXIT=$?
|
||||
echo "remote_exit=$REMOTE_EXIT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## Pre-deploy 8-check validation for liderra.ru"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/preflight.log 2>/dev/null || echo "(no log captured)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,167 +0,0 @@
|
||||
name: Setup logrotate for Laravel logs (incident prevention)
|
||||
|
||||
# Incident response prevention: 8.7G laravel.log заполнил диск (29.05.2026).
|
||||
# Существующий daily rotation (laravel.log.1) недостаточен — за один день шторма
|
||||
# accumulated 8.7G. Нужна size-based rotation с лимитом.
|
||||
#
|
||||
# This workflow installs /etc/logrotate.d/laravel-liderra config:
|
||||
# - size 50M (rotate when file >= 50MB, не daily)
|
||||
# - rotate 5 (keep 5 rotated copies)
|
||||
# - compress (gzip rotated files)
|
||||
# - copytruncate (atomic copy + truncate inode-preserving, Laravel handle continues)
|
||||
# - notifempty (skip if empty)
|
||||
# - su www-data www-data (correct ownership)
|
||||
#
|
||||
# Тестируется logrotate --debug сразу после установки.
|
||||
#
|
||||
# Ref: root-cause analysis incident 2026-05-29
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю установку logrotate конфига на проде'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
CONFIRM: ${{ github.event.inputs.confirm_apply }}
|
||||
|
||||
steps:
|
||||
- name: Guard
|
||||
run: |
|
||||
if [[ "$CONFIRM" != "true" ]]; then
|
||||
echo "::error::confirm_apply=true required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Install logrotate config + verify
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"bash -s" <<'REMOTE' | tee /tmp/logrotate-setup.log
|
||||
set +e
|
||||
|
||||
echo "=== 1. Discover Laravel logs path ==="
|
||||
LARAVEL_LOG_DIR=""
|
||||
for candidate in /var/www/liderra/app/storage/logs /var/www/lidpotok/storage/logs; do
|
||||
if [[ -d "$candidate" ]]; then
|
||||
LARAVEL_LOG_DIR="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo "LARAVEL_LOG_DIR=$LARAVEL_LOG_DIR"
|
||||
if [[ -z "$LARAVEL_LOG_DIR" ]]; then
|
||||
echo "::error::Cannot find Laravel logs directory"
|
||||
exit 1
|
||||
fi
|
||||
echo "Current sizes:"
|
||||
sudo du -sh "$LARAVEL_LOG_DIR"/*.log 2>/dev/null | head -10
|
||||
|
||||
echo
|
||||
echo "=== 2. Write logrotate config to /etc/logrotate.d/laravel-liderra ==="
|
||||
sudo tee /etc/logrotate.d/laravel-liderra > /dev/null <<EOF
|
||||
$LARAVEL_LOG_DIR/*.log {
|
||||
size 50M
|
||||
rotate 5
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
su www-data www-data
|
||||
create 0644 www-data www-data
|
||||
}
|
||||
EOF
|
||||
echo "Wrote config:"
|
||||
sudo cat /etc/logrotate.d/laravel-liderra
|
||||
sudo chmod 0644 /etc/logrotate.d/laravel-liderra
|
||||
|
||||
echo
|
||||
echo "=== 3. Verify config syntax via logrotate --debug ==="
|
||||
sudo logrotate --debug /etc/logrotate.d/laravel-liderra 2>&1 | head -30
|
||||
|
||||
echo
|
||||
echo "=== 4. Trigger rotation now (--force) for clean state ==="
|
||||
sudo logrotate --force /etc/logrotate.d/laravel-liderra 2>&1 | tail -10
|
||||
|
||||
echo
|
||||
echo "=== 5. PostgreSQL log rotation config ==="
|
||||
# Default Ubuntu postgresql-common rotates daily without size cap.
|
||||
# We override with size 100M / rotate 7 / postrotate SIGHUP (PG reopens log).
|
||||
# Higher alpha order than postgresql-common → processed later → wins on same files.
|
||||
sudo tee /etc/logrotate.d/postgresql-liderra > /dev/null <<EOF
|
||||
/var/log/postgresql/*.log {
|
||||
su postgres postgres
|
||||
size 100M
|
||||
rotate 7
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 0640 postgres adm
|
||||
sharedscripts
|
||||
postrotate
|
||||
# SIGHUP postmaster для re-open log file (standard PG idiom).
|
||||
# PG holds log file handle open — без SIGHUP write goes to old (deleted) inode.
|
||||
if [ -f /var/run/postgresql/16-main.pid ]; then
|
||||
kill -HUP \$(cat /var/run/postgresql/16-main.pid) 2>/dev/null || true
|
||||
fi
|
||||
endscript
|
||||
}
|
||||
EOF
|
||||
echo "Wrote /etc/logrotate.d/postgresql-liderra:"
|
||||
sudo cat /etc/logrotate.d/postgresql-liderra
|
||||
sudo chmod 0644 /etc/logrotate.d/postgresql-liderra
|
||||
|
||||
echo
|
||||
echo "=== 6. Verify PG logrotate syntax ==="
|
||||
sudo logrotate --debug /etc/logrotate.d/postgresql-liderra 2>&1 | head -20
|
||||
|
||||
echo
|
||||
echo "=== 7. Force PG log rotation now (clean state) ==="
|
||||
sudo logrotate --force /etc/logrotate.d/postgresql-liderra 2>&1 | tail -10
|
||||
|
||||
echo
|
||||
echo "=== 8. AFTER: PG log directory state ==="
|
||||
sudo ls -lah /var/log/postgresql/ 2>&1 | head -10
|
||||
|
||||
echo
|
||||
echo "=== 9. AFTER: Laravel log directory state ==="
|
||||
sudo ls -lah "$LARAVEL_LOG_DIR/" 2>&1 | head -20
|
||||
echo
|
||||
echo "=== 10. Disk free ==="
|
||||
df -h / 2>&1 | head -3
|
||||
|
||||
echo
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## logrotate setup"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/logrotate-setup.log 2>/dev/null || echo "(no log)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,208 +0,0 @@
|
||||
name: SQL rebuild audit hash-chain (per-tenant via postgres)
|
||||
|
||||
# Запускает per-tenant rebuild hash-chain для аудит-партиции через
|
||||
# sudo -u postgres psql (обход limitation crm_supplier_worker роли —
|
||||
# она не может SET session_replication_role).
|
||||
#
|
||||
# Поддерживает 2 таблицы (Stage 5 finding 1+2):
|
||||
# - activity_log → ROW(id,tenant_id,user_id,deal_id,event,old_value,
|
||||
# new_value,context,ip_address,user_agent,NULL::bytea,created_at)
|
||||
# - balance_transactions → ROW(id,tenant_id,type,amount_rub,amount_leads,
|
||||
# balance_rub_after,balance_leads_after,description,related_type,
|
||||
# related_id,user_id,admin_user_id,NULL::bytea,created_at)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
partition:
|
||||
description: 'Имя партиции, например activity_log_y2026_m05'
|
||||
required: true
|
||||
type: string
|
||||
from_id:
|
||||
description: 'ID с которого начать пересчёт (включительно)'
|
||||
required: true
|
||||
type: string
|
||||
table_kind:
|
||||
description: 'activity_log | balance_transactions | pd_processing_log | tenant_operations_log'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- activity_log
|
||||
- balance_transactions
|
||||
- pd_processing_log
|
||||
- tenant_operations_log
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю выполнение mutating cleanup'
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
rebuild:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
PARTITION: ${{ github.event.inputs.partition }}
|
||||
FROM_ID: ${{ github.event.inputs.from_id }}
|
||||
TABLE_KIND: ${{ github.event.inputs.table_kind }}
|
||||
|
||||
steps:
|
||||
- name: Confirm check
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.confirm_apply }}" != "true" ]]; then
|
||||
echo "::error::confirm_apply=true обязателен"
|
||||
exit 1
|
||||
fi
|
||||
# Sanity: partition must match table_kind
|
||||
case "$TABLE_KIND" in
|
||||
activity_log)
|
||||
if [[ ! "$PARTITION" =~ ^activity_log_y[0-9]{4}_m[0-9]{2}$ ]]; then
|
||||
echo "::error::partition '$PARTITION' не соответствует table_kind=activity_log"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
balance_transactions)
|
||||
if [[ ! "$PARTITION" =~ ^balance_transactions_y[0-9]{4}_m[0-9]{2}$ ]]; then
|
||||
echo "::error::partition '$PARTITION' не соответствует table_kind=balance_transactions"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
pd_processing_log)
|
||||
if [[ ! "$PARTITION" =~ ^pd_processing_log_y[0-9]{4}_m[0-9]{2}$ ]]; then
|
||||
echo "::error::partition '$PARTITION' не соответствует table_kind=pd_processing_log"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
tenant_operations_log)
|
||||
if [[ ! "$PARTITION" =~ ^tenant_operations_log_y[0-9]{4}_m[0-9]{2}$ ]]; then
|
||||
echo "::error::partition '$PARTITION' не соответствует table_kind=tenant_operations_log"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::table_kind unknown"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if ! [[ "$FROM_ID" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::from_id must be numeric"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Execute SQL rebuild on prod
|
||||
run: |
|
||||
# Build ROW expression per table_kind (mirror AuditChainConfig::TABLES)
|
||||
case "$TABLE_KIND" in
|
||||
activity_log)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.user_id, t.deal_id, t.event, t.old_value, t.new_value, t.context, t.ip_address, t.user_agent, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
balance_transactions)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.type, t.amount_rub, t.amount_leads, t.balance_rub_after, t.balance_leads_after, t.description, t.related_type, t.related_id, t.user_id, t.admin_user_id, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
pd_processing_log)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.subject_type, t.subject_id, t.action, t.purpose, t.actor_tenant_user_id, t.actor_admin_user_id, t.ip_address, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
tenant_operations_log)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.user_id, t.entity_type, t.entity_id, t.event, t.payload_before, t.payload_after, t.ip_address, t.user_agent, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Build SQL with substituted PARTITION + FROM_ID + ROW_EXPR
|
||||
cat > /tmp/rebuild.sql <<SQL
|
||||
\\set ON_ERROR_STOP 1
|
||||
|
||||
SELECT 'BEFORE: mismatches in partition' AS phase, COUNT(*) AS cnt
|
||||
FROM (
|
||||
WITH ordered AS (
|
||||
SELECT id, tenant_id, log_hash AS stored_hash,
|
||||
LAG(log_hash) OVER (PARTITION BY tenant_id ORDER BY id) AS prev_hash
|
||||
FROM ${PARTITION}
|
||||
)
|
||||
SELECT o.id
|
||||
FROM ordered o
|
||||
WHERE o.stored_hash IS DISTINCT FROM
|
||||
digest(
|
||||
COALESCE(o.prev_hash, ''::bytea)
|
||||
|| (SELECT ${ROW_EXPR}::text::bytea FROM ${PARTITION} t WHERE t.id = o.id),
|
||||
'sha256'
|
||||
)
|
||||
) sub;
|
||||
|
||||
DO \$\$
|
||||
DECLARE
|
||||
tenant_rec RECORD;
|
||||
row_rec RECORD;
|
||||
prev_hash BYTEA;
|
||||
new_hash BYTEA;
|
||||
updated_count INT := 0;
|
||||
tenant_count INT := 0;
|
||||
BEGIN
|
||||
SET session_replication_role = 'replica';
|
||||
|
||||
FOR tenant_rec IN
|
||||
SELECT DISTINCT tenant_id FROM ${PARTITION} WHERE id >= ${FROM_ID} ORDER BY tenant_id
|
||||
LOOP
|
||||
tenant_count := tenant_count + 1;
|
||||
|
||||
SELECT log_hash INTO prev_hash
|
||||
FROM ${PARTITION}
|
||||
WHERE tenant_id = tenant_rec.tenant_id AND id < ${FROM_ID}
|
||||
ORDER BY id DESC LIMIT 1;
|
||||
|
||||
FOR row_rec IN
|
||||
SELECT id FROM ${PARTITION}
|
||||
WHERE tenant_id = tenant_rec.tenant_id AND id >= ${FROM_ID}
|
||||
ORDER BY id
|
||||
LOOP
|
||||
UPDATE ${PARTITION} p
|
||||
SET log_hash = digest(
|
||||
COALESCE(prev_hash, ''::bytea)
|
||||
|| (SELECT ${ROW_EXPR}::text::bytea FROM ${PARTITION} t WHERE t.id = row_rec.id),
|
||||
'sha256'
|
||||
)
|
||||
WHERE p.id = row_rec.id
|
||||
RETURNING log_hash INTO new_hash;
|
||||
|
||||
prev_hash := new_hash;
|
||||
updated_count := updated_count + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
SET session_replication_role = 'origin';
|
||||
RAISE NOTICE 'Rebuild complete: % tenants, % rows updated', tenant_count, updated_count;
|
||||
END\$\$;
|
||||
|
||||
SELECT 'AFTER: mismatches in partition' AS phase, COUNT(*) AS cnt
|
||||
FROM (
|
||||
WITH ordered AS (
|
||||
SELECT id, tenant_id, log_hash AS stored_hash,
|
||||
LAG(log_hash) OVER (PARTITION BY tenant_id ORDER BY id) AS prev_hash
|
||||
FROM ${PARTITION}
|
||||
)
|
||||
SELECT o.id
|
||||
FROM ordered o
|
||||
WHERE o.stored_hash IS DISTINCT FROM
|
||||
digest(
|
||||
COALESCE(o.prev_hash, ''::bytea)
|
||||
|| (SELECT ${ROW_EXPR}::text::bytea FROM ${PARTITION} t WHERE t.id = o.id),
|
||||
'sha256'
|
||||
)
|
||||
) sub;
|
||||
SQL
|
||||
|
||||
scp -i ~/.ssh/liderra_deploy /tmp/rebuild.sql ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }}:/tmp/rebuild.sql
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'sudo -u postgres psql -d liderra -f /tmp/rebuild.sql && rm /tmp/rebuild.sql'
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,104 +0,0 @@
|
||||
name: Run whitelisted SQL on liderra.ru
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sql:
|
||||
description: 'SQL query (SELECT only by default; UPDATE/DELETE need confirm_mutating=true)'
|
||||
required: true
|
||||
type: string
|
||||
confirm_mutating:
|
||||
description: 'Подтверждаю UPDATE/DELETE на проде'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
SQL: ${{ github.event.inputs.sql }}
|
||||
CONFIRM_MUT: ${{ github.event.inputs.confirm_mutating }}
|
||||
|
||||
steps:
|
||||
- name: Whitelist check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SQL_LOWER=$(echo "$SQL" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
# Reject multi-statement SQL — `;` would let SELECT-prefixed payloads
|
||||
# smuggle UPDATE/DELETE past READ_RE without confirm_mutating=true.
|
||||
# Trailing single `;` is also rejected for symmetry (use no trailing `;`).
|
||||
if [[ "$SQL_LOWER" == *";"* ]]; then
|
||||
echo "::error::Multi-statement SQL is not allowed (no semicolons)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Allow: SELECT / WITH (CTE) / \d / EXPLAIN
|
||||
READ_RE='^(select |with |explain |\\d|\\df|\\di|\\dt)'
|
||||
|
||||
# Mutating allowed if confirm=true: targeted UPDATE/DELETE on specific tables
|
||||
MUTATING_RE='^(update supplier_leads|update supplier_projects|update failed_webhook_jobs|update scheduler_heartbeats|delete from failed_webhook_jobs|delete from incidents_log) '
|
||||
|
||||
if [[ "$SQL_LOWER" =~ $READ_RE ]]; then
|
||||
echo "::notice::SELECT/read-only — allowed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$SQL_LOWER" =~ $MUTATING_RE ]]; then
|
||||
if [[ "$CONFIRM_MUT" != "true" ]]; then
|
||||
echo "::error::Mutating SQL requires confirm_mutating=true."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::Mutating SQL authorized."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::SQL not in whitelist: $SQL_LOWER"
|
||||
exit 1
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run on prod
|
||||
run: |
|
||||
set -o pipefail
|
||||
SQL_B64=$(printf '%s' "$SQL" | base64 -w0)
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/sql.log
|
||||
SQL=$(echo "$SQL_B64" | base64 -d)
|
||||
echo "=== Running on $(hostname) at $(date -u) ==="
|
||||
echo "SQL: $SQL"
|
||||
echo
|
||||
sudo -u postgres psql -d liderra -c "$SQL"
|
||||
RC=$?
|
||||
echo
|
||||
echo "=== Exit code: $RC ==="
|
||||
exit $RC
|
||||
REMOTE
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## SQL on prod"
|
||||
echo
|
||||
echo '```sql'
|
||||
echo "$SQL"
|
||||
echo '```'
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/sql.log 2>/dev/null
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,136 +0,0 @@
|
||||
name: Diagnose SSH access to liderra.ru
|
||||
|
||||
# Цель: понять, почему dev-IP 89.144.17.119 не пускают по SSH.
|
||||
# Запускается вручную: gh workflow run ssh-diagnose.yml -f dev_ip=89.144.17.119
|
||||
# Ничего не меняет на проде — только читает состояние fail2ban / iptables / sshd /
|
||||
# auth.log.
|
||||
#
|
||||
# Использует тот же LIDERRA_SSH_KEY что и deploy.yml.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dev_ip:
|
||||
description: 'IP который нужно проверить на блок (по умолчанию 89.144.17.119)'
|
||||
required: true
|
||||
default: '89.144.17.119'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
diagnose:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
DEV_IP: ${{ github.event.inputs.dev_ip }}
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run diagnostic queries on prod
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"DEV_IP='${DEV_IP}' bash -s" <<'REMOTE' | tee /tmp/diagnose.log
|
||||
set +e
|
||||
echo "=== 1. fail2ban status (sshd jail) ==="
|
||||
sudo fail2ban-client status sshd 2>&1 | head -30 || echo "fail2ban not available"
|
||||
|
||||
echo
|
||||
echo "=== 2. Is ${DEV_IP} currently banned by fail2ban? ==="
|
||||
sudo fail2ban-client get sshd banip 2>&1 | grep -F "${DEV_IP}" || echo "NOT IN fail2ban banlist"
|
||||
|
||||
echo
|
||||
echo "=== 3. Recent fail2ban actions for ${DEV_IP} (last 50 lines) ==="
|
||||
sudo grep -F "${DEV_IP}" /var/log/fail2ban.log 2>/dev/null | tail -50 || echo "no fail2ban log entries"
|
||||
|
||||
echo
|
||||
echo "=== 4. iptables INPUT rules referencing ${DEV_IP} or :22 ==="
|
||||
sudo iptables -L INPUT -n -v --line-numbers 2>&1 | grep -E "(${DEV_IP}|dpt:22|tcp dpt:ssh|f2b)" || echo "no specific INPUT rules"
|
||||
|
||||
echo
|
||||
echo "=== 5. iptables chains containing fail2ban (f2b-*) ==="
|
||||
sudo iptables -L -n 2>&1 | grep -E "^Chain (f2b|INPUT)" | head -10
|
||||
|
||||
echo
|
||||
echo "=== 6. Full f2b-sshd chain (entries banning IPs) ==="
|
||||
sudo iptables -L f2b-sshd -n -v --line-numbers 2>&1 | head -40 || echo "no f2b-sshd chain"
|
||||
|
||||
echo
|
||||
echo "=== 7. Recent SSH failed attempts from ${DEV_IP} (last 30 lines auth.log) ==="
|
||||
sudo grep -F "${DEV_IP}" /var/log/auth.log 2>/dev/null | tail -30 || echo "no auth.log entries"
|
||||
|
||||
echo
|
||||
echo "=== 8. Active sshd config: AllowUsers / DenyUsers / Match blocks ==="
|
||||
sudo grep -E "^(AllowUsers|DenyUsers|AllowGroups|DenyGroups|Match)" /etc/ssh/sshd_config 2>&1 || true
|
||||
sudo ls /etc/ssh/sshd_config.d/ 2>&1
|
||||
sudo grep -E "^(AllowUsers|DenyUsers|AllowGroups|DenyGroups|Match)" /etc/ssh/sshd_config.d/*.conf 2>/dev/null || echo "no relevant entries in sshd_config.d"
|
||||
|
||||
echo
|
||||
echo "=== 9. hosts.deny / hosts.allow ==="
|
||||
echo "--- /etc/hosts.deny ---"
|
||||
sudo cat /etc/hosts.deny 2>/dev/null | grep -v '^#' | grep -v '^$' || echo "(empty)"
|
||||
echo "--- /etc/hosts.allow ---"
|
||||
sudo cat /etc/hosts.allow 2>/dev/null | grep -v '^#' | grep -v '^$' || echo "(empty)"
|
||||
|
||||
echo
|
||||
echo "=== 10. ufw status (если используется) ==="
|
||||
sudo ufw status verbose 2>&1 | head -20 || echo "ufw not active"
|
||||
|
||||
echo
|
||||
echo "=== 11. nftables ruleset (если активен) ==="
|
||||
sudo nft list ruleset 2>&1 | head -40 || echo "nftables not active"
|
||||
|
||||
echo
|
||||
echo "=== 12. Last 5 successful SSH logins (who logged in last) ==="
|
||||
last -n 5 ubuntu 2>&1 | head -10
|
||||
|
||||
echo
|
||||
echo "=== 13. Full content of /etc/ssh/sshd_config.d/01-claude.conf ==="
|
||||
sudo cat /etc/ssh/sshd_config.d/01-claude.conf 2>&1 | head -80
|
||||
|
||||
echo
|
||||
echo "=== 14. nftables full ruleset (f2b-table content) ==="
|
||||
sudo nft list ruleset 2>&1 | head -120
|
||||
|
||||
echo
|
||||
echo "=== 15. journalctl ssh.service last 30min ==="
|
||||
sudo journalctl -u ssh.service --since="30 minutes ago" --no-pager 2>&1 | tail -40
|
||||
|
||||
echo
|
||||
echo "=== 16. /etc/fail2ban/jail.d/ content ==="
|
||||
sudo ls -la /etc/fail2ban/jail.d/ 2>&1
|
||||
echo "--- whitelist-dev.conf ---"
|
||||
sudo cat /etc/fail2ban/jail.d/whitelist-dev.conf 2>&1 || echo "(missing)"
|
||||
echo "--- jail.local ---"
|
||||
sudo cat /etc/fail2ban/jail.local 2>&1 | head -40 || echo "(missing)"
|
||||
|
||||
echo
|
||||
echo "=== 17. recidive jail (if any — long-term ban) ==="
|
||||
sudo fail2ban-client status recidive 2>&1 | head -20 || echo "no recidive jail"
|
||||
sudo fail2ban-client get recidive banip 2>&1 | grep -F "${DEV_IP}" || echo "NOT IN recidive"
|
||||
|
||||
echo
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## SSH diagnostic for $DEV_IP → $LIDERRA_HOST"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/diagnose.log 2>/dev/null || echo "(no log captured)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,117 +0,0 @@
|
||||
name: Stage 5 daily monitor (29.05→04.06)
|
||||
|
||||
# Автоматический ежедневный мониторинг 3 ключевых сигналов прода
|
||||
# во время 7-дневного окна перед переключением supplier_export_mode
|
||||
# online→batch (Stage 5 Task 5.1).
|
||||
#
|
||||
# Запускается GitHub-cron'ом каждое утро 06:00 UTC (09:00 МСК)
|
||||
# 29.05.2026 — 04.06.2026 (после 04.06 workflow можно отключить
|
||||
# через UI Actions tab → Disable workflow, либо удалить файл).
|
||||
# Также доступен ручной запуск через workflow_dispatch.
|
||||
#
|
||||
# Выводит результаты в job summary + сохраняет как artifact.
|
||||
#
|
||||
# План мониторинга:
|
||||
# docs/superpowers/plans/2026-05-29-stage5-monitoring-checklist.md
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 06:00 UTC = 09:00 МСК ежедневно
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
# Жёсткий стоп — workflow ничего не делает после 04.06.2026 даже
|
||||
# если кто-то забудет отключить. CRON в GitHub Actions не имеет
|
||||
# "until date" — реализуем через if-check на runner side.
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.schedule == '0 6 * * *'
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
|
||||
steps:
|
||||
- name: Check window not expired
|
||||
id: window
|
||||
run: |
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
DEADLINE='2026-06-05' # 04.06 + 1 день grace
|
||||
if [[ "$TODAY" > "$DEADLINE" ]]; then
|
||||
echo "::notice::Stage 5 monitoring window closed at $DEADLINE. Disable this workflow via Actions UI."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup SSH key
|
||||
if: steps.window.outputs.skip != 'true'
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run 3 checks
|
||||
if: steps.window.outputs.skip != 'true'
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE' | tee /tmp/monitor.log
|
||||
set +e
|
||||
cd /var/www/liderra/app
|
||||
echo "=== Date: $(date -u) ==="
|
||||
|
||||
echo
|
||||
echo "=== 1. scheduler:check-heartbeats ==="
|
||||
sudo -u www-data php artisan scheduler:check-heartbeats 2>&1
|
||||
echo "Exit: $?"
|
||||
|
||||
echo
|
||||
echo "=== 2. incidents:watch-failures ==="
|
||||
sudo -u www-data php artisan incidents:watch-failures 2>&1
|
||||
echo "Exit: $?"
|
||||
|
||||
echo
|
||||
echo "=== 3. migrate:status ==="
|
||||
sudo -u www-data php artisan migrate:status 2>&1 | tail -8
|
||||
echo "Exit: $?"
|
||||
|
||||
echo
|
||||
echo "=== Auxiliary signals from system tables ==="
|
||||
echo "--- last 3 incidents_log entries ---"
|
||||
sudo -u postgres psql -d liderra -tA -c "SELECT severity, created_at, root_cause FROM incidents_log ORDER BY created_at DESC LIMIT 3;" 2>&1
|
||||
echo "--- snapshot count last 3 days ---"
|
||||
sudo -u postgres psql -d liderra -tA -c "SELECT snapshot_date, COUNT(*) FROM project_routing_snapshots GROUP BY 1 ORDER BY 1 DESC LIMIT 3;" 2>&1
|
||||
echo "--- failed_webhook_jobs last 24h count ---"
|
||||
sudo -u postgres psql -d liderra -tA -c "SELECT COUNT(*) FROM failed_webhook_jobs WHERE failed_at > NOW() - INTERVAL '24 hours';" 2>&1
|
||||
echo "--- scheduler_heartbeats with failures ---"
|
||||
sudo -u postgres psql -d liderra -tA -c "SELECT command_name, consecutive_failures, last_run_at FROM scheduler_heartbeats WHERE consecutive_failures > 0 ORDER BY consecutive_failures DESC;" 2>&1
|
||||
|
||||
echo
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always() && steps.window.outputs.skip != 'true'
|
||||
run: |
|
||||
{
|
||||
echo "## Stage 5 daily monitor — $(date -u +%Y-%m-%d)"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/monitor.log 2>/dev/null || echo "(no output)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload as artifact
|
||||
if: always() && steps.window.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: monitor-${{ github.run_id }}
|
||||
path: /tmp/monitor.log
|
||||
retention-days: 14
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,111 +0,0 @@
|
||||
name: Stage 5 day 1 investigation — round 3 (schema + full rows)
|
||||
|
||||
# Round 3: реальные имена колонок hash в audit-таблицах,
|
||||
# реальные имена FK в supplier_projects/supplier_leads,
|
||||
# полное содержимое битых строк (599/462) и застрявших лидов (1110/1157).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
investigate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Round 3 schema + rows
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE' | tee /tmp/investigate3.log
|
||||
set +e
|
||||
cd /var/www/liderra/app
|
||||
|
||||
echo "=========================================="
|
||||
echo "SCHEMAS"
|
||||
echo "=========================================="
|
||||
|
||||
echo
|
||||
echo "--- activity_log columns ---"
|
||||
sudo -u postgres psql -d liderra -c "SELECT column_name, data_type FROM information_schema.columns WHERE table_name='activity_log' ORDER BY ordinal_position;"
|
||||
|
||||
echo
|
||||
echo "--- balance_transactions columns ---"
|
||||
sudo -u postgres psql -d liderra -c "SELECT column_name, data_type FROM information_schema.columns WHERE table_name='balance_transactions' ORDER BY ordinal_position;"
|
||||
|
||||
echo
|
||||
echo "--- supplier_projects columns ---"
|
||||
sudo -u postgres psql -d liderra -c "SELECT column_name, data_type FROM information_schema.columns WHERE table_name='supplier_projects' ORDER BY ordinal_position;"
|
||||
|
||||
echo
|
||||
echo "--- supplier_leads columns ---"
|
||||
sudo -u postgres psql -d liderra -c "SELECT column_name, data_type FROM information_schema.columns WHERE table_name='supplier_leads' ORDER BY ordinal_position;"
|
||||
|
||||
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo "BROKEN ROWS — full SELECT *"
|
||||
echo "=========================================="
|
||||
|
||||
echo
|
||||
echo "--- activity_log_y2026_m05 ids 597-601 ---"
|
||||
sudo -u postgres psql -d liderra -x -c "SELECT * FROM activity_log_y2026_m05 WHERE id BETWEEN 597 AND 601 ORDER BY id;"
|
||||
|
||||
echo
|
||||
echo "--- balance_transactions_y2026_m05 ids 460-464 ---"
|
||||
sudo -u postgres psql -d liderra -x -c "SELECT * FROM balance_transactions_y2026_m05 WHERE id BETWEEN 460 AND 464 ORDER BY id;"
|
||||
|
||||
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo "STUCK LEADS 1110 + 1157"
|
||||
echo "=========================================="
|
||||
|
||||
echo
|
||||
echo "--- supplier_leads.id IN (1110, 1157) ---"
|
||||
sudo -u postgres psql -d liderra -x -c "SELECT * FROM supplier_leads WHERE id IN (1110, 1157);"
|
||||
|
||||
echo
|
||||
echo "--- failed_webhook_jobs sample raw_payload for sl_id=1110 (1 row) ---"
|
||||
sudo -u postgres psql -d liderra -x -c "SELECT * FROM failed_webhook_jobs WHERE raw_payload->>'supplier_lead_id' = '1110' ORDER BY failed_at DESC LIMIT 1;"
|
||||
|
||||
echo
|
||||
echo "--- All supplier_projects with platform B1 ---"
|
||||
sudo -u postgres psql -d liderra -c "SELECT * FROM supplier_projects WHERE platform='B1' LIMIT 5;"
|
||||
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo "DONE"
|
||||
echo "=========================================="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## Stage 5 day 1 investigation — round 3 schemas"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/investigate3.log 2>/dev/null || echo "(no output)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: investigate-day1-round3
|
||||
path: /tmp/investigate3.log
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
-36
@@ -2,14 +2,6 @@
|
||||
# .gitignore — Лидерра
|
||||
# =============================================================================
|
||||
|
||||
# ── Session junk (broken PS paths from parallel Claude sessions, deploy tarballs, ad-hoc screenshots) ──
|
||||
CTemp*
|
||||
CWindowsTemp*
|
||||
phase[0-9]*-update.tar.gz
|
||||
recheck-*.png
|
||||
.tmp-*.sql
|
||||
tools/cloudflared.*
|
||||
|
||||
# ── Node / npm ──────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
@@ -47,16 +39,6 @@ demo-*.jpeg
|
||||
# gitleaks
|
||||
gitleaks-report.json
|
||||
|
||||
# ward (security-сканер) — отчёты в корне
|
||||
ward-report.*
|
||||
lychee-links-report.txt
|
||||
walk-*.png
|
||||
|
||||
# ZAP active scan — сырые отчёты (анализ коммитится как .md, сырьё локально:
|
||||
# может содержать снимки ответов dev-приложения)
|
||||
docs/security/*-zap-active-scan.json
|
||||
docs/security/*-zap-active-scan.html
|
||||
|
||||
# ── IDE / редакторы ─────────────────────────────────────────────────────────
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -139,7 +121,6 @@ c--Users-*/
|
||||
# ── Временные файлы ─────────────────────────────────────────────────────────
|
||||
*.tmp
|
||||
*.bak
|
||||
.mcp.json.bak-*
|
||||
*.log
|
||||
tmp/
|
||||
.tmp/
|
||||
@@ -162,12 +143,6 @@ app/playwright/node_modules/
|
||||
# Superpowers using-git-worktrees — локальные worktrees вне репо
|
||||
.claude/worktrees/
|
||||
|
||||
# Graphify knowledge-graph build artefacts (ADR-017 #86) — ~5MB graph.json + 1.8MB
|
||||
# graph.html + cache/. Local-only, не коммитятся; восстанавливается пересборкой
|
||||
# через /graphify --update. В main worktree graphify-out — junction на spike worktree.
|
||||
graphify-out/
|
||||
graphify-out-*/
|
||||
|
||||
# Vitest coverage output (app/coverage/) — генерируется npm run test:coverage
|
||||
/app/coverage/
|
||||
|
||||
@@ -213,14 +188,3 @@ ruflo-mcp-stderr.log
|
||||
.claude/commands/*
|
||||
!.claude/commands/security-review.md
|
||||
.claude/helpers/
|
||||
|
||||
# ── Локальные бэкапы settings.json + эталон-снимки (M7 canon backups, local-only) ──
|
||||
.claude/arh settings/
|
||||
.claude/settings - *.json
|
||||
.claude/settings эталон*.json
|
||||
.claude/эталон/
|
||||
.claude/scheduled_tasks.lock
|
||||
/settings.json
|
||||
settings copy.json
|
||||
# Строчный Ctemp-дамп (CTemp* выше не ловит из-за регистра)
|
||||
Ctemp*
|
||||
|
||||
+1
-19
@@ -89,37 +89,19 @@ paths = [
|
||||
'''app/tests/.*\.php''',
|
||||
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
|
||||
'''app/database/seeders/.*\.php''',
|
||||
# Database factories — генераторы тестовых фикстур (фейковые телефоны/ИНН,
|
||||
# напр. TenantFactory::withRequisites +79150000000), не реальные ПДн. Та же
|
||||
# категория, что seeders/tests.
|
||||
'''app/database/factories/.*\.php''',
|
||||
# Audit-internal docs (findings/blocked/report/plan) — содержат демо-телефоны и
|
||||
# script-смешанные artifacts как finding'и для review (не реальные ПДн)
|
||||
'''docs/superpowers/audits/.*\.md''',
|
||||
'''docs/superpowers/plans/.*\.md''',
|
||||
# Приёмочные ранбуки (R0–R5) — синтетические тест-телефоны (79990001122 и
|
||||
# пр.) в матрицах провижининга/инъекции, не реальные ПДн. Та же категория,
|
||||
# что plans/specs/audits.
|
||||
'''docs/superpowers/runbooks/.*\.md''',
|
||||
# Internal design specs — внутренние проектные доки с демо-данными (демо-телефоны
|
||||
# в примерах, напр. spec про log-PII-scrubbing), не реальные ПДн. Как plans/audits.
|
||||
'''docs/superpowers/specs/.*\.md''',
|
||||
# Mock-данные для UI-разводки фронтенда (фиктивные имена/телефоны)
|
||||
'''app/resources/js/composables/mockDeals\.ts''',
|
||||
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
|
||||
'''app/tests/Frontend/.*\.(spec|test)\.ts''',
|
||||
# Settings-вкладки с фиктивными mock-данными (профиль/сессии — UI-разводка)
|
||||
'''app/resources/js/views/settings/.*\.vue''',
|
||||
# Публичные реквизиты ПРОДАВЦА (ИП) — единый источник для футера/оферты/цен.
|
||||
# По требованию ЮKassa контакты продавца (телефон/почта) обязаны быть публично
|
||||
# на сайте; это не клиентские ПДн, а опубликованные бизнес-реквизиты.
|
||||
'''app/resources/js/constants/legal\.ts''',
|
||||
# Test fixtures for the observer PII filter — contains synthetic JWT / AWS /
|
||||
# Yandex tokens that the filter is supposed to redact. Not real secrets.
|
||||
'''tools/observer-pii-filter\.test\.mjs''',
|
||||
# Test fixture for the secret-scanner / read-path-deny (M5) — PEM-header marker +
|
||||
# AWS EXAMPLE key, used to verify detection. Not a real key; file deleted in brain split.
|
||||
'''tools/enforce-read-path-deny\.test\.mjs'''
|
||||
'''tools/observer-pii-filter\.test\.mjs'''
|
||||
]
|
||||
regexTarget = "match"
|
||||
regexes = [
|
||||
|
||||
@@ -39,13 +39,3 @@ a2f6714440c925e8ffdec8667373511dcce1b3aa:ПИЛОТ.md:ru-phone-unmasked:31
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:46
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:48
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:76
|
||||
|
||||
# 2026-05-26 — реальные RU-телефоны в ПИЛОТ.md и spec'ах от параллельных сессий
|
||||
# Дмитрия (33184985 / f48f79d2 / da4ab729 уже на origin/main — историю не переписать;
|
||||
# 6b2597ff / d2100a9b на ветке fix/supplier-platform-prefix, не в main lineage).
|
||||
# TODO: маскировать +7XXXXXXXXXX в новых коммитах ПИЛОТ.md / специов supplier-*.
|
||||
6b2597ff4ac2a34ed3d4d5a05c47318502b3f469:ПИЛОТ.md:ru-phone-unmasked:11
|
||||
d2100a9bab954296fa71dcfdb59568a1986e0dbe:docs/superpowers/specs/2026-05-26-supplier-platform-prefix-design.md:ru-phone-unmasked:18
|
||||
f48f79d2f333cd5acffb7751e6c3554d0807cb39:ПИЛОТ.md:ru-phone-unmasked:13
|
||||
33184985875ac8219464fd3d0f65b6740d587f50:ПИЛОТ.md:ru-phone-unmasked:11
|
||||
da4ab729df08ded7aa7d2523ef6c81efeacc1849:docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md:ru-phone-unmasked:34
|
||||
|
||||
+2
-9
@@ -28,12 +28,6 @@ exclude = [
|
||||
# Шаблонные плейсхолдеры
|
||||
"^\\{\\{.*\\}\\}$",
|
||||
"^\\[.*\\]$",
|
||||
# v3.9 hooks удалены Stream G (2026-05-30), CLAUDE.md содержит исторические упоминания
|
||||
"tools/enforce-chain-recommendation\\.mjs",
|
||||
"tools/enforce-classifier-match\\.mjs",
|
||||
"tools/enforce-graph-first\\.mjs",
|
||||
"tools/enforce-semgrep-security\\.mjs",
|
||||
"tools/enforce-override-limit\\.mjs",
|
||||
# localhost и приватные адреса
|
||||
"^https?://localhost",
|
||||
"^https?://127\\.0\\.0\\.1",
|
||||
@@ -54,9 +48,8 @@ exclude = [
|
||||
# Sample/примерные адреса
|
||||
"^https?://example\\.com",
|
||||
"^https?://example\\.org",
|
||||
# Покойный GitHub-аккаунт CoralMinister (suspended) — все ссылки на него мертвы:
|
||||
# исторические compare/actions-runs в ПИЛОТ.md / handoffs / plans. Бэкап теперь Gitea.
|
||||
"^https?://github\\.com/CoralMinister/",
|
||||
# Приватный репозиторий проекта (404 для анонимных запросов — это норма)
|
||||
"^https?://github\\.com/CoralMinister/liderra",
|
||||
# web/v8/*.html — статические концепты, root-relative ссылки на будущие маршруты Vue
|
||||
"^/(login|register|legal|dashboard|deals|admin|reports|reminders|billing|impersonation|notifications)(/|$|\\?)",
|
||||
# Корневой `/` в концептах (логотип-якорь для будущей главной)
|
||||
|
||||
@@ -6,4 +6,3 @@ CLAUDE.md
|
||||
.claude/skills/ccpm/
|
||||
.claude/skills/data-scientist/
|
||||
.claude/skills/marketingskills/
|
||||
docs/superpowers/
|
||||
|
||||
@@ -54,32 +54,32 @@
|
||||
},
|
||||
"comment": "A3 integration-tooling #47 — OpenAPI MCP (ivo-toby/mcp-openapi-server, @ivotoby/openapi-mcp-server v1.14.0, MIT). Exposes Лидерра REST API endpoints (docs/api/openapi.yaml) as MCP tools. Config via env-vars API_BASE_URL + OPENAPI_SPEC_PATH (stdio transport default). READ scope: API discovery/introspection for Claude Code. Формализован в Tooling §4.22, PSR_v1 R10.1 блок 3, Pravila §13.2."
|
||||
},
|
||||
"perplexity": {
|
||||
"marketing-metrika": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@perplexity-ai/mcp-server"],
|
||||
"args": ["-y", "github:atomkraft/yandex-metrika-mcp"],
|
||||
"env": {
|
||||
"PERPLEXITY_API_KEY": "${PERPLEXITY_API_KEY}",
|
||||
"PERPLEXITY_BASE_URL": "https://api.aitunnel.ru/v1"
|
||||
"YANDEX_OAUTH_TOKEN": "${YANDEX_OAUTH_TOKEN}"
|
||||
},
|
||||
"comment": "research-tooling (Perplexity Pack) #87 — research-канал. Официальный @perplexity-ai/mcp-server (репо perplexityai/modelcontextprotocol), MIT, подписанная сборка. Tools: perplexity_search/ask/research/reason (sonar-*). ПЛАТНЫЙ API; ключ PERPLEXITY_API_KEY только в user env (не в репо). Вет ПРИНЯТ — docs/research/research-vet.md. Перенос plan-v13 2026-06-14 (owner waiver, Вариант 2)."
|
||||
"comment": "C1 marketing-tooling #78 — Yandex Metrika MCP (vetted source: github:atomkraft/yandex-metrika-mcp, MIT — выбран по IS9-вету из 3 кандидатов, см. docs/security/marketing-vet.md). READ-ONLY аналитика: посещаемость, источники трафика, конверсии. Env: YANDEX_OAUTH_TOKEN — OAuth-токен с правами read-only. Постура IS9: READ-ONLY, мутации API Метрики не задействуются. Tooling §4.53. docs/marketing/README.md."
|
||||
},
|
||||
"exa": {
|
||||
"marketing-wordstat": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "exa-mcp-server"],
|
||||
"args": ["-y", "github:SvechaPVL/yandex-mcp"],
|
||||
"env": {
|
||||
"EXA_API_KEY": "${EXA_API_KEY}"
|
||||
"YANDEX_OAUTH_TOKEN": "${YANDEX_OAUTH_TOKEN}"
|
||||
},
|
||||
"comment": "research-tooling (Perplexity Pack) #88 — Exa нейро/семантический поиск. exa-mcp-server (репо exa-labs), MIT (license-поле npm пусто — см. вет). Tools: web_search_exa / web_fetch_exa (default). ПЛАТНЫЙ API; ключ EXA_API_KEY только в user env. Вет ПРИНЯТ — docs/research/research-vet.md."
|
||||
"comment": "C1 marketing-tooling #79 — Yandex Direct+Wordstat MCP (vetted source: github:SvechaPVL/yandex-mcp, MIT — выбран по IS9-вету, см. docs/security/marketing-vet.md). Репозиторий отдаёт 128 tools (Direct + Wordstat + Метрика); по IS9-условию используются ТОЛЬКО Wordstat-инструменты для подбора ключевых слов и оценки спроса — Direct-мутации (создание/правка кампаний, изменение ставок) поведенчески запрещены через marketing-ru #77 и MKT8 (никаких автоматических трат рекламного бюджета). Env: YANDEX_OAUTH_TOKEN с минимальным scope. Tooling §4.54. docs/marketing/README.md."
|
||||
},
|
||||
"firecrawl": {
|
||||
"marketing-telegram": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "firecrawl-mcp"],
|
||||
"args": ["-y", "github:chigwell/telegram-mcp"],
|
||||
"env": {
|
||||
"FIRECRAWL_API_KEY": "${FIRECRAWL_API_KEY}"
|
||||
"TELEGRAM_API_ID": "${TELEGRAM_API_ID}",
|
||||
"TELEGRAM_API_HASH": "${TELEGRAM_API_HASH}",
|
||||
"TELEGRAM_SESSION_STRING": "${TELEGRAM_SESSION_STRING}"
|
||||
},
|
||||
"comment": "research-tooling (Perplexity Pack) #89 — Firecrawl глубокое чтение/обход. firecrawl-mcp (репо firecrawl/firecrawl-mcp-server), MIT, очень активен. Tools: scrape/crawl/extract + firecrawl_agent. ПЛАТНЫЙ API; ключ FIRECRAWL_API_KEY только в user env. Вет ПРИНЯТ — docs/research/research-vet.md."
|
||||
"comment": "C1 marketing-tooling #80 — Telegram MCP (chigwell/telegram-mcp, Apache-2.0, GitHub-only — не npm). Работа с Telegram-каналами и чатами Лидерры: публикация, планирование, аналитика. Env: TELEGRAM_API_ID + TELEGRAM_API_HASH (получить на https://my.telegram.org/apps) + TELEGRAM_SESSION_STRING (генерируется один раз через GramJS/Telethon, хранить в .env.local gitignored). ОБЯЗАТЕЛЬНО: выделенный Telegram-аккаунт для Лидерры, не личный (IS9-постура MKT8). Tooling §4.51. docs/marketing/README.md."
|
||||
},
|
||||
"_disabled_marketing_servers_note": "ОТКЛЮЧЕНЫ 2026-05-31 (владелец: «отрежь маркетинг»). Причина: их авто-генерируемые схемы (особенно wordstat — 128 tools из Яндекс.Директа) — главный подозреваемый в API 400 tools.110/113, ронявшем субагентов при bulk-load всех инструментов (subagent-driven-development). Серверы off-phase и без OAuth-токенов всё равно не стартовали. Полный конфиг — в git до этого коммита. Чтобы вернуть, восстановить три блока mcpServers: marketing-metrika (npx -y github:atomkraft/yandex-metrika-mcp; env YANDEX_OAUTH_TOKEN; READ-ONLY; Tooling §4.53), marketing-wordstat (npx -y github:SvechaPVL/yandex-mcp; env YANDEX_OAUTH_TOKEN; ТОЛЬКО Wordstat per IS9/MKT8; Tooling §4.54), marketing-telegram (npx -y github:chigwell/telegram-mcp; env TELEGRAM_API_ID/API_HASH/SESSION_STRING; выделенный аккаунт IS9; Tooling §4.51). См. docs/security/marketing-vet.md и docs/marketing/README.md.",
|
||||
"_comment_postiz_skeleton": "TODO: C1 marketing-tooling #81 — Postiz MCP (gitroomhq/postiz-app self-host + antoniolg/postiz-mcp). Активировать ПОСЛЕ: 1) развернуть Postiz self-hosted (git clone https://github.com/gitroomhq/postiz-app + docker-compose, AGPL-3.0: internal-only, no modifications); 2) провести vet лицензии antoniolg/postiz-mcp (NOT YET VERIFIED — см. docs/marketing/README.md Open vet notes); 3) подключить соцсети в Postiz UI. Будущий entry: \"marketing-postiz\": { \"command\": \"npx\", \"args\": [\"-y\", \"postiz-mcp\"], \"env\": { \"POSTIZ_API_URL\": \"${POSTIZ_API_URL}\", \"POSTIZ_API_KEY\": \"${POSTIZ_API_KEY}\" }, \"comment\": \"C1 #81 post-activation\" }. Tooling §4.52. docs/marketing/README.md."
|
||||
}
|
||||
}
|
||||
|
||||
-69526
File diff suppressed because it is too large
Load Diff
-150000
File diff suppressed because it is too large
Load Diff
-142791
File diff suppressed because it is too large
Load Diff
-73783
File diff suppressed because it is too large
Load Diff
-16985
File diff suppressed because it is too large
Load Diff
@@ -42,18 +42,6 @@ SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru
|
||||
# Supplier alerts (email через Unisender Go relay)
|
||||
SUPPLIER_ALERT_EMAIL=
|
||||
|
||||
# SaaS-admin fail-closed гейт (M-1). Логины nginx basic-auth (.htpasswd-admin),
|
||||
# допущенные в /api/admin/*. CSV; дефолт совпадает с прод-.htpasswd.
|
||||
ADMIN_ALLOWED_USERS=admin
|
||||
# ADMIN_GATE_ENFORCED=true # авто: true вне local/testing; задать явно для override
|
||||
# Системный admin-id для audit-trail (FK saas_admin_audit_log). На проде crm_app_user
|
||||
# не имеет прав на saas_admin_users → задать id сид-стаба. dev/test — оставить пустым.
|
||||
ADMIN_AUDIT_SYSTEM_USER_ID=
|
||||
|
||||
# Капча самозаписи (M-2). driver=null (dev) | yandex (prod). Для yandex нужен server-key.
|
||||
CAPTCHA_DRIVER=null
|
||||
YANDEX_SMARTCAPTCHA_SERVER_KEY=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
@@ -82,8 +70,6 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
SUPPORT_EMAIL=support@liderra.ru
|
||||
JIVO_WIDGET_ID=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
@@ -92,7 +78,3 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev).
|
||||
# На проде — клиентский ключ ysc1_… (для виджета на странице регистрации).
|
||||
VITE_YANDEX_SMARTCAPTCHA_SITEKEY=
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.env.testing
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.deptrac.cache
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Audit\AuditChainConfig;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Пересчитывает hash-цепь в указанной партиции аудит-таблицы начиная с заданного id.
|
||||
*
|
||||
* ADR-018: воспроизводит per-tenant scope триггера audit_chain_hash() (через RLS).
|
||||
* Для tenant-таблиц (activity_log/balance_transactions/tenant_operations_log/
|
||||
* pd_processing_log) — отдельная цепочка на каждый tenant. Для BYPASSRLS-таблиц
|
||||
* (auth_log/saas_admin_audit_log) — единая цепочка в пределах партиции.
|
||||
*
|
||||
* Алгоритм (Вариант B — PHP-iteration с partition awareness):
|
||||
* 1. SET session_replication_role = replica отключает BEFORE-триггеры.
|
||||
* 2. Determine partition_clause из AuditChainConfig::TABLES[parent_table].
|
||||
* 3. Для per-tenant таблиц: получить distinct tenant_ids в range, для каждого:
|
||||
* - prev_hash = log_hash of last row with id<from-id AND tenant_id=X
|
||||
* - iterate rows ordered by id, UPDATE + propagate prev_hash forward
|
||||
* Для BYPASSRLS-таблиц: одна iteration без tenant scope.
|
||||
* 4. Возвращаем session_replication_role = origin.
|
||||
*
|
||||
* NB: row-by-row PHP loop сохранён намеренно (вариант с одиночным CTE и
|
||||
* LAG страдает snapshot-isolation bug — downstream rows используют OLD stored
|
||||
* prev_hash вместо новых хешей текущего UPDATE'а; chain ломается через >1 row).
|
||||
*
|
||||
* Ref: docs/adr/ADR-018-audit-chain-per-tenant-semantics.md
|
||||
* docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md
|
||||
*/
|
||||
final class AuditRebuildChain extends Command
|
||||
{
|
||||
protected $signature = 'audit:rebuild-chain
|
||||
{--partition= : Имя партиции, например activity_log_y2026_m05}
|
||||
{--from-id= : ID с которого начать пересчёт (включительно)}
|
||||
{--dry-run : Показать сколько строк затронет, без UPDATE}
|
||||
{--force : Пропустить интерактивное подтверждение (для CI/тестов)}';
|
||||
|
||||
protected $description = 'Пересчитать hash-цепь партиции аудит-таблицы (per-tenant per ADR-018)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$partition = (string) $this->option('partition');
|
||||
$fromId = (int) $this->option('from-id');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
if ($partition === '' || $fromId <= 0) {
|
||||
$this->error('--partition и --from-id обязательны');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$parentTable = (string) preg_replace('/_y\d{4}_m\d{2}$/', '', $partition);
|
||||
|
||||
if (! array_key_exists($parentTable, AuditChainConfig::TABLES)) {
|
||||
$this->error("Partition '{$partition}' не относится к поддерживаемым аудит-таблицам.");
|
||||
$this->line('Поддерживаемые: '.implode(', ', array_keys(AuditChainConfig::TABLES)));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$partitionClause = AuditChainConfig::TABLES[$parentTable]['partition'];
|
||||
$rowExpr = AuditChainConfig::rowExpression($parentTable);
|
||||
|
||||
$count = DB::connection('pgsql_supplier')
|
||||
->table($partition)
|
||||
->where('id', '>=', $fromId)
|
||||
->count();
|
||||
|
||||
$scopeLabel = $partitionClause !== '' ? $partitionClause : 'global (within partition)';
|
||||
|
||||
$this->info("Партиция : {$partition}");
|
||||
$this->info("Родитель : {$parentTable}");
|
||||
$this->info("Scope : {$scopeLabel}");
|
||||
$this->info("От id : {$fromId}");
|
||||
$this->info("Строк : {$count}");
|
||||
|
||||
if ($count === 0) {
|
||||
$this->warn('Нет строк с id >= '.$fromId.'. Пересчёт не нужен.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('--dry-run: UPDATE не выполнен.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $force && ! $this->confirm(
|
||||
"Пересчитать log_hash для {$count} строк в {$partition} (scope: {$scopeLabel})? Это изменит данные в проде.",
|
||||
false,
|
||||
)) {
|
||||
$this->warn('Отменено.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Пересчёт цепочки = 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.
|
||||
$tenantIds = DB::connection('pgsql_supplier')
|
||||
->table($partition)
|
||||
->where('id', '>=', $fromId)
|
||||
->distinct()
|
||||
->pluck('tenant_id')
|
||||
->all();
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$totalUpdated += $this->rebuildScope(
|
||||
$partition,
|
||||
$rowExpr,
|
||||
$fromId,
|
||||
'tenant_id',
|
||||
(int) $tenantId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// global scope (auth_log, saas_admin_audit_log).
|
||||
$totalUpdated = $this->rebuildScope($partition, $rowExpr, $fromId, null, null);
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Обновлено {$totalUpdated} строк в {$partition}.");
|
||||
|
||||
$this->info('Готово. Запустите audit:verify-chains для проверки целостности.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересчитывает chain для одного scope (tenant или global).
|
||||
*
|
||||
* Iterative PHP loop: prev_hash propagate'ится forward через каждый row,
|
||||
* UPDATE применяется immediately чтобы snapshot для следующей iteration
|
||||
* был свежий (default PG READ COMMITTED — own writes visible immediately).
|
||||
*
|
||||
* @param string|null $tenantColumn 'tenant_id' для per-tenant scope, null для global
|
||||
* @param int|null $tenantValue значение tenant_id для этого scope (если применимо)
|
||||
*/
|
||||
private function rebuildScope(
|
||||
string $partition,
|
||||
string $rowExpr,
|
||||
int $fromId,
|
||||
?string $tenantColumn,
|
||||
?int $tenantValue,
|
||||
): int {
|
||||
// Find prev_hash (last row before fromId within scope).
|
||||
$prevQuery = DB::connection('pgsql_supplier')
|
||||
->table($partition)
|
||||
->where('id', '<', $fromId);
|
||||
if ($tenantColumn !== null) {
|
||||
$prevQuery->where($tenantColumn, $tenantValue);
|
||||
}
|
||||
$prevHashRow = $prevQuery->orderByDesc('id')->first(['log_hash']);
|
||||
$prevHashHex = $this->bytesToHex($prevHashRow?->log_hash);
|
||||
|
||||
// Get rows to rebuild ordered by id.
|
||||
$rowsQuery = DB::connection('pgsql_supplier')
|
||||
->table($partition)
|
||||
->where('id', '>=', $fromId);
|
||||
if ($tenantColumn !== null) {
|
||||
$rowsQuery->where($tenantColumn, $tenantValue);
|
||||
}
|
||||
$rows = $rowsQuery->orderBy('id')->get(['id']);
|
||||
|
||||
$updated = 0;
|
||||
foreach ($rows as $row) {
|
||||
$prevHashExpr = $prevHashHex !== null
|
||||
? "'{$prevHashHex}'::bytea"
|
||||
: "''::bytea";
|
||||
|
||||
$sql = "
|
||||
UPDATE {$partition}
|
||||
SET log_hash = (
|
||||
SELECT digest(
|
||||
COALESCE({$prevHashExpr}, ''::bytea)
|
||||
|| (SELECT {$rowExpr}::text::bytea FROM {$partition} t WHERE t.id = ?)
|
||||
, 'sha256'
|
||||
)
|
||||
)
|
||||
WHERE id = ?
|
||||
RETURNING log_hash
|
||||
";
|
||||
|
||||
$result = DB::connection('pgsql_supplier')->selectOne($sql, [$row->id, $row->id]);
|
||||
$updated++;
|
||||
|
||||
$prevHashHex = $this->bytesToHex($result?->log_hash);
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a BYTEA value (PHP resource or string) to hex literal for SQL.
|
||||
* PostgreSQL PDO driver returns BYTEA as a PHP stream resource.
|
||||
*/
|
||||
private function bytesToHex(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$bin = is_resource($value) ? stream_get_contents($value) : (string) $value;
|
||||
if ($bin === '' || $bin === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '\\x'.bin2hex($bin);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Billing\BalanceFrozenReminderJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Daily: повторные письма заморозки баланса.
|
||||
* • reminder в окне 24-48ч после freeze;
|
||||
* • final в окне 72-96ч после freeze.
|
||||
*
|
||||
* Запускается @18:30 MSK (routes/console.php), после основного preflight-sweep @18:00.
|
||||
* Throttle живёт в BalanceFrozenReminderJob через balance_freeze_log markers.
|
||||
*/
|
||||
final class BillingFrozenReminderCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'billing:frozen-reminder';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Повторные письма заморозки баланса (reminder +1д, final +3д)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
$this->info('Повторные письма заморозки разосланы (если есть кандидаты в окнах).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Billing\BalancePreflightSweepJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* One-time: при выкатке преfflight прогнать всех тенантов и заморозить
|
||||
* недофинансированных. Запускается ОДИН раз вручную после миграции.
|
||||
*
|
||||
* См. спек §3.9: «Клиент уже в минусовом балансе на момент запуска
|
||||
* преfflight (legacy состояние) — одноразовая artisan-команда».
|
||||
*
|
||||
* Идемпотентна: повторный запуск не пере-замораживает уже замороженных
|
||||
* (логика sweep-джоба — переход active→frozen / frozen→active, стабильное
|
||||
* состояние не трогается).
|
||||
*/
|
||||
final class BillingPreflightInitialSweepCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'billing:preflight-initial-sweep';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Разовый преfflight при внедрении — заморозить недофинансированных тенантов';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->warn('Разовый преfflight всех тенантов. Запускать ОДИН раз после выкатки Spec C.');
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
$this->info('Initial sweep завершён.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Billing\BalancePreflightSweepJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class BillingPreflightSweepCommand extends Command
|
||||
{
|
||||
protected $signature = 'billing:preflight-sweep';
|
||||
|
||||
protected $description = 'Ежедневный преfflight баланса — заморозка/разморозка тенантов (cut-off 18:00 MSK)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
$this->info('Преfflight sweep завершён.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Одноразовый бэкфилл: проставляет deals.city (имя субъекта) у уже существующих сделок,
|
||||
* у которых city ещё пуст, по resolved_subject_code связанного лида
|
||||
* (deals → supplier_lead_deliveries → supplier_leads). Идемпотентно (только city IS NULL).
|
||||
*
|
||||
* Запускается через .github/workflows/artisan-run.yml (mutating-whitelist, confirm_apply).
|
||||
* Парная правка для RouteSupplierLeadJob, который заполняет city у новых сделок.
|
||||
*/
|
||||
final class DealsBackfillRegionCityCommand extends Command
|
||||
{
|
||||
protected $signature = 'deals:backfill-region-city {--dry-run : Только посчитать, ничего не записывать}';
|
||||
|
||||
protected $description = 'Дозаполнить deals.city именем региона по resolved_subject_code лида (одноразовый бэкфилл)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
// BYPASSRLS-роль: бэкфилл идёт по всем тенантам без SET app.current_tenant_id.
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
$map = RussianRegions::CODE_TO_NAME;
|
||||
|
||||
$rows = $conn->table('deals')
|
||||
->join('supplier_lead_deliveries as dlv', 'dlv.deal_id', '=', 'deals.id')
|
||||
->join('supplier_leads as sl', 'sl.id', '=', 'dlv.supplier_lead_id')
|
||||
->whereNull('deals.city')
|
||||
->whereNotNull('sl.resolved_subject_code')
|
||||
->select('deals.id', 'deals.received_at', 'sl.resolved_subject_code')
|
||||
->get();
|
||||
|
||||
$seen = [];
|
||||
$updated = 0;
|
||||
foreach ($rows as $r) {
|
||||
$dealId = (int) $r->id;
|
||||
if (isset($seen[$dealId])) {
|
||||
continue; // у сделки несколько доставок — обрабатываем один раз
|
||||
}
|
||||
$seen[$dealId] = true;
|
||||
|
||||
$name = $map[(int) $r->resolved_subject_code] ?? null;
|
||||
if ($name === null) {
|
||||
continue; // код вне справочника 1..89 — пропускаем
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$conn->table('deals')
|
||||
->where('id', $dealId)
|
||||
->where('received_at', $r->received_at) // partition key
|
||||
->whereNull('city') // идемпотентный страж
|
||||
->update(['city' => $name]);
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$prefix = $dryRun ? '[dry-run] ' : '';
|
||||
$this->info("{$prefix}deals.city backfill: {$updated} обновлено из ".count($seen).' кандидатов.');
|
||||
Log::info('deals.backfill_region_city', [
|
||||
'updated' => $updated,
|
||||
'candidates' => count($seen),
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Imitation;
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\RussianRegions;
|
||||
use Carbon\Carbon;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Populate a LOCAL portal with imitation clients and leads for hands-on UI review
|
||||
* (Phase 1 imitation harness). It NEVER runs on production.
|
||||
*
|
||||
* Self-contained on purpose (it must not depend on test-harness helpers): it funds
|
||||
* a few tenant balances locally, disables the external DaData call (region is taken
|
||||
* from the lead tag), builds the routing snapshot for the active date, then injects
|
||||
* synthetic leads through the real RouteSupplierLeadJob so deals, charges and
|
||||
* notifications appear exactly as they would in production.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md
|
||||
*/
|
||||
final class ImitationSeedCommand extends Command
|
||||
{
|
||||
protected $signature = 'imitation:seed
|
||||
{--leads=20 : Number of synthetic leads to inject}
|
||||
{--clients=3 : Number of imitation clients to create}';
|
||||
|
||||
protected $description = 'Populate the LOCAL portal with imitation clients and leads for UI review (never on production)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->getLaravel()->environment('production')) {
|
||||
$this->error('imitation:seed is forbidden in production.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$leads = max(1, (int) $this->option('leads'));
|
||||
$clients = max(1, (int) $this->option('clients'));
|
||||
|
||||
// Region comes from the lead tag — no external (paid) DaData call.
|
||||
config(['services.dadata.enabled' => false]);
|
||||
|
||||
// Reference data required by the ledger.
|
||||
(new PricingTierSeeder)->run();
|
||||
|
||||
$moscow = RussianRegions::nameToCode()['Москва']; // ordinal 82
|
||||
|
||||
// One shared supplier source (B2 site signal). The unique_key must be a
|
||||
// domain-like string: RouteSupplierLeadJob re-resolves the supplier from the
|
||||
// lead payload by (platform, unique_key) and infers signal_type from the
|
||||
// identifier shape (see parseProjectField/resolveOrStub) — a domain → 'site'.
|
||||
$supplierKey = 'imitseed-'.strtolower(Str::random(8)).'.test';
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => $supplierKey,
|
||||
]);
|
||||
|
||||
// Funded imitation clients, all targeting Москва, full week, generous limit.
|
||||
for ($i = 1; $i <= $clients; $i++) {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$project = Project::factory()
|
||||
->asSiteSignal('imitseed-'.$i.'-'.Str::random(6).'.test')
|
||||
->create([
|
||||
'name' => "IMIT-seed-client-{$i}",
|
||||
'tenant_id' => $tenant->id,
|
||||
'regions' => [$moscow],
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 1000,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $supplier->id,
|
||||
'platform' => $supplier->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Build the routing snapshot for the active date the router will query.
|
||||
Artisan::call('snapshot:rebuild', ['--date' => $this->activeDate()]);
|
||||
|
||||
// Inject synthetic leads through the real routing + ledger pipeline.
|
||||
$injected = 0;
|
||||
for ($n = 1; $n <= $leads; $n++) {
|
||||
$phone = '79'.str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
$vid = random_int(100_000_000, 999_999_999);
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => $supplier->id,
|
||||
'platform' => $supplier->platform,
|
||||
'phone' => $phone,
|
||||
'vid' => $vid,
|
||||
'raw_payload' => [
|
||||
'vid' => $vid,
|
||||
'project' => $supplier->platform.'_'.$supplierKey,
|
||||
'tag' => 'Москва',
|
||||
'phone' => $phone,
|
||||
'phones' => [$phone],
|
||||
'time' => now()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
'deals_created_count' => null,
|
||||
]);
|
||||
|
||||
RouteSupplierLeadJob::dispatchSync($lead->id);
|
||||
$injected++;
|
||||
}
|
||||
|
||||
$this->info("imitation:seed done — {$clients} clients, {$injected} leads injected (region from tag, DaData disabled).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active snapshot date — mirrors LeadRouter::activeSnapshotDate()
|
||||
* (today before 21:00 MSK, tomorrow at/after).
|
||||
*/
|
||||
private function activeDate(): string
|
||||
{
|
||||
$msk = Carbon::now('Europe/Moscow');
|
||||
|
||||
return ($msk->hour >= 21 ? $msk->copy()->addDay() : $msk)->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,12 @@ class IncidentsWatchFailures extends Command
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
protected $signature = 'incidents:watch-failures
|
||||
{--window=10 : Окно сканирования в минутах}
|
||||
{--threshold=200 : Порог спайка для failed_webhook_jobs}
|
||||
{--threshold-spike=10 : Порог спайка для failed_jobs (за окно)}
|
||||
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
|
||||
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
|
||||
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}
|
||||
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}';
|
||||
{--window=10 : Окно сканирования в минутах}
|
||||
{--threshold=200 : Порог спайка для failed_webhook_jobs}
|
||||
{--threshold-spike=10 : Порог спайка для failed_jobs (за окно)}
|
||||
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
|
||||
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
|
||||
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}';
|
||||
|
||||
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
|
||||
|
||||
@@ -46,8 +45,6 @@ class IncidentsWatchFailures extends Command
|
||||
$persistentHours = (int) $this->option('persistent-hours');
|
||||
$dedupMinutes = (int) $this->option('dedup-window');
|
||||
|
||||
$thresholdSingleLead = (int) $this->option('threshold-single-lead');
|
||||
|
||||
$since = Carbon::now()->subMinutes($windowMinutes);
|
||||
$since24h = Carbon::now()->subHours(24);
|
||||
$dedupAt = Carbon::now()->subMinutes($dedupMinutes);
|
||||
@@ -188,39 +185,6 @@ class IncidentsWatchFailures extends Command
|
||||
$this->info("Job persistent [medium]: {$jobClass}");
|
||||
}
|
||||
|
||||
// ===== БЛОК 5: single-lead storm detection =====
|
||||
// Detects случай когда один supplier_lead_id генерирует >= threshold
|
||||
// failures за окно — классический шторм от застрявшего лида (Finding 2,
|
||||
// 2026-05-29). Создаём severity=high инцидент per lead_id.
|
||||
if ($thresholdSingleLead > 0) {
|
||||
$stormLeads = DB::connection(self::DB_CONNECTION)
|
||||
->table('failed_webhook_jobs')
|
||||
->selectRaw("raw_payload->>'supplier_lead_id' AS lead_id, COUNT(*) AS cnt")
|
||||
->whereNull('resolved_at')
|
||||
->where('failed_at', '>=', $since)
|
||||
->whereRaw("raw_payload ?? 'supplier_lead_id'")
|
||||
->groupByRaw("raw_payload->>'supplier_lead_id'")
|
||||
->havingRaw('COUNT(*) >= ?', [$thresholdSingleLead])
|
||||
->get();
|
||||
|
||||
foreach ($stormLeads as $row) {
|
||||
$leadId = $row->lead_id;
|
||||
$cnt = (int) $row->cnt;
|
||||
$dedupKey = "single-lead-storm:{$leadId}";
|
||||
|
||||
if ($this->isDup($dedupKey, $dedupAt)) {
|
||||
$this->line("Skipping single-lead-storm (dedup): {$dedupKey}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = "Автоматически: single-lead-storm {$cnt} failures supplier_lead_id={$leadId} за {$windowMinutes} мин. Вероятная причина: terminal error без fast-fail guard.";
|
||||
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey);
|
||||
$created++;
|
||||
$this->info("Single-lead storm [high]: lead_id={$leadId} — {$cnt}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Done. Created {$created} incident(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Pd;
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* F-P1 / 152-ФЗ ретеншен: анонимизирует ПДн soft-deleted сделок по истечении
|
||||
* настраиваемого срока (спека 2026-06-17-fp1-deal-pii-retention-spec).
|
||||
*
|
||||
* Срок (дней) — в system_settings, ключ `pd_scrub_soft_deleted_deals_days`.
|
||||
* Отсутствие ключа или значение < 1 → no-op (юридический срок не зашит в код,
|
||||
* выставляется на проде). Паттерн безопасности идентичен PartitionsDropExpired.
|
||||
*
|
||||
* Значения анонимизации идентичны PdErasureService::eraseSubject. Работает
|
||||
* cross-tenant через pgsql_supplier (BYPASSRLS). Идемпотентно: уже затёртые
|
||||
* (phone = ANON_PHONE) исключаются из выборки.
|
||||
*/
|
||||
class ScrubSoftDeletedDealsCommand extends Command
|
||||
{
|
||||
private const DB = 'pgsql_supplier';
|
||||
|
||||
private const SETTING_KEY = 'pd_scrub_soft_deleted_deals_days';
|
||||
|
||||
private const ANON_PHONE = '+7000XXXXXXX';
|
||||
|
||||
private const ANON_NAME = 'Удалено';
|
||||
|
||||
/** @var string */
|
||||
protected $signature = 'pd:scrub-soft-deleted-deals
|
||||
{--dry-run : Показать число кандидатов, не анонимизировать}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Анонимизирует ПДн (телефон/имя) soft-deleted сделок старше retention-срока (152-ФЗ, F-P1)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = $this->resolveRetentionDays();
|
||||
|
||||
if ($days === null) {
|
||||
$this->line('<fg=gray>skip</> retention не настроен (system_settings.'.self::SETTING_KEY.' отсутствует или < 1).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$cutoff = CarbonImmutable::now()->subDays($days);
|
||||
$candidates = $this->candidateQuery($cutoff)->count();
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line("<fg=yellow>[dry-run]</> кандидатов на анонимизацию: {$candidates} (deleted_at старше {$days} дн.)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($candidates === 0) {
|
||||
$this->info("Кандидатов на анонимизацию нет (retention={$days} дн.).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
// Bulk-UPDATE атомарен одним SQL; лог — одна summary-запись. Явная
|
||||
// транзакция не нужна и несовместима с shared-PDO в тестах
|
||||
// (pgsql_supplier делит сессию с уже открытой транзакцией pgsql).
|
||||
$this->candidateQuery($cutoff)->update([
|
||||
'phone' => self::ANON_PHONE,
|
||||
'contact_name' => self::ANON_NAME,
|
||||
'phones' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Системное действие: оба actor-поля NULL (допускается chk_pd_actor).
|
||||
// log_hash заполняется триггером цепочки целостности.
|
||||
DB::connection(self::DB)->table('pd_processing_log')->insert([
|
||||
'tenant_id' => null,
|
||||
'subject_type' => 'deal',
|
||||
'subject_id' => null,
|
||||
'action' => 'deleted',
|
||||
'purpose' => '152-FZ retention scrub',
|
||||
'actor_tenant_user_id' => null,
|
||||
'actor_admin_user_id' => null,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
$this->info("Анонимизировано сделок: {$candidates} (retention={$days} дн.).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** Кандидаты: soft-deleted старше cutoff, ещё не анонимизированные. */
|
||||
private function candidateQuery(CarbonImmutable $cutoff): Builder
|
||||
{
|
||||
return DB::connection(self::DB)->table('deals')
|
||||
->whereNotNull('deleted_at')
|
||||
->where('deleted_at', '<', $cutoff)
|
||||
->where('phone', '<>', self::ANON_PHONE);
|
||||
}
|
||||
|
||||
/** Срок ретеншена из system_settings; null если ключа нет или значение < 1. */
|
||||
private function resolveRetentionDays(): ?int
|
||||
{
|
||||
$setting = SystemSetting::find(self::SETTING_KEY);
|
||||
|
||||
if ($setting === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = (int) $setting->value;
|
||||
|
||||
return $value >= 1 ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
|
||||
|
||||
/**
|
||||
* Импорт реестра нумерации Россвязи в `phone_ranges` (spec §6).
|
||||
*
|
||||
* php artisan phone-ranges:import --file=<csv|xlsx> [--force] [--dry-run]
|
||||
* php artisan phone-ranges:import --dir=<dir с пакетом файлов> [...]
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. Резолв входных файлов (--file | --dir; --url отложен — оператор качает пакет вручную).
|
||||
* 2. Checksum-идемпотентность: совпал с предыдущим `completed` → status='rolled_back', выход.
|
||||
* 3. Парсинг (CSV через str_getcsv ';', XLSX через openspout) → нормализованные строки.
|
||||
* 4. Маппинг region → subject_code через RussianRegions::nameToCode(). Несматчившиеся → лог в error.
|
||||
* 5. Сборка `phone_ranges_staging` (LIKE phone_ranges) + bulk INSERT.
|
||||
* 6. --dry-run → staging остаётся для инспекции, swap НЕ делается, status='rolled_back'.
|
||||
* Иначе → atomic RENAME swap + status='completed'.
|
||||
*
|
||||
* Запись идёт через `pgsql_supplier` (на проде crm_supplier_worker — член crm_migrator,
|
||||
* INHERIT даёт CREATE; SET ROLE crm_migrator выравнивает ownership. На dev/test — postgres superuser).
|
||||
*
|
||||
* NB (swap — operator-validated): committing-swap (шаг 6 else) НЕ покрыт автотестом —
|
||||
* RENAME коммитит и сломал бы общую тестовую БД. Свап проверяется первым реальным
|
||||
* импортом оператора по runbook (Session 6). Тесты покрывают parse/map/dry-run/idempotency.
|
||||
*/
|
||||
class PhoneRangesImportCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'phone-ranges:import
|
||||
{--file= : Путь к одному CSV/XLSX файлу реестра}
|
||||
{--dir= : Каталог с пакетом файлов реестра (*.csv, *.xlsx)}
|
||||
{--url= : (отложено) URL пакета — скачать вручную и использовать --dir}
|
||||
{--force : Игнорировать checksum-идемпотентность}
|
||||
{--dry-run : Распарсить и собрать staging, но не делать atomic swap}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Импорт реестра нумерации Россвязи в phone_ranges (idempotent, atomic swap)';
|
||||
|
||||
/** Connection для DDL/записи (на проде crm_migrator-capable, на dev/test — superuser fallback). */
|
||||
private const DDL_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/** Размер пачки для bulk INSERT в staging. */
|
||||
private const INSERT_CHUNK = 1000;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$files = $this->resolveFiles();
|
||||
if ($files === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$checksum = $this->computeChecksum($files);
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
// 2. Идемпотентность по checksum (если не --force).
|
||||
if (! $force) {
|
||||
$prev = DB::table('phone_ranges_imports')
|
||||
->where('checksum_sha256', $checksum)
|
||||
->where('status', 'completed')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($prev !== null) {
|
||||
DB::table('phone_ranges_imports')->insert([
|
||||
'source_url' => $this->sourceLabel($files),
|
||||
'checksum_sha256' => $checksum,
|
||||
'status' => 'rolled_back',
|
||||
'rows_inserted' => 0,
|
||||
'rows_updated' => 0,
|
||||
'error' => "Идентично импорту #{$prev->id} (checksum совпал) — пропуск.",
|
||||
'imported_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->info("Реестр идентичен импорту #{$prev->id} — пропуск (используйте --force для принудительного импорта).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Журнал импорта (in_progress).
|
||||
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
|
||||
'source_url' => $this->sourceLabel($files),
|
||||
'checksum_sha256' => $checksum,
|
||||
'status' => 'in_progress',
|
||||
'imported_at' => now(),
|
||||
]);
|
||||
|
||||
try {
|
||||
// 4. Парсинг + маппинг.
|
||||
$unmatched = [];
|
||||
$rows = [];
|
||||
foreach ($files as $file) {
|
||||
foreach ($this->parseFile($file) as $rec) {
|
||||
$regionNormalized = RussianRegions::canonicalRegionName($rec['region']);
|
||||
$subjectCode = $regionNormalized === null
|
||||
? null
|
||||
: (RussianRegions::nameToCode()[$regionNormalized] ?? null);
|
||||
if ($subjectCode === null && trim($rec['region']) !== '') {
|
||||
$unmatched[trim($rec['region'])] = true;
|
||||
}
|
||||
$rows[] = [
|
||||
'def_code' => $rec['def_code'],
|
||||
'from_num' => $rec['from_num'],
|
||||
'to_num' => $rec['to_num'],
|
||||
'operator' => $rec['operator'],
|
||||
'region' => $rec['region'],
|
||||
'region_normalized' => $regionNormalized,
|
||||
'subject_code' => $subjectCode,
|
||||
'imported_at' => now(),
|
||||
'import_id' => $importId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Сборка staging.
|
||||
$this->buildStaging($rows, $importId);
|
||||
|
||||
$unmatchedNote = $unmatched === []
|
||||
? ''
|
||||
: 'Не сопоставлены регионы: '.implode(', ', array_keys($unmatched)).'.';
|
||||
|
||||
if ($dryRun) {
|
||||
DB::table('phone_ranges_imports')->where('id', $importId)->update([
|
||||
'status' => 'rolled_back',
|
||||
'rows_inserted' => count($rows),
|
||||
'error' => trim('dry-run (swap не выполнен). '.$unmatchedNote),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->info('dry-run: '.count($rows).' строк в phone_ranges_staging, swap не выполнен.');
|
||||
if ($unmatchedNote !== '') {
|
||||
$this->warn($unmatchedNote);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 6. Atomic swap (operator-validated — см. docblock).
|
||||
$this->atomicSwap();
|
||||
|
||||
DB::table('phone_ranges_imports')->where('id', $importId)->update([
|
||||
'status' => 'completed',
|
||||
'rows_inserted' => count($rows),
|
||||
'error' => $unmatchedNote !== '' ? $unmatchedNote : null,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->info('Импортировано '.count($rows).' строк в phone_ranges (atomic swap выполнен).');
|
||||
if ($unmatchedNote !== '') {
|
||||
$this->warn($unmatchedNote);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
DB::table('phone_ranges_imports')->where('id', $importId)->update([
|
||||
'status' => 'failed',
|
||||
'error' => mb_substr($e->getMessage(), 0, 2000),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->error('Импорт упал: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null Список файлов или null при ошибке валидации опций.
|
||||
*/
|
||||
private function resolveFiles(): ?array
|
||||
{
|
||||
$file = $this->option('file');
|
||||
$dir = $this->option('dir');
|
||||
$url = $this->option('url');
|
||||
|
||||
if ($url !== null) {
|
||||
$this->error('--url отложен (пакет ~500-600 файлов). Скачайте вручную и используйте --dir.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($file !== null) {
|
||||
if (! is_file($file)) {
|
||||
$this->error("Файл не найден: {$file}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$file];
|
||||
}
|
||||
|
||||
if ($dir !== null) {
|
||||
if (! is_dir($dir)) {
|
||||
$this->error("Каталог не найден: {$dir}");
|
||||
|
||||
return null;
|
||||
}
|
||||
$found = glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.{csv,xlsx}', GLOB_BRACE) ?: [];
|
||||
if ($found === []) {
|
||||
$this->error("В каталоге нет *.csv / *.xlsx: {$dir}");
|
||||
|
||||
return null;
|
||||
}
|
||||
sort($found);
|
||||
|
||||
return array_values($found);
|
||||
}
|
||||
|
||||
$this->error('Укажите --file=<путь> или --dir=<каталог>.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $files
|
||||
*/
|
||||
private function computeChecksum(array $files): string
|
||||
{
|
||||
if (count($files) === 1) {
|
||||
return (string) hash_file('sha256', $files[0]);
|
||||
}
|
||||
|
||||
$hashes = array_map(static fn (string $f): string => (string) hash_file('sha256', $f), $files);
|
||||
sort($hashes);
|
||||
|
||||
return hash('sha256', implode('|', $hashes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $files
|
||||
*/
|
||||
private function sourceLabel(array $files): string
|
||||
{
|
||||
return $this->option('url')
|
||||
?? $this->option('dir')
|
||||
?? ($files[0] ?? 'unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит один файл реестра в нормализованные строки.
|
||||
*
|
||||
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
|
||||
*/
|
||||
private function parseFile(string $path): array
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
return $ext === 'xlsx'
|
||||
? $this->parseXlsx($path)
|
||||
: $this->parseCsv($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
|
||||
*/
|
||||
private function parseCsv(string $path): array
|
||||
{
|
||||
$content = (string) file_get_contents($path);
|
||||
// BOM strip + split строк (CRLF/CR/LF).
|
||||
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content) ?? $content;
|
||||
$lines = preg_split('/\r\n|\r|\n/', rtrim($content)) ?: [];
|
||||
if ($lines === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$header = str_getcsv((string) array_shift($lines), ';');
|
||||
$cols = $this->resolveColumns($header);
|
||||
|
||||
$out = [];
|
||||
foreach ($lines as $line) {
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
$cells = str_getcsv($line, ';');
|
||||
$rec = $this->mapCells($cells, $cols);
|
||||
if ($rec !== null) {
|
||||
$out[] = $rec;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг XLSX через openspout (operator-real-files; CSV-ветка покрыта тестом).
|
||||
*
|
||||
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
|
||||
*/
|
||||
private function parseXlsx(string $path): array
|
||||
{
|
||||
$reader = new XlsxReader;
|
||||
$reader->open($path);
|
||||
|
||||
$out = [];
|
||||
$cols = null;
|
||||
foreach ($reader->getSheetIterator() as $sheet) {
|
||||
foreach ($sheet->getRowIterator() as $row) {
|
||||
$cells = array_map(static fn ($c): string => (string) $c, $row->toArray());
|
||||
if ($cols === null) {
|
||||
$cols = $this->resolveColumns($cells);
|
||||
|
||||
continue;
|
||||
}
|
||||
$rec = $this->mapCells($cells, $cols);
|
||||
if ($rec !== null) {
|
||||
$out[] = $rec;
|
||||
}
|
||||
}
|
||||
break; // только первый лист
|
||||
}
|
||||
$reader->close();
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сопоставляет индексы колонок по заголовку (русские имена Россвязи) с позиционным fallback.
|
||||
*
|
||||
* @param list<string> $header
|
||||
* @return array{def:int, from:int, to:int, operator:int, region:int}
|
||||
*/
|
||||
private function resolveColumns(array $header): array
|
||||
{
|
||||
$cols = ['def' => 0, 'from' => 1, 'to' => 2, 'operator' => 4, 'region' => 5];
|
||||
|
||||
foreach ($header as $i => $cell) {
|
||||
$n = preg_replace('/[\s\/]+/u', '', mb_strtolower(trim((string) $cell))) ?? '';
|
||||
if (str_contains($n, 'def') || str_contains($n, 'авс')) {
|
||||
$cols['def'] = $i;
|
||||
} elseif ($n === 'от') {
|
||||
$cols['from'] = $i;
|
||||
} elseif ($n === 'до') {
|
||||
$cols['to'] = $i;
|
||||
} elseif (str_contains($n, 'оператор')) {
|
||||
$cols['operator'] = $i;
|
||||
} elseif (str_contains($n, 'регион')) {
|
||||
$cols['region'] = $i;
|
||||
}
|
||||
}
|
||||
|
||||
return $cols;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $cells
|
||||
* @param array{def:int, from:int, to:int, operator:int, region:int} $cols
|
||||
* @return array{def_code:int, from_num:int, to_num:int, operator:string, region:string}|null
|
||||
*/
|
||||
private function mapCells(array $cells, array $cols): ?array
|
||||
{
|
||||
$def = (int) preg_replace('/\D+/', '', $cells[$cols['def']] ?? '');
|
||||
if ($def === 0) {
|
||||
return null; // пустая/битая строка
|
||||
}
|
||||
|
||||
return [
|
||||
'def_code' => $def,
|
||||
'from_num' => (int) preg_replace('/\D+/', '', $cells[$cols['from']] ?? '0'),
|
||||
'to_num' => (int) preg_replace('/\D+/', '', $cells[$cols['to']] ?? '0'),
|
||||
'operator' => trim((string) ($cells[$cols['operator']] ?? '')),
|
||||
'region' => trim((string) ($cells[$cols['region']] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает phone_ranges_staging (LIKE phone_ranges) и заливает строки.
|
||||
*
|
||||
* id: НЕ копируем серийный default через INCLUDING DEFAULTS — он ссылается на
|
||||
* исходную последовательность phone_ranges, которую atomic-swap уничтожает
|
||||
* (DROP phone_ranges_old CASCADE) после первого импорта, оставляя staging.id
|
||||
* без default (NOT NULL violation на повторном импорте). Вместо этого даём
|
||||
* staging собственную последовательность с уникальным по import_id именем,
|
||||
* OWNED BY колонкой id → она переезжает при RENAME и дропается вместе со
|
||||
* старой таблицей (без коллизий имён и без утечки последовательностей).
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function buildStaging(array $rows, int $importId): void
|
||||
{
|
||||
$c = DB::connection(self::DDL_CONNECTION);
|
||||
$this->elevate($c);
|
||||
|
||||
$seq = "phone_ranges_stg_seq_{$importId}";
|
||||
$c->statement('DROP TABLE IF EXISTS phone_ranges_staging CASCADE');
|
||||
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING CONSTRAINTS)');
|
||||
$c->statement("CREATE SEQUENCE {$seq}");
|
||||
$c->statement("ALTER TABLE phone_ranges_staging ALTER COLUMN id SET DEFAULT nextval('{$seq}')");
|
||||
$c->statement("ALTER SEQUENCE {$seq} OWNED BY phone_ranges_staging.id");
|
||||
$c->statement('CREATE INDEX IF NOT EXISTS idx_phone_ranges_staging_lookup ON phone_ranges_staging (def_code, from_num, to_num)');
|
||||
|
||||
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
|
||||
$c->table('phone_ranges_staging')->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic swap живого phone_ranges на staging (spec §6.2 шаг 6).
|
||||
*
|
||||
* NB: НЕ покрыт автотестом (committing RENAME сломал бы общую тестовую БД).
|
||||
* Проверяется первым реальным импортом оператора (Session 6 runbook).
|
||||
* Сохраняет одну предыдущую версию (phone_ranges_old) для `phone-ranges:rollback`.
|
||||
* GRANT'ы переустанавливаются (RENAME их не переносит); lookup-индекс на новой
|
||||
* таблице носит имя idx_phone_ranges_staging_lookup (косметика — имя занято _old).
|
||||
*/
|
||||
private function atomicSwap(): void
|
||||
{
|
||||
$c = DB::connection(self::DDL_CONNECTION);
|
||||
$this->elevate($c);
|
||||
|
||||
// Транзакция вокруг свапа (spec §6.2): PostgreSQL поддерживает транзакционный
|
||||
// DDL, поэтому DROP+RENAME+RENAME+GRANT атомарны. Обрыв процесса между
|
||||
// переименованиями не оставит phone_ranges несуществующей — откат вернёт
|
||||
// живую таблицу (раньше 4 авто-коммит-statement'а оставляли окно, в котором
|
||||
// Россвязь-lookup падал бы до ручного восстановления).
|
||||
$c->transaction(function () use ($c) {
|
||||
$c->statement('DROP TABLE IF EXISTS phone_ranges_old CASCADE');
|
||||
$c->statement('ALTER TABLE phone_ranges RENAME TO phone_ranges_old');
|
||||
$c->statement('ALTER TABLE phone_ranges_staging RENAME TO phone_ranges');
|
||||
$c->statement('GRANT SELECT ON phone_ranges TO crm_app_user, crm_supplier_worker');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SET ROLE crm_migrator для корректного ownership на проде; на dev/test роль
|
||||
* отсутствует → RESET и работаем как superuser (зеркало миграционного паттерна).
|
||||
*/
|
||||
private function elevate(Connection $c): void
|
||||
{
|
||||
try {
|
||||
$c->statement('SET ROLE crm_migrator');
|
||||
$canCreate = $c->selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
|
||||
if (! $canCreate || ! $canCreate->ok) {
|
||||
$c->statement('RESET ROLE');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// окружение без роли — продолжаем как superuser
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SupplierLead;
|
||||
use App\Services\LeadRegionResolver;
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Staging-smoke резолва региона по телефону (spec §9.4): дёргает живой каскад
|
||||
* DaData → Россвязь → tag и печатает решение. В БД ничего НЕ пишет.
|
||||
*
|
||||
* php artisan phone-region:smoke --phone=79161234567 [--tag=Москва]
|
||||
*
|
||||
* Принудительно включает services.dadata.enabled на время прогона (smoke всегда
|
||||
* проверяет полный каскад, независимо от глобального feature-flag). С реальным
|
||||
* DADATA_API_KEY делает платный вызов — запускать осознанно.
|
||||
*/
|
||||
class PhoneRegionSmokeCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'phone-region:smoke
|
||||
{--phone= : Телефон в формате 7XXXXXXXXXX}
|
||||
{--tag= : Регион-тег поставщика (fallback-слой)}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Прогон резолва региона по телефону (DaData→Россвязь→tag) без записи в БД (staging-smoke)';
|
||||
|
||||
public function handle(LeadRegionResolver $resolver): int
|
||||
{
|
||||
$phone = (string) $this->option('phone');
|
||||
if ($phone === '') {
|
||||
$this->error('Укажите --phone=7XXXXXXXXXX');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Smoke всегда прогоняет полный каскад, даже если глобальный флаг выключен.
|
||||
config(['services.dadata.enabled' => true]);
|
||||
|
||||
$lead = new SupplierLead([
|
||||
'phone' => $phone,
|
||||
'raw_payload' => ['tag' => (string) $this->option('tag')],
|
||||
]);
|
||||
|
||||
$r = $resolver->resolve($lead);
|
||||
|
||||
$region = $r->subjectCode !== null
|
||||
? (RussianRegions::CODE_TO_NAME[$r->subjectCode] ?? '?')
|
||||
: '—';
|
||||
|
||||
$this->info('Телефон: '.$this->maskPhone($phone));
|
||||
$this->line('Источник: '.$r->source);
|
||||
$this->line('Субъект: '.($r->subjectCode ?? '—').' ('.$region.')');
|
||||
$this->line('Оператор: '.($r->phoneOperator ?? '—'));
|
||||
$this->line('DaData qc: '.($r->qc ?? '—'));
|
||||
$this->line('Cache hit: '.($r->cacheHit ? 'да' : 'нет'));
|
||||
$this->line('Россвязь: '.($r->rossvyazMatched ? 'совпала' : 'нет'));
|
||||
$this->line('Длит., мс: '.($r->durationMs ?? '—'));
|
||||
$this->newLine();
|
||||
$this->comment('NB: запись в БД НЕ выполнялась (smoke).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $phone) ?? '';
|
||||
if (strlen($digits) < 8) {
|
||||
return '***';
|
||||
}
|
||||
|
||||
return substr($digits, 0, 4).'***'.substr($digits, -4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Reminder;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Cron-команда диспатча due-reminders.
|
||||
*
|
||||
* Идёт по `reminders` где `is_sent=false AND completed_at IS NULL AND
|
||||
* remind_at <= NOW()`. Для каждой строки:
|
||||
* 1) NotificationService::notifyReminder (email + inapp по prefs);
|
||||
* 2) UPDATE is_sent=true, sent_at=NOW().
|
||||
*
|
||||
* RLS: SET LOCAL app.current_tenant_id = reminder.tenant_id внутри
|
||||
* транзакции каждой обработки (по одному reminder в транзакции — иначе
|
||||
* нельзя переключить tenant между строками с разных tenant'ов).
|
||||
*
|
||||
* Запускается каждую минуту через Windows Task Scheduler / cron.
|
||||
* Идемпотентна: повторный вызов на отправленных ($is_sent=true) skipаются.
|
||||
*
|
||||
* --dry-run печатает плановых получателей без реальных INSERT'ов.
|
||||
*
|
||||
* Источник: db/schema.sql §17.5; ТЗ §6.6 / §18.5.
|
||||
*/
|
||||
class RemindersDispatchDue extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'reminders:dispatch-due
|
||||
{--dry-run : Не отправлять, только напечатать список плановых получателей}
|
||||
{--limit=500 : Максимум reminders за один запуск}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Диспатч due-reminders: email/inapp уведомления + is_sent=true (ТЗ §18.5)';
|
||||
|
||||
public function handle(NotificationService $service): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$now = Carbon::now();
|
||||
|
||||
// Cross-tenant gather via BYPASSRLS connection — on prod crm_app_user cannot
|
||||
// call current_setting('app.current_tenant_id') without a GUC set first.
|
||||
// pgsql_supplier (crm_supplier_worker, BYPASSRLS) is the canonical pattern
|
||||
// for SaaS-admin cron queries (precedent: IncidentsWatchFailures, Reset*).
|
||||
$rows = DB::connection('pgsql_supplier')
|
||||
->table('reminders')
|
||||
->select(['id', 'tenant_id', 'deal_id', 'remind_at'])
|
||||
->where('is_sent', false)
|
||||
->whereNull('completed_at')
|
||||
->where('remind_at', '<=', $now)
|
||||
->orderBy('remind_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
$this->info('Нет due-reminders.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
' would dispatch <fg=yellow>id=%d</> tenant=%d deal=%d remind_at=%s',
|
||||
$row->id,
|
||||
$row->tenant_id,
|
||||
$row->deal_id,
|
||||
$row->remind_at ?? '-',
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($row, $service): void {
|
||||
// SET LOCAL scopes GUC to this transaction — PgBouncer-safe.
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $row->tenant_id);
|
||||
// Fetch the full Eloquent model with tenant context active so
|
||||
// relations (user, etc.) work correctly inside NotificationService.
|
||||
$reminder = Reminder::query()->findOrFail((int) $row->id);
|
||||
$service->notifyReminder($reminder);
|
||||
$reminder->update([
|
||||
'is_sent' => true,
|
||||
'sent_at' => Carbon::now(),
|
||||
]);
|
||||
});
|
||||
$sent++;
|
||||
$this->info(" dispatched <fg=green>id={$row->id}</>");
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->error(" failed <fg=red>id={$row->id}</>: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Done: {$sent} sent, {$failed} failed (limit={$limit}, dry-run=".($dryRun ? '1' : '0').').');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Создаёт project_routing_snapshots за указанную дату из текущего live-состояния.
|
||||
* Используется один раз при выкатке Этапа 2 + для ручного recovery после падения cron'а.
|
||||
*
|
||||
* Spec §4.2.6.
|
||||
*/
|
||||
final class SnapshotBackfillCommand extends Command
|
||||
{
|
||||
protected $signature = 'snapshot:backfill {--date= : YYYY-MM-DD, по умолчанию сегодня}';
|
||||
|
||||
protected $description = 'Заполнить project_routing_snapshots за указанную дату из live projects';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dateStr = (string) ($this->option('date') ?? Carbon::today('Europe/Moscow')->toDateString());
|
||||
$date = Carbon::parse($dateStr, 'Europe/Moscow');
|
||||
$weekdayBit = 1 << ($date->isoWeekday() - 1);
|
||||
|
||||
$count = DB::connection('pgsql_supplier')->transaction(function () use ($dateStr, $weekdayBit) {
|
||||
return DB::connection('pgsql_supplier')->insert(<<<'SQL'
|
||||
INSERT INTO project_routing_snapshots (
|
||||
snapshot_date, project_id, tenant_id,
|
||||
daily_limit, delivery_days_mask, regions,
|
||||
signal_type, signal_identifier, sms_senders, sms_keyword,
|
||||
expected_volume
|
||||
)
|
||||
SELECT
|
||||
?::date,
|
||||
p.id, p.tenant_id,
|
||||
COALESCE(p.effective_daily_limit_today, p.daily_limit_target),
|
||||
p.delivery_days_mask, p.regions,
|
||||
p.signal_type, p.signal_identifier, p.sms_senders, p.sms_keyword,
|
||||
COALESCE(p.effective_daily_limit_today, p.daily_limit_target)
|
||||
FROM projects p
|
||||
INNER JOIN tenants t ON t.id = p.tenant_id
|
||||
WHERE p.is_active = true
|
||||
AND (p.delivery_days_mask & ?::int) <> 0
|
||||
AND p.preflight_blocked_at IS NULL
|
||||
AND t.frozen_by_balance_at IS NULL
|
||||
AND t.deleted_at IS NULL
|
||||
ON CONFLICT (snapshot_date, project_id) DO NOTHING
|
||||
SQL, [$dateStr, $weekdayBit]);
|
||||
});
|
||||
|
||||
$this->info("Snapshot backfilled for {$dateStr}: {$count} rows.");
|
||||
Log::info('snapshot.backfill', ['date' => $dateStr, 'rows' => $count]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Перестраивает project_routing_snapshots за указанную дату из текущего
|
||||
* live-состояния, ПЕРЕЗАПИСЫВАЯ существующий snapshot.
|
||||
*
|
||||
* В отличие от `snapshot:backfill` (идемпотентный — ON CONFLICT DO NOTHING),
|
||||
* `snapshot:rebuild` всегда сначала DELETE'ит существующий snapshot за дату,
|
||||
* затем создаёт новый. Используется для manual recovery после падения
|
||||
* `SnapshotProjectRoutingJob` cron'а с уже частично записанным snapshot'ом
|
||||
* (см. Task 2.10, Spec §4.2.6 fail-loud strategy).
|
||||
*
|
||||
* Fail-loud strategy:
|
||||
* 1. Heartbeat alarm via SchedulerHeartbeatTracker (Task 2.4).
|
||||
* 2. LeadRouter Log::error on missing snapshot (Task 2.5).
|
||||
* 3. Manual recovery: `php artisan snapshot:rebuild --date=YYYY-MM-DD`.
|
||||
*
|
||||
* NO fallback to live projects — explicit downtime + alert is safer
|
||||
* than silent regression.
|
||||
*/
|
||||
final class SnapshotRebuildCommand extends Command
|
||||
{
|
||||
protected $signature = 'snapshot:rebuild {--date= : YYYY-MM-DD, по умолчанию сегодня}';
|
||||
|
||||
protected $description = 'Перестроить project_routing_snapshots за указанную дату (DELETE+INSERT, для recovery)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dateStr = (string) ($this->option('date') ?? Carbon::today('Europe/Moscow')->toDateString());
|
||||
$date = Carbon::parse($dateStr, 'Europe/Moscow');
|
||||
$weekdayBit = 1 << ($date->isoWeekday() - 1);
|
||||
|
||||
// NB: НЕ оборачиваем в ->transaction() — это recovery-команда, half-done state
|
||||
// допустим (retry восстанавливает; на проде admin контроль). Wrapper конфликтует
|
||||
// с tests SharesSupplierPdo (shared PDO + nested transaction levels).
|
||||
$deleted = DB::connection('pgsql_supplier')
|
||||
->table('project_routing_snapshots')
|
||||
->where('snapshot_date', $dateStr)
|
||||
->delete();
|
||||
|
||||
$inserted = DB::connection('pgsql_supplier')->insert(<<<'SQL'
|
||||
INSERT INTO project_routing_snapshots (
|
||||
snapshot_date, project_id, tenant_id,
|
||||
daily_limit, delivery_days_mask, regions,
|
||||
signal_type, signal_identifier, sms_senders, sms_keyword,
|
||||
expected_volume
|
||||
)
|
||||
SELECT
|
||||
?::date,
|
||||
p.id, p.tenant_id,
|
||||
COALESCE(p.effective_daily_limit_today, p.daily_limit_target),
|
||||
p.delivery_days_mask, p.regions,
|
||||
p.signal_type, p.signal_identifier, p.sms_senders, p.sms_keyword,
|
||||
COALESCE(p.effective_daily_limit_today, p.daily_limit_target)
|
||||
FROM projects p
|
||||
INNER JOIN tenants t ON t.id = p.tenant_id
|
||||
WHERE p.is_active = true
|
||||
AND (p.delivery_days_mask & ?::int) <> 0
|
||||
AND p.preflight_blocked_at IS NULL
|
||||
AND t.frozen_by_balance_at IS NULL
|
||||
AND t.deleted_at IS NULL
|
||||
SQL, [$dateStr, $weekdayBit]);
|
||||
|
||||
$this->info("Snapshot rebuilt for {$dateStr}: deleted={$deleted}, inserted={$inserted}.");
|
||||
Log::warning('snapshot.rebuild', [
|
||||
'date' => $dateStr,
|
||||
'deleted' => $deleted,
|
||||
'inserted' => $inserted,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Supplier\DeleteSupplierProjectJob;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* One-time migration: clean up orphan supplier_projects rows created by the
|
||||
* now-removed buildUniqueKey($p, $platform) divergence for SMS+keyword projects.
|
||||
*
|
||||
* Before R-17 unification (Stage 4 §4.4.1) SMS+keyword projects had two diverging
|
||||
* supplier_projects keys per group:
|
||||
* B2: unique_key = sender+keyword
|
||||
* B3: unique_key = sender (without keyword) — ORPHAN after unification
|
||||
*
|
||||
* This command finds orphan B3 rows (sms, no '+' in unique_key, owning project has
|
||||
* sms_keyword) and either UPDATEs them to sender+keyword (no sibling) or marks them
|
||||
* for deletion via DeleteSupplierProjectJob (sibling at sender+keyword already exists).
|
||||
*
|
||||
* Usage:
|
||||
* php artisan supplier:rekey-orphans --dry-run # preview
|
||||
* php artisan supplier:rekey-orphans # apply
|
||||
*
|
||||
* Spec §4.4.1.
|
||||
*/
|
||||
final class SupplierRekeyOrphansCommand extends Command
|
||||
{
|
||||
protected $signature = 'supplier:rekey-orphans {--dry-run : Preview without modifying anything}';
|
||||
|
||||
protected $description = 'One-time R-17 cleanup of orphan SMS supplier_projects keyed under sender alone';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
// Find candidate orphans: sms supplier_projects whose unique_key has no '+'
|
||||
// and whose tenant has an SMS project with sms_keyword set matching this sender.
|
||||
$orphans = DB::connection('pgsql_supplier')
|
||||
->table('supplier_projects as sp')
|
||||
->join('project_supplier_links as psl', 'psl.supplier_project_id', '=', 'sp.id')
|
||||
->join('projects as p', 'p.id', '=', 'psl.project_id')
|
||||
->where('sp.signal_type', 'sms')
|
||||
->where('sp.unique_key', 'NOT LIKE', '%+%')
|
||||
->whereNotNull('p.sms_keyword')
|
||||
->where('p.sms_keyword', '!=', '')
|
||||
->select([
|
||||
'sp.id as sp_id',
|
||||
'sp.unique_key as sender',
|
||||
'sp.platform',
|
||||
'p.tenant_id',
|
||||
'p.sms_keyword as keyword',
|
||||
])
|
||||
->get();
|
||||
|
||||
if ($orphans->isEmpty()) {
|
||||
$this->info('No orphan SMS supplier_projects found. Nothing to migrate.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf('Found %d orphan SMS supplier_projects row(s).', $orphans->count()));
|
||||
|
||||
$updated = 0;
|
||||
$dispatched = 0;
|
||||
$toDelete = [];
|
||||
|
||||
foreach ($orphans as $o) {
|
||||
$sender = (string) $o->sender;
|
||||
$keyword = (string) $o->keyword;
|
||||
$newKey = $sender.'+'.$keyword;
|
||||
|
||||
// Sibling check: another supplier_project for same tenant/keyword combo already
|
||||
// exists at the unified key? Look across pivot to the same tenant scope.
|
||||
$siblingExists = DB::connection('pgsql_supplier')
|
||||
->table('supplier_projects as sp2')
|
||||
->join('project_supplier_links as psl2', 'psl2.supplier_project_id', '=', 'sp2.id')
|
||||
->join('projects as p2', 'p2.id', '=', 'psl2.project_id')
|
||||
->where('sp2.signal_type', 'sms')
|
||||
->where('sp2.unique_key', $newKey)
|
||||
->where('p2.tenant_id', $o->tenant_id)
|
||||
->where('sp2.id', '!=', $o->sp_id)
|
||||
->exists();
|
||||
|
||||
if ($siblingExists) {
|
||||
$toDelete[] = (int) $o->sp_id;
|
||||
$this->line(sprintf(
|
||||
' orphan #%d (%s sender=%s) → DELETE (sibling at %s exists for tenant %d)',
|
||||
$o->sp_id, $o->platform, $sender, $newKey, $o->tenant_id
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
' orphan #%d (%s sender=%s) → UPDATE unique_key=%s',
|
||||
$o->sp_id, $o->platform, $sender, $newKey
|
||||
));
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::connection('pgsql_supplier')
|
||||
->table('supplier_projects')
|
||||
->where('id', $o->sp_id)
|
||||
->update(['unique_key' => $newKey, 'updated_at' => now()]);
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun && $toDelete !== []) {
|
||||
DeleteSupplierProjectJob::dispatch($toDelete);
|
||||
$dispatched = count($toDelete);
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('--dry-run: no changes made.');
|
||||
} else {
|
||||
$this->info(sprintf(
|
||||
'Migration complete: %d row(s) updated, %d row(s) queued for deletion.',
|
||||
$updated, $dispatched
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\AuditChainBreachMail;
|
||||
use App\Services\Audit\AuditChainConfig;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -84,12 +83,166 @@ class VerifyAuditChains extends Command
|
||||
|
||||
protected $description = 'Проверяет целостность SHA-256 hash-chain в 6 audit-таблицах (per-partition)';
|
||||
|
||||
/**
|
||||
* Конфигурация таблиц: имя таблицы → [columns, partition_clause].
|
||||
*
|
||||
* columns: список столбцов строго в порядке ordinal_position из db/schema.sql.
|
||||
* Специальное значение '__log_hash__' — маркер позиции log_hash → NULL::bytea.
|
||||
*
|
||||
* partition_clause: SQL-фрагмент для OVER (PARTITION BY … ORDER BY id),
|
||||
* воспроизводящий RLS-scope триггера внутри одной партиции.
|
||||
* Пустая строка = глобальная цепочка внутри партиции.
|
||||
*
|
||||
* @var array<string, array{columns: list<string>, partition: string}>
|
||||
*/
|
||||
private const TABLE_CONFIG = [
|
||||
// auth_log:
|
||||
// RLS: actor_type='tenant_user' AND tenant_id = current_setting(...)
|
||||
// Tenant-сессия видит только (actor_type='tenant_user', tenant_id=N).
|
||||
// saas_admin-сессия BYPASSRLS — видит всё.
|
||||
// Partition (actor_type, tenant_id) воспроизводит оба случая:
|
||||
// каждая пара образует независимую цепочку.
|
||||
'auth_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'actor_type',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'saas_admin_user_id',
|
||||
'email',
|
||||
'event',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'failure_reason',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
// global chain: auth_log пишется при ЛОГИНЕ под BYPASSRLS-роль
|
||||
// (tenant ещё не установлен — пользователь не аутентифицирован),
|
||||
// поэтому триггерный prev-SELECT видит ВСЕ строки → цепочка глобальная
|
||||
// внутри данной партиции (эмпирически подтверждено прод-smoke).
|
||||
'partition' => '',
|
||||
],
|
||||
|
||||
// activity_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'activity_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'deal_id',
|
||||
'event',
|
||||
'old_value',
|
||||
'new_value',
|
||||
'context',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// tenant_operations_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'tenant_operations_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'event',
|
||||
'payload_before',
|
||||
'payload_after',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// balance_transactions:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'balance_transactions' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'type',
|
||||
'amount_rub',
|
||||
'amount_leads',
|
||||
'balance_rub_after',
|
||||
'balance_leads_after',
|
||||
'description',
|
||||
'related_type',
|
||||
'related_id',
|
||||
'user_id',
|
||||
'admin_user_id',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// pd_processing_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'pd_processing_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'action',
|
||||
'purpose',
|
||||
'actor_tenant_user_id',
|
||||
'actor_admin_user_id',
|
||||
'ip_address',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// saas_admin_audit_log:
|
||||
// Нет RLS-политики для tenant-ролей (REVOKE ALL FROM crm_app_user).
|
||||
// Вставляет только crm_admin_user (BYPASSRLS) — триггер's SELECT
|
||||
// видит ВСЕ строки партиции → цепочка глобальная внутри партиции.
|
||||
// Partition: нет (пустая строка = ORDER BY id без PARTITION BY).
|
||||
'saas_admin_audit_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'admin_user_id',
|
||||
'action',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'target_tenant_id',
|
||||
'payload_before',
|
||||
'payload_after',
|
||||
'reason',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'requires_approval',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => '', // global chain within partition — inserting role is BYPASSRLS
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$anyBreach = false;
|
||||
$now = Carbon::now();
|
||||
|
||||
foreach (AuditChainConfig::TABLES as $table => $config) {
|
||||
foreach (self::TABLE_CONFIG as $table => $config) {
|
||||
// Get all partitions for this table via pg_inherits.
|
||||
$partitions = $this->listPartitions($table);
|
||||
|
||||
@@ -98,10 +251,8 @@ class VerifyAuditChains extends Command
|
||||
$partitions = [$table];
|
||||
}
|
||||
|
||||
$tableHadBreach = false;
|
||||
|
||||
foreach ($partitions as $partitionName) {
|
||||
$breaches = $this->checkPartition($partitionName, $table, $config['partition']);
|
||||
$breaches = $this->checkPartition($partitionName, $config['columns'], $config['partition']);
|
||||
|
||||
if (empty($breaches)) {
|
||||
$this->line(" ✓ {$partitionName}: chain intact");
|
||||
@@ -110,7 +261,6 @@ class VerifyAuditChains extends Command
|
||||
}
|
||||
|
||||
$anyBreach = true;
|
||||
$tableHadBreach = true;
|
||||
$firstId = $breaches[0]->id;
|
||||
$count = count($breaches);
|
||||
|
||||
@@ -125,18 +275,6 @@ class VerifyAuditChains extends Command
|
||||
|
||||
$this->sendAlert($table, $partitionName, $firstId, $count);
|
||||
}
|
||||
|
||||
// Auto-resolve: a table whose chain is intact across ALL partitions
|
||||
// closes any stale open chain incident left by a previous transient
|
||||
// breach (e.g. acceptance test-tenant rows since removed by teardown).
|
||||
// Best-effort: never let cleanup break the command or its exit code.
|
||||
if (! $tableHadBreach) {
|
||||
try {
|
||||
$this->resolveOpenIncidents($table, $now);
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" Incident auto-resolve failed for {$table}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit FAILURE on ANY breach regardless of incident-write success.
|
||||
@@ -183,11 +321,12 @@ class VerifyAuditChains extends Command
|
||||
* где ROW(...) имеет NULL::bytea на позиции log_hash.
|
||||
* 4. Возвращает строки, где stored IS DISTINCT FROM recomputed.
|
||||
*
|
||||
* @param list<string> $columns
|
||||
* @return list<object>
|
||||
*/
|
||||
private function checkPartition(string $partitionName, string $table, string $partition): array
|
||||
private function checkPartition(string $partitionName, array $columns, string $partition): array
|
||||
{
|
||||
$rowExpr = AuditChainConfig::rowExpression($table);
|
||||
$rowExpr = $this->buildRowExpression($columns);
|
||||
|
||||
// Build OVER clause: with or without PARTITION BY depending on table's RLS scope.
|
||||
$overClause = $partition !== ''
|
||||
@@ -227,6 +366,25 @@ class VerifyAuditChains extends Command
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит SQL-выражение ROW(col1, col2, ..., NULL::bytea, ..., coln)
|
||||
* с NULL::bytea на месте log_hash.
|
||||
*
|
||||
* Пример для auth_log:
|
||||
* ROW(t.id, t.actor_type, t.tenant_id, ..., NULL::bytea, t.created_at)
|
||||
*
|
||||
* @param list<string> $columns
|
||||
*/
|
||||
private function buildRowExpression(array $columns): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($columns as $col) {
|
||||
$parts[] = ($col === '__log_hash__') ? 'NULL::bytea' : "t.{$col}";
|
||||
}
|
||||
|
||||
return 'ROW('.implode(', ', $parts).')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставляет запись в incidents_log (через pgsql_supplier BYPASSRLS).
|
||||
* Дедупликация: не создаёт повторный инцидент для той же таблицы,
|
||||
@@ -296,38 +454,6 @@ class VerifyAuditChains extends Command
|
||||
$this->warn(" Incident recorded for {$partitionName} (first broken id={$firstBrokenId})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Авто-закрытие устаревших открытых инцидентов разрыва цепочки для таблицы,
|
||||
* чья цепочка снова целостна во всех партициях.
|
||||
*
|
||||
* Закрывает класс «вечно-открытых» high-инцидентов после транзиентного
|
||||
* разрыва (строки удалены/исправлены вне прогона — напр. строки тест-тенантов
|
||||
* приёмки, убранные teardown): без этого verify-chains накапливал бы открытые
|
||||
* инциденты и слал бы по ним алёрты после истечения дедупа.
|
||||
*
|
||||
* Матчинг summary — тот же per-table шаблон, что в recordIncident()
|
||||
* (дедупликация и закрытие симметричны). Вызывается только когда таблица
|
||||
* чиста во ВСЕХ партициях (guard $tableHadBreach в handle()).
|
||||
*/
|
||||
private function resolveOpenIncidents(string $table, Carbon $now): void
|
||||
{
|
||||
$resolved = DB::connection(self::DB_CONNECTION)
|
||||
->table('incidents_log')
|
||||
->where('type', 'other')
|
||||
->where('severity', 'high')
|
||||
->where('summary', 'like', '%chain%'.addcslashes($table, '%_\\').'%')
|
||||
->whereNull('resolved_at')
|
||||
->update([
|
||||
'resolved_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'root_cause' => "Автоматически закрыт: audit:verify-chains подтвердил целостность hash-chain таблицы {$table}.",
|
||||
]);
|
||||
|
||||
if ($resolved > 0) {
|
||||
$this->info(" ↻ {$table}: auto-resolved {$resolved} stale chain incident(s).");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет email-алёрт на monitoring email.
|
||||
*/
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Autopodbor;
|
||||
|
||||
class RunInFlightException extends \RuntimeException {}
|
||||
@@ -1,172 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Account\ChangePasswordRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\UserSessionTracker;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Аккаунт пользователя — вкладка «Безопасность» (UI-аудит 21.06.2026).
|
||||
*
|
||||
* Заменяет статичные mock-карточки (ChangePasswordCard/SessionsTable):
|
||||
* - POST /api/account/change-password — реальная смена пароля.
|
||||
* - GET /api/account/security — дата последней смены пароля + активные сессии.
|
||||
* - DELETE /api/account/sessions/{id} — отозвать сессию (UI-аудит 21.06.2026).
|
||||
*
|
||||
* Активные сессии берутся из user_sessions (запись при входе); отзыв реально
|
||||
* убивает сессию (удаление из Redis по session_id). Заменяет прежний mock.
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
use WritesAuthLog;
|
||||
|
||||
/**
|
||||
* POST /api/account/change-password — смена пароля авторизованным пользователем.
|
||||
*
|
||||
* Проверяет текущий пароль (Hash::check против password_hash), пишет новый хэш,
|
||||
* логирует password_changed в auth_log. На неверном текущем — 422 + лог
|
||||
* password_change_failed.
|
||||
*/
|
||||
public function changePassword(ChangePasswordRequest $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
if (! Hash::check($request->string('current_password')->toString(), (string) $user->password_hash)) {
|
||||
$this->logAuthEvent(
|
||||
'password_change_failed',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
'wrong_current_password',
|
||||
);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'current_password' => ['Неверный текущий пароль.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'password_hash' => Hash::make($request->string('password')->toString()),
|
||||
])->save();
|
||||
|
||||
$this->logAuthEvent(
|
||||
'password_changed',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Пароль изменён.',
|
||||
'last_password_change_at' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/security — данные вкладки «Безопасность».
|
||||
*
|
||||
* last_password_change_at — max(created_at) по password-событиям в auth_log
|
||||
* (null, если пароль ни разу не менялся через портал).
|
||||
* recent_logins — последние входы текущего пользователя (устройство/IP/время).
|
||||
*/
|
||||
public function security(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
$lastChange = DB::table('auth_log')
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('event', ['password_changed', 'password_reset_completed'])
|
||||
->max('created_at');
|
||||
|
||||
$currentSid = $request->session()->getId();
|
||||
$rows = DB::table('user_sessions')
|
||||
->where('user_id', $user->id)
|
||||
->where('expires_at', '>', now())
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get(['id', 'token_hash', 'ip_address', 'user_agent', 'last_active_at', 'created_at']);
|
||||
|
||||
$sessions = $rows->map(fn ($row): array => [
|
||||
'id' => $row->id,
|
||||
'device' => $this->deviceLabel($row->user_agent),
|
||||
'ip' => $row->ip_address,
|
||||
'at' => Carbon::parse($row->last_active_at ?? $row->created_at)->toIso8601String(),
|
||||
'current' => $row->token_hash === $currentSid,
|
||||
])->all();
|
||||
|
||||
return response()->json([
|
||||
'last_password_change_at' => $lastChange ? Carbon::parse($lastChange)->toIso8601String() : null,
|
||||
'sessions' => $sessions,
|
||||
]);
|
||||
}
|
||||
|
||||
/** DELETE /api/account/sessions/{id} — отозвать конкретную сессию пользователя. */
|
||||
public function revokeSession(Request $request, int $id): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$ok = app(UserSessionTracker::class)->revoke($user->id, $id);
|
||||
|
||||
if (! $ok) {
|
||||
return response()->json(['message' => 'Сессия не найдена.'], 404);
|
||||
}
|
||||
|
||||
$this->logAuthEvent(
|
||||
'session_revoked',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
null,
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Сессия завершена.']);
|
||||
}
|
||||
|
||||
/** Грубый человекочитаемый ярлык устройства из User-Agent (браузер + ОС). */
|
||||
private function deviceLabel(?string $ua): string
|
||||
{
|
||||
if ($ua === null || $ua === '') {
|
||||
return 'Неизвестное устройство';
|
||||
}
|
||||
|
||||
$browser = match (true) {
|
||||
str_contains($ua, 'Firefox/') => 'Firefox',
|
||||
str_contains($ua, 'Edg/') => 'Edge',
|
||||
str_contains($ua, 'OPR/') || str_contains($ua, 'Opera') => 'Opera',
|
||||
str_contains($ua, 'Chrome/') => 'Chrome',
|
||||
str_contains($ua, 'Safari/') => 'Safari',
|
||||
default => 'Браузер',
|
||||
};
|
||||
|
||||
$os = match (true) {
|
||||
str_contains($ua, 'Windows') => 'Windows',
|
||||
str_contains($ua, 'Android') => 'Android',
|
||||
str_contains($ua, 'iPhone') || str_contains($ua, 'iPad') => 'iOS',
|
||||
str_contains($ua, 'Mac OS') || str_contains($ua, 'Macintosh') => 'macOS',
|
||||
str_contains($ua, 'Linux') => 'Linux',
|
||||
default => '',
|
||||
};
|
||||
|
||||
return $os !== '' ? "{$browser}, {$os}" : $browser;
|
||||
}
|
||||
}
|
||||
@@ -1,580 +0,0 @@
|
||||
<?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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
<?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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LegalEntity;
|
||||
use App\Models\PaymentGateway;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/**
|
||||
* SaaS-admin: ввод секретных ключей платёжного шлюза.
|
||||
*
|
||||
* config хранится Crypt::encrypt — ключи не попадают в БД в открытом виде и
|
||||
* не возвращаются обратно клиенту. На MVP без auth-middleware (как остальные
|
||||
* /api/admin/* эндпоинты); production — middleware('auth:saas-admin').
|
||||
*/
|
||||
class AdminPaymentGatewayController extends Controller
|
||||
{
|
||||
public function update(Request $request, string $code): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'shop_id' => ['required', 'string', 'max:255'],
|
||||
'secret_key' => ['required', 'string', 'max:255'],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
'legal_entity_id' => ['nullable', 'integer', 'exists:legal_entities,id'],
|
||||
]);
|
||||
|
||||
$gw = PaymentGateway::firstOrNew(['code' => $code]);
|
||||
|
||||
// legal_entity_id обязателен (NOT NULL FK). Берём из запроса, иначе первое юрлицо.
|
||||
if ($gw->legal_entity_id === null) {
|
||||
$legalEntityId = $validated['legal_entity_id'] ?? LegalEntity::query()->min('id');
|
||||
if ($legalEntityId === null) {
|
||||
return response()->json([
|
||||
'message' => 'Сначала заведите юридическое лицо (реквизиты получателя платежей).',
|
||||
], 422);
|
||||
}
|
||||
$gw->legal_entity_id = (int) $legalEntityId;
|
||||
}
|
||||
|
||||
$gw->name ??= 'ЮKassa';
|
||||
$gw->driver ??= $code;
|
||||
$gw->config = Crypt::encrypt([
|
||||
'shop_id' => $validated['shop_id'],
|
||||
'secret_key' => $validated['secret_key'],
|
||||
]);
|
||||
$gw->is_active = $validated['is_active'];
|
||||
$gw->min_amount_rub ??= '100.00';
|
||||
$gw->save();
|
||||
|
||||
return response()->json(['status' => 'ok', 'code' => $gw->code, 'is_active' => $gw->is_active]);
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ final class AdminPricingTiersController extends Controller
|
||||
'tiers.*.tier_no' => ['required', 'integer', 'between:1,7'],
|
||||
'tiers.*.leads_in_tier' => ['nullable', 'integer', 'min:1'],
|
||||
'tiers.*.price_rub' => ['required', 'string', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after_or_equal:'.$todayMsk],
|
||||
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],
|
||||
]);
|
||||
|
||||
/** @var array<int, array{tier_no:int, leads_in_tier:?int, price_rub:string|float}> $tiers */
|
||||
@@ -163,13 +163,6 @@ final class AdminPricingTiersController extends Controller
|
||||
*/
|
||||
private function resolveAdminUserId(Request $request): int
|
||||
{
|
||||
// Прод: crm_app_user не имеет прав на saas_admin_users → берём системный
|
||||
// admin-id из конфига, не обращаясь к таблице. null (dev/test) → fallback ниже.
|
||||
$configured = config('admin.audit_system_user_id');
|
||||
if ($configured !== null) {
|
||||
return (int) $configured;
|
||||
}
|
||||
|
||||
$requested = $request->input('admin_user_id');
|
||||
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
|
||||
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
|
||||
|
||||
@@ -15,7 +15,6 @@ 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;
|
||||
@@ -71,33 +70,6 @@ final class AdminSupplierIntegrationController extends Controller
|
||||
return response()->json(['dispatched' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Эпик 5: история вечерних заливок проектов поставщику (supplier_sync_runs).
|
||||
* SaaS-admin сверяет глазами, что заливка прошла ровно — от этого зависят
|
||||
* заказанные у поставщика лиды.
|
||||
*/
|
||||
public function syncRuns(): JsonResponse
|
||||
{
|
||||
$rows = DB::connection('pgsql_supplier')
|
||||
->table('supplier_sync_runs')
|
||||
->orderByDesc('id')
|
||||
->limit(self::HISTORY_LIMIT)
|
||||
->get();
|
||||
|
||||
return response()->json([
|
||||
'runs' => $rows->map(fn ($r): array => [
|
||||
'started_at' => $r->started_at,
|
||||
'finished_at' => $r->finished_at,
|
||||
'groups_total' => (int) $r->groups_total,
|
||||
'synced_ok' => (int) $r->synced_ok,
|
||||
'manual_queued' => (int) $r->manual_queued,
|
||||
'deferred' => (int) $r->deferred,
|
||||
'failed' => (int) $r->failed,
|
||||
'status' => $r->status,
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Очередь яруса 3 резерва канала миграции проектов — pending-список для
|
||||
* оператора админ-экрана. Spec §4.6.
|
||||
@@ -226,49 +198,6 @@ 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,14 +30,10 @@ class AdminTenantsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
/** GET /api/admin/tenants?status=&statuses=&tariffs=&search=&limit=&offset= */
|
||||
/** GET /api/admin/tenants?status=&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'));
|
||||
@@ -63,22 +59,8 @@ class AdminTenantsController extends Controller
|
||||
])
|
||||
->whereNull('tenants.deleted_at');
|
||||
|
||||
// Производный статус — зеркалит 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 ($status !== '') {
|
||||
$query->where('tenants.status', $status);
|
||||
}
|
||||
if ($search !== '') {
|
||||
$like = '%'.$search.'%';
|
||||
@@ -469,19 +451,6 @@ 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.
|
||||
* Считается отдельным запросом без фильтров (показывает глобальную картину
|
||||
|
||||
@@ -7,12 +7,11 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Mail\SuspiciousLoginNotification;
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\UserSessionTracker;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -94,23 +93,6 @@ class AuthController extends Controller
|
||||
|
||||
if (! $user->is_active) {
|
||||
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
|
||||
|
||||
// Косяк 05: неподтверждённая почта — это НЕ блокировка. Новый клиент
|
||||
// создаётся is_active=false до ввода кода из письма. Не пугаем
|
||||
// «Аккаунт заблокирован», а зовём подтвердить почту.
|
||||
if ($user->email_verified_at === null) {
|
||||
$this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(),
|
||||
'email_not_confirmed');
|
||||
|
||||
$msg = 'Подтвердите почту — мы отправили код на '.$user->email.'.';
|
||||
|
||||
return response()->json([
|
||||
'message' => $msg,
|
||||
'errors' => ['email' => [$msg]],
|
||||
'email_not_confirmed' => true,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(),
|
||||
'account_locked');
|
||||
|
||||
@@ -142,7 +124,6 @@ class AuthController extends Controller
|
||||
$user->update(['last_login_at' => now()]);
|
||||
|
||||
$this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null);
|
||||
app(UserSessionTracker::class)->record($request, $user->id);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
@@ -150,25 +131,46 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(RegisterRequest $request): JsonResponse
|
||||
{
|
||||
// На MVP — attach нового user'а к первому tenant'у (для UI-разводки).
|
||||
// Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли.
|
||||
$tenant = Tenant::first();
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
'message' => 'Tenants не настроены. Обратитесь к администратору.',
|
||||
], 503);
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => $request->string('email')->toString(),
|
||||
'password_hash' => Hash::make($request->string('password')->toString()),
|
||||
'first_name' => 'Новый',
|
||||
'last_name' => 'Пользователь',
|
||||
'is_active' => true,
|
||||
'totp_enabled' => false,
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
'requires_2fa' => false,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$resource = $this->userResource($user);
|
||||
|
||||
$marker = $request->hasSession() ? $request->session()->get('impersonation') : null;
|
||||
if ($marker !== null) {
|
||||
$token = ImpersonationToken::on('pgsql_supplier')->find($marker['token_id']);
|
||||
$tenant = $token?->tenant;
|
||||
$resource['impersonation'] = [
|
||||
'active' => true,
|
||||
'tenant_name' => $tenant?->organization_name,
|
||||
'started_at' => $marker['started_at'] ?? null,
|
||||
'expires_at' => $token?->sessionExpiresAt()?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json(['user' => $resource]);
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
@@ -177,9 +179,6 @@ class AuthController extends Controller
|
||||
$tenantId = $request->user()?->tenant_id;
|
||||
$email = $request->user()?->email;
|
||||
|
||||
// Снять текущую сессию из списка «Активные» до инвалидации (id ещё прежний).
|
||||
app(UserSessionTracker::class)->revokeCurrent($request);
|
||||
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
@@ -1,581 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,11 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalanceToLeadsConverter;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use App\Services\Billing\Gateway\PaymentGatewayManager;
|
||||
use App\Services\Billing\OnlineTopupService;
|
||||
use App\Services\Billing\RunwayCalculator;
|
||||
use App\Support\SystemSettings;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -42,9 +35,8 @@ class BillingController extends Controller
|
||||
/**
|
||||
* POST /api/billing/topup — пополнить рублёвый баланс.
|
||||
*
|
||||
* Развилка: если флаг billing_yookassa_enabled ВКЛ — создаём платёж через
|
||||
* шлюз и возвращаем confirmation_url (баланс не меняется до webhook).
|
||||
* Если ВЫКЛ — MVP-stub мгновенного зачисления (текущее прод-поведение до Б-1).
|
||||
* MVP-stub: кредитует баланс немедленно (без ЮKassa — реальная оплата
|
||||
* post-Б-1). Записывает append-only строку balance_transactions(topup).
|
||||
*/
|
||||
public function topup(Request $request): JsonResponse
|
||||
{
|
||||
@@ -54,25 +46,10 @@ class BillingController extends Controller
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
// Нормализуем в DECIMAL-строку scale 2 для bcmath (НЕ float).
|
||||
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
|
||||
|
||||
// Развилка: реальный шлюз (флаг ВКЛ) ИЛИ мгновенная заглушка (флаг ВЫКЛ).
|
||||
if (SystemSettings::bool('billing_yookassa_enabled')) {
|
||||
$manager = app(PaymentGatewayManager::class);
|
||||
$gateway = $manager->activeGateway();
|
||||
if ($gateway === null) {
|
||||
return response()->json(['message' => 'Платёжный шлюз не настроен.'], 503);
|
||||
}
|
||||
|
||||
$returnUrl = rtrim((string) config('app.url'), '/').'/billing?topup=return';
|
||||
$result = app(OnlineTopupService::class)->start(
|
||||
(int) $user->tenant_id, $amountRub, $gateway, $returnUrl, (int) $user->id
|
||||
);
|
||||
|
||||
return response()->json(['confirmation_url' => $result->confirmationUrl], 201);
|
||||
}
|
||||
|
||||
// Заглушка (текущее прод-поведение до Б-1): мгновенное зачисление.
|
||||
$tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id);
|
||||
|
||||
return response()->json([
|
||||
@@ -134,101 +111,6 @@ class BillingController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/billing/balance-status — лёгкий статус баланса для UI префлайта
|
||||
* (Billing v2 Spec C §3.6). Питает глобальный баннер заморозки
|
||||
* (BalanceFrozenBanner: frozen_by_balance_at + дефицит) и индикатор ёмкости
|
||||
* (BalanceCapacityIndicator: balance / capacity / required). Грузится в
|
||||
* AppLayout на всех страницах, поэтому без tiers_preview и истории.
|
||||
*/
|
||||
public function balanceStatus(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::query()->findOrFail((int) $user->tenant_id);
|
||||
|
||||
$activeTiers = app(PricingTierRepository::class)->activeAt(Carbon::now('Europe/Moscow'));
|
||||
$deliveredInMonth = (int) ($tenant->delivered_in_month ?? 0);
|
||||
|
||||
$capacityLeads = (int) app(BalanceToLeadsConverter::class)->convert(
|
||||
(string) $tenant->balance_rub,
|
||||
$deliveredInMonth,
|
||||
$activeTiers,
|
||||
)['leads'];
|
||||
|
||||
// Требуемые лиды/день — сумма лимитов активных не-заблокированных проектов
|
||||
// (та же выборка, что в ProjectController preflight).
|
||||
$requiredLeads = (int) Project::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
|
||||
$deficitLeads = max(0, $requiredLeads - $capacityLeads);
|
||||
$deficitRub = '0.00';
|
||||
if ($deficitLeads > 0) {
|
||||
$needed = $this->minBalanceForLeads($requiredLeads, $deliveredInMonth, $activeTiers);
|
||||
$deficitRub = bcsub($needed, (string) $tenant->balance_rub, 2);
|
||||
if (bccomp($deficitRub, '0', 2) < 0) {
|
||||
$deficitRub = '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'frozen_by_balance_at' => $tenant->frozen_by_balance_at?->toISOString(),
|
||||
'balance_rub' => (string) $tenant->balance_rub,
|
||||
'capacity_leads' => $capacityLeads,
|
||||
'required_leads_per_day' => $requiredLeads,
|
||||
'deficit_leads' => $deficitLeads,
|
||||
'deficit_rub' => $deficitRub,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальный баланс (₽, scale 2), чтобы позволить себе $leads лидов при уже
|
||||
* доставленных $deliveredInMonth в этом месяце — сумма цен ступеней по позициям
|
||||
* [delivered .. delivered+leads-1]. Зеркалит логику BalanceToLeadsConverter.
|
||||
*
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function minBalanceForLeads(int $leads, int $deliveredInMonth, $tiers): string
|
||||
{
|
||||
if ($leads <= 0) {
|
||||
return '0.00';
|
||||
}
|
||||
|
||||
$sorted = $tiers
|
||||
->filter(fn ($t) => (bool) $t->is_active)
|
||||
->sortBy('tier_no')
|
||||
->values();
|
||||
|
||||
$kopecks = '0';
|
||||
$remaining = $leads;
|
||||
$cumulative = 0; // позиции [0..cumulative) пройдены предыдущими ступенями
|
||||
$position = $deliveredInMonth;
|
||||
|
||||
foreach ($sorted as $tier) {
|
||||
$unlimited = $tier->leads_in_tier === null;
|
||||
$tierEnd = $unlimited ? PHP_INT_MAX : $cumulative + (int) $tier->leads_in_tier;
|
||||
|
||||
$slotsInTier = max(0, $tierEnd - max($cumulative, $position));
|
||||
if ($slotsInTier > 0) {
|
||||
$take = min($remaining, $slotsInTier);
|
||||
$kopecks = bcadd($kopecks, bcmul((string) (int) $tier->price_per_lead_kopecks, (string) $take, 0), 0);
|
||||
$remaining -= $take;
|
||||
$position += $take;
|
||||
}
|
||||
|
||||
if ($remaining <= 0 || $unlimited) {
|
||||
break;
|
||||
}
|
||||
$cumulative = $tierEnd;
|
||||
}
|
||||
|
||||
return bcdiv($kopecks, '100', 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/billing/transactions?type=topup|lead_charge|migration&page=N
|
||||
* — пагинированная история balance_transactions тенанта (20/страница).
|
||||
@@ -336,8 +218,21 @@ class BillingController extends Controller
|
||||
*/
|
||||
private function runwayDays(Tenant $tenant, int $affordableLeads): ?int
|
||||
{
|
||||
// F3 (17.06.2026): единый источник расчёта — RunwayCalculator (общий с дашбордом),
|
||||
// чтобы прогноз «хватит на дни» не расходился между биллингом и дашбордом.
|
||||
return app(RunwayCalculator::class)->daysLeft((int) $tenant->id, $affordableLeads);
|
||||
if ($affordableLeads <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$leadsLast30Days = (int) DB::table('lead_charges')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('charged_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
if ($leadsLast30Days <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$avgPerDay = $leadsLast30Days / 30.0;
|
||||
|
||||
return max(0, (int) floor($affordableLeads / $avgPerDay));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalanceToLeadsConverter;
|
||||
use App\Services\Billing\RunwayCalculator;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -79,6 +75,7 @@ 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();
|
||||
@@ -106,49 +103,26 @@ class DashboardController extends Controller
|
||||
->map(fn ($c) => (int) $c)
|
||||
->toArray();
|
||||
|
||||
// --- runway (F3, 17.06.2026: единый источник с биллингом) ---
|
||||
// Раньше дашборд считал от legacy `balance_leads` (после Billing v2 ≈0
|
||||
// для рублёвых тенантов) → расходился с биллингом «0 дней ↔ N дней».
|
||||
// Теперь — affordable leads от рублёвого баланса по тарифу
|
||||
// (BalanceToLeadsConverter) + общий RunwayCalculator.
|
||||
$activeTiers = app(PricingTierRepository::class)
|
||||
->activeAt(Carbon::now('Europe/Moscow'));
|
||||
$conversion = app(BalanceToLeadsConverter::class)->convert(
|
||||
(string) $tenant->balance_rub,
|
||||
(int) ($tenant->delivered_in_month ?? 0),
|
||||
$activeTiers,
|
||||
);
|
||||
$affordableLeads = (int) $conversion['leads'];
|
||||
// B1-2 (UX-аудит 25.06): null (нет активных проектов) НЕ приводим к 0 —
|
||||
// иначе дашборд врал «хватит на 0 дней» при полном балансе, расходясь с
|
||||
// биллингом «∞». null → фронт показывает «нет активных проектов».
|
||||
$runwayDays = app(RunwayCalculator::class)
|
||||
->daysLeft($tenantId, $affordableLeads);
|
||||
|
||||
// --- средняя стоимость лида (F5): среднее фактически списанных rub-сумм
|
||||
// за окно периода. Только charge_source='rub' (у prepaid цена 0 по CHECK —
|
||||
// иначе среднее занижается); источник тот же, что у карточки сделки (F2).
|
||||
// null, если в окне нет rub-списаний (ничего ещё не списано).
|
||||
$avgKopecks = DB::table('lead_charges')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('charge_source', 'rub')
|
||||
->whereBetween('charged_at', [$windowStart, $now])
|
||||
->avg('price_per_lead_kopecks');
|
||||
$avgLeadCostRub = $avgKopecks !== null ? round((float) $avgKopecks / 100, 2) : null;
|
||||
// --- runway ---
|
||||
// runway опирается на приток за фиксированное 7-дневное окно,
|
||||
// независимо от выбранного range (для today/30d $curLeads — не 7-дневный).
|
||||
$leads7d = (clone $base())->whereBetween('received_at', [$now->subDays(7), $now])->count();
|
||||
$avgDaily = $leads7d / 7.0;
|
||||
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
|
||||
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
|
||||
|
||||
return [
|
||||
'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],
|
||||
'active_projects' => ['active' => $activeProjects, 'limit' => $maxProjects],
|
||||
'balance' => [
|
||||
'amount_rub' => (string) $tenant->balance_rub,
|
||||
'runway_days' => $runwayDays,
|
||||
'runway_leads' => $affordableLeads,
|
||||
'runway_leads' => $balanceLeads,
|
||||
],
|
||||
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
|
||||
'funnel' => (object) $funnel,
|
||||
'avg_lead_cost_rub' => $avgLeadCostRub,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\User;
|
||||
@@ -103,6 +102,13 @@ class DealController extends Controller
|
||||
// whereNotNull('deleted_at') фильтрует только удалённые.
|
||||
$query = Deal::query()
|
||||
->select('deals.*')
|
||||
->addSelect(['next_reminder_at' => DB::table('reminders')
|
||||
->select('remind_at')
|
||||
->whereColumn('reminders.deal_id', 'deals.id')
|
||||
->whereNull('reminders.completed_at')
|
||||
->orderBy('remind_at')
|
||||
->limit(1),
|
||||
])
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
|
||||
|
||||
@@ -188,24 +194,6 @@ class DealController extends Controller
|
||||
return response()->json(['total' => $total]);
|
||||
}
|
||||
|
||||
// U4 (UX-аудит 25.06): стоимость лида должна быть видна и в листинге
|
||||
// (панель деталей и канбан рисуют из строки листинга, show отдельно не
|
||||
// дозапрашивают). Запрос — в своей транзакции с app.current_tenant_id,
|
||||
// иначе RLS на lead_charges вернёт 0 строк (как в show). rub-провенанс.
|
||||
$costByDeal = collect();
|
||||
$dealIds = $deals->pluck('id')->all();
|
||||
if ($dealIds !== []) {
|
||||
$costByDeal = DB::transaction(function () use ($tenantId, $dealIds) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
return LeadCharge::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('deal_id', $dealIds)
|
||||
->where('charge_source', 'rub')
|
||||
->pluck('price_per_lead_kopecks', 'deal_id');
|
||||
});
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'deals' => $deals->map(fn (Deal $d) => [
|
||||
'id' => $d->id,
|
||||
@@ -229,7 +217,9 @@ class DealController extends Controller
|
||||
'project_signal_identifier' => $d->project?->signal_identifier,
|
||||
'project_sms_keyword' => $d->project?->sms_keyword,
|
||||
'project_sms_senders' => $d->project?->sms_senders,
|
||||
'cost_kopecks' => $costByDeal[$d->id] ?? null,
|
||||
'next_reminder_at' => $d->next_reminder_at
|
||||
? Carbon::parse($d->next_reminder_at)->toIso8601String()
|
||||
: null,
|
||||
]),
|
||||
'limit' => $limit,
|
||||
'next_cursor' => $nextCursor,
|
||||
@@ -256,7 +246,7 @@ class DealController extends Controller
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
[$deal, $events, $charge] = DB::transaction(function () use ($tenantId, $id) {
|
||||
[$deal, $events] = DB::transaction(function () use ($tenantId, $id) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$deal = Deal::query()
|
||||
@@ -266,7 +256,7 @@ class DealController extends Controller
|
||||
->first();
|
||||
|
||||
if ($deal === null) {
|
||||
return [null, [], null];
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$events = ActivityLog::query()
|
||||
@@ -278,14 +268,7 @@ class DealController extends Controller
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
// F2: реальная стоимость лида — снимок списания из lead_charges
|
||||
// (rub-провенанс). Запрос в транзакции, где выставлен app.current_tenant_id.
|
||||
$charge = LeadCharge::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('deal_id', $id)
|
||||
->first();
|
||||
|
||||
return [$deal, $events, $charge];
|
||||
return [$deal, $events];
|
||||
});
|
||||
|
||||
if ($deal === null) {
|
||||
@@ -326,8 +309,6 @@ class DealController extends Controller
|
||||
'project_signal_identifier' => $deal->project?->signal_identifier,
|
||||
'project_sms_keyword' => $deal->project?->sms_keyword,
|
||||
'project_sms_senders' => $deal->project?->sms_senders,
|
||||
// F2: стоимость лида = снимок rub-списания (копейки) или null (prepaid/не списано).
|
||||
'cost_kopecks' => ($charge && $charge->charge_source === 'rub') ? $charge->price_per_lead_kopecks : null,
|
||||
],
|
||||
'events' => $events->map(fn (ActivityLog $e) => [
|
||||
'id' => $e->id,
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Support\CsvFormulaGuard;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -123,15 +122,12 @@ class DealExportController extends Controller
|
||||
$signal = $deal->project?->signal_type;
|
||||
$source = trim(($deal->project?->name ?? '—').' · '
|
||||
.(self::SIGNAL_LABELS[$signal] ?? '—'));
|
||||
// F-CSV: свободный текст (телефон/источник/город/статус/
|
||||
// комментарий) экранируем от formula-инъекции. Дата —
|
||||
// системная, не экранируется.
|
||||
$writer->addRow(Row::fromValues([
|
||||
CsvFormulaGuard::neutralize((string) $deal->phone),
|
||||
CsvFormulaGuard::neutralize($source),
|
||||
CsvFormulaGuard::neutralize((string) ($deal->city ?? '')),
|
||||
CsvFormulaGuard::neutralize((string) ($statusNames[$deal->status] ?? $deal->status)),
|
||||
CsvFormulaGuard::neutralize((string) ($deal->comment ?? '')),
|
||||
(string) $deal->phone,
|
||||
$source,
|
||||
(string) ($deal->city ?? ''),
|
||||
(string) ($statusNames[$deal->status] ?? $deal->status),
|
||||
(string) ($deal->comment ?? ''),
|
||||
$deal->received_at?->toDateTimeString() ?? '',
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -5,19 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\ImpersonationCodeMail;
|
||||
use App\Mail\ImpersonationEndedMail;
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Pd\ImpersonationAuditService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* SaaS-admin impersonation flow (ТЗ §22.7 / Ю-1).
|
||||
@@ -46,8 +39,6 @@ class ImpersonationController extends Controller
|
||||
|
||||
private const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
private const SESSION_TTL_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* SaaS-admin — кросс-тенантная зона: запросы к impersonation_tokens / tenants
|
||||
* идут через BYPASSRLS-подключение pgsql_supplier (роль crm_supplier_worker).
|
||||
@@ -143,12 +134,7 @@ class ImpersonationController extends Controller
|
||||
|
||||
$audit->recordInit($token, adminId: $requestedBy, ip: $request->ip());
|
||||
|
||||
try {
|
||||
Mail::to((string) $tenant->contact_email)
|
||||
->queue(new ImpersonationCodeMail($plainCode, (string) $tenant->contact_email));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('impersonation init: не удалось поставить письмо с кодом: '.$e->getMessage());
|
||||
}
|
||||
// TODO: отправить email на $tenant->contact_email с $plainCode.
|
||||
$payload = [
|
||||
'token_id' => $token->id,
|
||||
'expires_at' => $token->expires_at->toIso8601String(),
|
||||
@@ -204,33 +190,10 @@ class ImpersonationController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Success: целевой пользователь тенанта = самый ранний активный.
|
||||
$targetUser = User::on(self::DB_CONNECTION)
|
||||
->where('tenant_id', $token->tenant_id)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if ($targetUser === null) {
|
||||
return response()->json(['message' => 'У тенанта нет активного пользователя для входа.'], 422);
|
||||
}
|
||||
|
||||
// Машинный ключ для ИИ: lpimp_<id>_<secret>. Храним только хеш секрета.
|
||||
$secret = Str::random(48);
|
||||
$machineToken = 'lpimp_'.$token->id.'_'.$secret;
|
||||
|
||||
// Success: mark used. Создание saas_admin_session с
|
||||
// impersonating_token_id — отдельный коммит после saas-admin auth.
|
||||
$token->update([
|
||||
'used_at' => now(),
|
||||
'session_token_hash' => Hash::make($secret),
|
||||
]);
|
||||
|
||||
// Путь человека: логиним браузер целевым пользователем + маркер impersonation в сессию.
|
||||
Auth::login($targetUser);
|
||||
$request->session()->put('impersonation', [
|
||||
'token_id' => $token->id,
|
||||
'tenant_id' => $token->tenant_id,
|
||||
'target_user_id' => $targetUser->id,
|
||||
'started_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
$audit->recordVerify($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
@@ -239,8 +202,6 @@ class ImpersonationController extends Controller
|
||||
'token_id' => $token->id,
|
||||
'tenant_id' => $token->tenant_id,
|
||||
'used_at' => $token->used_at->toIso8601String(),
|
||||
'expires_at' => $token->sessionExpiresAt(self::SESSION_TTL_MINUTES)->toIso8601String(),
|
||||
'machine_token' => $machineToken,
|
||||
'message' => 'Impersonation начат. Сессия активна 1 час.',
|
||||
]);
|
||||
}
|
||||
@@ -271,12 +232,7 @@ class ImpersonationController extends Controller
|
||||
|
||||
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
|
||||
try {
|
||||
Mail::to((string) $token->sent_to_email)
|
||||
->queue(new ImpersonationEndedMail((string) $token->sent_to_email));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('impersonation end mail: '.$e->getMessage());
|
||||
}
|
||||
// TODO: уведомление клиенту по email о завершении (как и в init flow).
|
||||
|
||||
return response()->json([
|
||||
'token_id' => $token->id,
|
||||
@@ -284,35 +240,4 @@ class ImpersonationController extends Controller
|
||||
'message' => 'Impersonation завершён.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/impersonation/leave — завершить свою impersonation-сессию из кабинета.
|
||||
*
|
||||
* Маркер `impersonation` из сессии НЕ удаляется здесь намеренно:
|
||||
* ImpersonationContext (global web middleware) на следующем запросе
|
||||
* обнаружит isSessionActive()=false и вернёт 401 явно, не доходя до auth:sanctum.
|
||||
* Это обеспечивает корректный 401 как в реальном браузере, так и в тест-среде
|
||||
* (где Auth::guard('web')->logout() может не повлиять на кэш sanctum-guard).
|
||||
*/
|
||||
public function leave(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
{
|
||||
$marker = $request->session()->get('impersonation');
|
||||
if ($marker === null) {
|
||||
return response()->json(['message' => 'Сессия impersonation не активна.'], 422);
|
||||
}
|
||||
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($marker['token_id']);
|
||||
if ($token !== null && $token->session_ended_at === null) {
|
||||
$token->update(['session_ended_at' => now()]);
|
||||
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
try {
|
||||
Mail::to((string) $token->sent_to_email)
|
||||
->queue(new ImpersonationEndedMail((string) $token->sent_to_email));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('impersonation leave mail: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Вы вышли из режима поддержки.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PaymentGateway;
|
||||
use App\Models\SaasTransaction;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use App\Services\Billing\Gateway\PaymentGatewayDriver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
|
||||
/**
|
||||
* Приём webhook от платёжного шлюза (ЮKassa). Публичный роут (без auth/tenant),
|
||||
* URL под маской api/webhook/* → CSRF-exempt (bootstrap/app.php).
|
||||
*
|
||||
* Подлинность: НЕ доверяем телу webhook — по object.id делаем server-to-server
|
||||
* сверку через драйвер (GET /payments/{id}) и верим статусу из ответа шлюза.
|
||||
*
|
||||
* RLS: webhook вне tenant-сессии. Поиск платежа cross-tenant — через
|
||||
* BYPASSRLS-соединение pgsql_supplier (как джобы, Plan 3/4). Зачисление —
|
||||
* под app.current_tenant_id (SET LOCAL внутри транзакции, как BalancePreflightSweepJob).
|
||||
*
|
||||
* Идемпотентность: атомарный claim pending→success (UPDATE ... WHERE status='pending').
|
||||
* Повторный webhook → claimed=0 → no-op, 200 OK без двойного зачисления.
|
||||
*
|
||||
* Зачисление денег делегируется BillingTopupService (lockForUpdate + append-only ledger).
|
||||
*/
|
||||
class PaymentWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaymentGatewayDriver $driver,
|
||||
private readonly BillingTopupService $topupService,
|
||||
) {}
|
||||
|
||||
public function receive(Request $request): JsonResponse
|
||||
{
|
||||
// Defense-in-depth: IP-allowlist ЮKassa. Fail-open при пустом списке —
|
||||
// не ломаем легитимный поток; на проде заполнить YOOKASSA_WEBHOOK_IPS
|
||||
// опубликованными ЮKassa подсетями, чтобы аноним не дёргал endpoint.
|
||||
$allowlist = array_values(array_filter((array) config('services.yookassa.webhook_ip_allowlist', [])));
|
||||
if ($allowlist !== [] && ! IpUtils::checkIp((string) $request->ip(), $allowlist)) {
|
||||
return response()->json(['status' => 'ignored'], 200);
|
||||
}
|
||||
|
||||
$paymentId = (string) $request->input('object.id', '');
|
||||
if ($paymentId === '') {
|
||||
return response()->json(['status' => 'ignored'], 200);
|
||||
}
|
||||
|
||||
// Cross-tenant поиск платежа под BYPASSRLS-ролью (tenant ещё неизвестен).
|
||||
$tx = DB::connection('pgsql_supplier')->table('saas_transactions')
|
||||
->where('gateway_payment_id', $paymentId)
|
||||
->first();
|
||||
if ($tx === null) {
|
||||
return response()->json(['status' => 'unknown'], 200);
|
||||
}
|
||||
|
||||
$gateway = $this->gatewayFor($tx);
|
||||
|
||||
// Server-to-server сверка — источник правды о статусе.
|
||||
$verify = $this->driver->verifyPayment($gateway, $paymentId);
|
||||
if (! $verify->isSucceeded()) {
|
||||
return response()->json(['status' => 'not_paid'], 200);
|
||||
}
|
||||
|
||||
// Confused-deputy: сверенный платёж должен быть РОВНО тем, что в webhook.
|
||||
if ($verify->gatewayPaymentId !== $paymentId) {
|
||||
return response()->json(['status' => 'ignored'], 200);
|
||||
}
|
||||
|
||||
// Защита от чужой валюты с тем же числом — зачисляем только рубли.
|
||||
if ($verify->currency !== 'RUB') {
|
||||
return response()->json(['status' => 'currency_mismatch'], 200);
|
||||
}
|
||||
|
||||
// Защита: оплаченная сумма должна совпасть с запрошенной (scale 2).
|
||||
if (bccomp((string) $verify->amountRub, (string) $tx->amount_rub, 2) !== 0) {
|
||||
return response()->json(['status' => 'amount_mismatch'], 200);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tx, $verify) {
|
||||
// RLS-контекст для этой транзакции (PgBouncer-safe SET LOCAL).
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tx->tenant_id);
|
||||
|
||||
// Атомарно занимаем pending→success; 0 строк = уже зачислено (дубль/гонка).
|
||||
$claimed = SaasTransaction::where('id', $tx->id)
|
||||
->where('status', SaasTransaction::STATUS_PENDING)
|
||||
->update(['status' => SaasTransaction::STATUS_SUCCESS, 'completed_at' => now()]);
|
||||
|
||||
if ($claimed === 0) {
|
||||
return; // идемпотентный no-op
|
||||
}
|
||||
|
||||
$balanceTx = $this->topupService->topup(
|
||||
(int) $tx->tenant_id, (string) $tx->amount_rub, null
|
||||
);
|
||||
|
||||
SaasTransaction::where('id', $tx->id)->update([
|
||||
'balance_rub_after' => $balanceTx->balance_rub_after,
|
||||
'payment_method' => $verify->paymentMethod,
|
||||
'balance_transaction_id' => $balanceTx->id, // provenance: оплата → строка журнала
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json(['status' => 'ok'], 200);
|
||||
}
|
||||
|
||||
private function gatewayFor(object $tx): PaymentGateway
|
||||
{
|
||||
return $tx->gateway_id !== null
|
||||
? PaymentGateway::findOrFail($tx->gateway_id)
|
||||
: PaymentGateway::where('code', $tx->gateway_code)->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,7 @@ use App\Http\Requests\UpdateProjectRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Project\ProjectService;
|
||||
use App\Services\Requisites\RequisitesService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -30,17 +26,13 @@ use Illuminate\Http\Request;
|
||||
*/
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectService $projects,
|
||||
private readonly RequisitesService $requisites,
|
||||
) {}
|
||||
public function __construct(private readonly ProjectService $projects) {}
|
||||
|
||||
/** GET /api/projects */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Project::query()
|
||||
->with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers
|
||||
->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1 (hasLinks без per-row запроса)
|
||||
->where('tenant_id', $request->user()->tenant_id);
|
||||
|
||||
// Batch-fetch по ids — возвращает без пагинации (для dropdown'ов и т.п.)
|
||||
@@ -125,120 +117,24 @@ class ProjectController extends Controller
|
||||
/** POST /api/projects */
|
||||
public function store(StoreProjectRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$tenant = $request->user()->tenant;
|
||||
$project = $this->projects->create($request->user()->tenant, $request->validated());
|
||||
|
||||
// G1/SP2: гейт первого проекта — нельзя создать первый проект без минимальных реквизитов.
|
||||
if (Project::where('tenant_id', $tenant->id)->count() === 0
|
||||
&& ! $this->requisites->isLightComplete($tenant)) {
|
||||
return response()->json(['error' => 'requisites_required'], 422);
|
||||
}
|
||||
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
|
||||
// Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные.
|
||||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
||||
|
||||
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
||||
|
||||
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
||||
return response()->json([
|
||||
'error' => 'balance_insufficient',
|
||||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||||
'would_be_required_leads' => $wouldBeRequired,
|
||||
'deficit_leads' => $preflight['deficit_leads'],
|
||||
], 409);
|
||||
}
|
||||
|
||||
if (! $preflight['passes'] && $forceSaveBlocked) {
|
||||
$validated['preflight_blocked_at'] = now();
|
||||
}
|
||||
|
||||
$project = $this->projects->create($tenant, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project->loadCount('supplierProjects'))], 201);
|
||||
return response()->json(['data' => new ProjectResource($project)], 201);
|
||||
}
|
||||
|
||||
/** PATCH /api/projects/{id} */
|
||||
public function update(UpdateProjectRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$validated = $request->validated();
|
||||
$tenant = $request->user()->tenant;
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
$updated = $this->projects->update($project, $request->validated());
|
||||
|
||||
// Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО
|
||||
// проекта + лимиты остальных активных не-blocked.
|
||||
if (array_key_exists('daily_limit_target', $validated)) {
|
||||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('id', '!=', $project->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
||||
|
||||
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
||||
|
||||
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
||||
return response()->json([
|
||||
'error' => 'balance_insufficient',
|
||||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||||
'would_be_required_leads' => $wouldBeRequired,
|
||||
'deficit_leads' => $preflight['deficit_leads'],
|
||||
], 409);
|
||||
}
|
||||
|
||||
if (! $preflight['passes'] && $forceSaveBlocked) {
|
||||
$validated['preflight_blocked_at'] = now();
|
||||
}
|
||||
}
|
||||
|
||||
$updated = $this->projects->update($project, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
|
||||
*/
|
||||
private function runPreflight(Tenant $tenant, int $requiredLeads): array
|
||||
{
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
// Safe fallback: без активных pricing_tiers биллинг не настроен —
|
||||
// преfflight не имеет смысла, пропускаем (legacy-окружения / тесты).
|
||||
if ($tiers->isEmpty()) {
|
||||
return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0];
|
||||
}
|
||||
|
||||
$result = (new BalancePreflightService)->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $requiredLeads,
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
return [
|
||||
'passes' => $result->passes,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'deficit_leads' => $result->deficitLeads,
|
||||
];
|
||||
return response()->json(['data' => new ProjectResource($updated)]);
|
||||
}
|
||||
|
||||
/** GET /api/projects/{id} */
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1
|
||||
->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1
|
||||
->where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($id);
|
||||
|
||||
@@ -268,24 +164,13 @@ class ProjectController extends Controller
|
||||
{
|
||||
$request->validate(['is_active' => ['required', 'boolean']]);
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||||
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта.
|
||||
$newActive = $request->boolean('is_active');
|
||||
$project->update([
|
||||
'is_active' => $newActive,
|
||||
'paused_at' => $newActive ? null : now(),
|
||||
]);
|
||||
$project->update(['is_active' => $request->boolean('is_active')]);
|
||||
|
||||
// #10: pause/resume must reach the supplier. The job's group recompute pushes
|
||||
// status=paused when no active project of the group remains (resume → active).
|
||||
// G (балансовый блок): заблокированный за нехваткой баланса проект не
|
||||
// возобновляется/синхронизируется у поставщика (зеркалит create-гард).
|
||||
if ($project->preflight_blocked_at === null) {
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
}
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]);
|
||||
return response()->json(['data' => new ProjectResource($project->fresh())]);
|
||||
}
|
||||
|
||||
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* Публичный (без auth) список текущей тарифной сетки для страницы цен.
|
||||
*
|
||||
* Read-only, без ПДн. Переиспользует PricingTierRepository::activeAt.
|
||||
* Требование ЮKassa: цены должны быть публично доступны на сайте.
|
||||
*/
|
||||
class PublicPricingController extends Controller
|
||||
{
|
||||
public function index(PricingTierRepository $repo): JsonResponse
|
||||
{
|
||||
$tiers = $repo->activeAt(CarbonImmutable::now())
|
||||
->map(fn ($t) => [
|
||||
'tier_no' => (int) $t->tier_no,
|
||||
'leads_in_tier' => $t->leads_in_tier === null ? null : (int) $t->leads_in_tier,
|
||||
'price_rub' => number_format((int) $t->price_per_lead_kopecks / 100, 2, '.', ''),
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json(['tiers' => $tiers]);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\ConfirmEmailRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Http\Requests\Auth\ResendCodeRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\RegistrationException;
|
||||
use App\Services\Auth\RegistrationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Самозапись клиента (G1/SP1): register → confirm-email → (вход).
|
||||
* Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе
|
||||
* pending_email_confirm, активируется и получает 300 ₽ при подтверждении.
|
||||
*/
|
||||
class RegistrationController extends Controller
|
||||
{
|
||||
use WritesAuthLog;
|
||||
|
||||
public function register(RegisterRequest $request, RegistrationService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $service->register(
|
||||
$request->string('email')->toString(),
|
||||
$request->string('password')->toString(),
|
||||
$request->input('captcha_token'),
|
||||
$request->ip(),
|
||||
);
|
||||
} catch (RegistrationException $e) {
|
||||
return $this->registrationError($e);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'status' => $result['status'],
|
||||
'email' => $result['user']->email,
|
||||
'expires_at' => $result['verification']->expires_at->toIso8601String(),
|
||||
];
|
||||
if ($result['dev_code'] !== null) {
|
||||
$payload['_dev_plain_code'] = $result['dev_code'];
|
||||
}
|
||||
|
||||
return response()->json($payload, 201);
|
||||
}
|
||||
|
||||
public function confirmEmail(ConfirmEmailRequest $request, RegistrationService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$user = $service->confirm(
|
||||
$request->string('email')->toString(),
|
||||
$request->string('code')->toString(),
|
||||
);
|
||||
} catch (RegistrationException $e) {
|
||||
$payload = ['message' => 'Код подтверждения недействителен.', 'reason' => $e->reason];
|
||||
if ($e->attemptsRemaining !== null) {
|
||||
$payload['attempts_remaining'] = $e->attemptsRemaining;
|
||||
}
|
||||
|
||||
return response()->json($payload, 422);
|
||||
}
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
'requires_2fa' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function resendCode(ResendCodeRequest $request, RegistrationService $service): JsonResponse
|
||||
{
|
||||
$devCode = $service->resend($request->string('email')->toString());
|
||||
|
||||
$payload = ['message' => 'Если аккаунт ожидает подтверждения, мы отправили новый код на указанный email.'];
|
||||
if ($devCode !== null) {
|
||||
$payload['_dev_plain_code'] = $devCode;
|
||||
}
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
private function registrationError(RegistrationException $e): JsonResponse
|
||||
{
|
||||
$map = [
|
||||
'captcha_failed' => ['captcha_token', 'Проверка «я не робот» не пройдена.'],
|
||||
'email_taken' => ['email', 'Аккаунт с таким email уже существует.'],
|
||||
];
|
||||
[$field, $message] = $map[$e->reason] ?? ['email', 'Не удалось зарегистрировать аккаунт.'];
|
||||
|
||||
return response()->json([
|
||||
'message' => $message,
|
||||
'errors' => [$field => [$message]],
|
||||
], 422);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function userResource(User $user): array
|
||||
{
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'first_name' => $user->first_name,
|
||||
'last_name' => $user->last_name,
|
||||
'phone' => $user->phone,
|
||||
'timezone' => $user->timezone,
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'totp_enabled' => $user->totp_enabled,
|
||||
'last_login_at' => $user->last_login_at,
|
||||
'notification_preferences' => $user->notification_preferences,
|
||||
'sound_enabled' => $user->sound_enabled,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Reminder;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Reminders API (schema v8.10 §17.5). Все endpoint'ы под `auth:sanctum`.
|
||||
*
|
||||
* Фильтры filter= для GET /api/reminders:
|
||||
* today — completed_at IS NULL AND remind_at в (now-1d, now+1d)
|
||||
* upcoming — completed_at IS NULL AND remind_at > now+1d
|
||||
* overdue — completed_at IS NULL AND remind_at < now-1d
|
||||
* completed — completed_at IS NOT NULL
|
||||
* active — completed_at IS NULL (default)
|
||||
*
|
||||
* RLS: внутри транзакции SET LOCAL app.current_tenant_id = $user->tenant_id.
|
||||
* Защита от кражи: явный where('tenant_id', $user->tenant_id) поверх RLS.
|
||||
*/
|
||||
class ReminderController extends Controller
|
||||
{
|
||||
private const FILTERS = ['active', 'today', 'upcoming', 'overdue', 'completed'];
|
||||
|
||||
/**
|
||||
* GET /api/reminders?filter=&deal_id=&limit=
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'filter' => 'nullable|string|in:'.implode(',', self::FILTERS),
|
||||
'deal_id' => 'nullable|integer|min:1',
|
||||
'limit' => 'nullable|integer|min:1|max:200',
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$filter = $validated['filter'] ?? 'active';
|
||||
$limit = (int) ($validated['limit'] ?? 100);
|
||||
|
||||
return DB::transaction(function () use ($user, $filter, $validated, $limit): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$query = Reminder::query()
|
||||
->with('creator:id,email,first_name,last_name')
|
||||
->where('tenant_id', $user->tenant_id);
|
||||
|
||||
if (isset($validated['deal_id'])) {
|
||||
$query->where('deal_id', (int) $validated['deal_id']);
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
switch ($filter) {
|
||||
case 'today':
|
||||
$query->whereNull('completed_at')
|
||||
->whereBetween('remind_at', [$now->copy()->subDay(), $now->copy()->addDay()]);
|
||||
break;
|
||||
case 'upcoming':
|
||||
$query->whereNull('completed_at')
|
||||
->where('remind_at', '>', $now->copy()->addDay());
|
||||
break;
|
||||
case 'overdue':
|
||||
$query->whereNull('completed_at')
|
||||
->where('remind_at', '<', $now->copy()->subDay());
|
||||
break;
|
||||
case 'completed':
|
||||
$query->whereNotNull('completed_at');
|
||||
break;
|
||||
case 'active':
|
||||
default:
|
||||
$query->whereNull('completed_at');
|
||||
break;
|
||||
}
|
||||
|
||||
$items = $query->orderBy('remind_at')->limit($limit)->get();
|
||||
|
||||
// Counters для UI badges (today/upcoming/overdue) — отдельные SELECT'ы.
|
||||
$base = Reminder::query()->where('tenant_id', $user->tenant_id);
|
||||
$counts = [
|
||||
'today' => (clone $base)->whereNull('completed_at')
|
||||
->whereBetween('remind_at', [$now->copy()->subDay(), $now->copy()->addDay()])
|
||||
->count(),
|
||||
'upcoming' => (clone $base)->whereNull('completed_at')
|
||||
->where('remind_at', '>', $now->copy()->addDay())
|
||||
->count(),
|
||||
'overdue' => (clone $base)->whereNull('completed_at')
|
||||
->where('remind_at', '<', $now->copy()->subDay())
|
||||
->count(),
|
||||
'active' => (clone $base)->whereNull('completed_at')->count(),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'items' => $items->map(fn (Reminder $r) => $this->toResource($r))->all(),
|
||||
'counts' => $counts,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/reminders {deal_id, text?, remind_at}.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'deal_id' => 'required|integer|min:1',
|
||||
'text' => 'nullable|string|max:255',
|
||||
'remind_at' => 'required|date',
|
||||
'assignee_id' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
// Manager FK guard для assignee_id: должен принадлежать тому же tenant'у.
|
||||
if (isset($validated['assignee_id'])) {
|
||||
$exists = User::query()
|
||||
->where('id', $validated['assignee_id'])
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
if (! $exists) {
|
||||
return response()->json([
|
||||
'message' => 'Менеджер не найден в этом тенанте.',
|
||||
'errors' => ['assignee_id' => ['Не принадлежит вашему тенанту или не активен.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $validated): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$reminder = Reminder::create([
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'deal_id' => (int) $validated['deal_id'],
|
||||
'text' => $validated['text'] ?? null,
|
||||
'remind_at' => Carbon::parse($validated['remind_at']),
|
||||
'created_by' => $user->id,
|
||||
'assignee_id' => $validated['assignee_id'] ?? null,
|
||||
'is_sent' => false,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'reminder' => $this->toResource($reminder->load('creator:id,email,first_name,last_name')),
|
||||
], 201);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/reminders/{id} {text?, remind_at?, assignee_id?}.
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'text' => 'nullable|string|max:255',
|
||||
'remind_at' => 'nullable|date',
|
||||
'assignee_id' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
if (count($validated) === 0) {
|
||||
return response()->json([
|
||||
'message' => 'Передайте хотя бы одно поле.',
|
||||
'errors' => ['_general' => ['Нужно хотя бы одно поле для обновления.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
if (isset($validated['assignee_id'])) {
|
||||
$exists = User::query()
|
||||
->where('id', $validated['assignee_id'])
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
if (! $exists) {
|
||||
return response()->json([
|
||||
'message' => 'Менеджер не найден.',
|
||||
'errors' => ['assignee_id' => ['Не принадлежит вашему тенанту или не активен.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $id, $validated): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$reminder = Reminder::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->first();
|
||||
|
||||
if ($reminder === null) {
|
||||
return response()->json(['message' => 'Напоминание не найдено.'], 404);
|
||||
}
|
||||
|
||||
$update = [];
|
||||
if (array_key_exists('text', $validated)) {
|
||||
$update['text'] = $validated['text'];
|
||||
}
|
||||
if (isset($validated['remind_at'])) {
|
||||
$update['remind_at'] = Carbon::parse($validated['remind_at']);
|
||||
// При сдвиге remind_at сбрасываем is_sent, чтобы cron смог
|
||||
// снова отправить уведомление к новому времени.
|
||||
$update['is_sent'] = false;
|
||||
$update['sent_at'] = null;
|
||||
}
|
||||
if (array_key_exists('assignee_id', $validated)) {
|
||||
$update['assignee_id'] = $validated['assignee_id'];
|
||||
}
|
||||
|
||||
$reminder->update($update);
|
||||
|
||||
return response()->json([
|
||||
'reminder' => $this->toResource($reminder->fresh('creator')),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/reminders/{id}/complete — пометить выполненным.
|
||||
* Идемпотентно: повторный вызов NO-OP.
|
||||
*/
|
||||
public function complete(Request $request, int $id): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
return DB::transaction(function () use ($user, $id): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$reminder = Reminder::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->first();
|
||||
|
||||
if ($reminder === null) {
|
||||
return response()->json(['message' => 'Напоминание не найдено.'], 404);
|
||||
}
|
||||
|
||||
if ($reminder->completed_at === null) {
|
||||
$reminder->update(['completed_at' => Carbon::now()]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'reminder' => $this->toResource($reminder->fresh('creator')),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/reminders/{id}.
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
return DB::transaction(function () use ($user, $id): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$deleted = Reminder::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->delete();
|
||||
|
||||
if ($deleted === 0) {
|
||||
return response()->json(['message' => 'Напоминание не найдено.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Удалено.']);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function toResource(Reminder $reminder): array
|
||||
{
|
||||
$creator = $reminder->creator;
|
||||
|
||||
return [
|
||||
'id' => $reminder->id,
|
||||
'deal_id' => $reminder->deal_id,
|
||||
'text' => $reminder->text,
|
||||
'remind_at' => $reminder->remind_at?->toIso8601String(),
|
||||
'completed_at' => $reminder->completed_at?->toIso8601String(),
|
||||
'is_sent' => $reminder->is_sent,
|
||||
'sent_at' => $reminder->sent_at?->toIso8601String(),
|
||||
'created_at' => $reminder->created_at?->toIso8601String(),
|
||||
'created_by' => $reminder->created_by,
|
||||
'assignee_id' => $reminder->assignee_id,
|
||||
'creator_name' => $creator
|
||||
? trim(($creator->first_name ?? '').' '.($creator->last_name ?? '')) ?: $creator->email
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -44,22 +44,9 @@ class SupplierWebhookController extends Controller
|
||||
/** Audit-fix C2: per-IP rate-limit (DoS-guard), запросов в минуту. */
|
||||
private const RATE_LIMIT_PER_MINUTE = 600;
|
||||
|
||||
public function receive(Request $request, string $secret = ''): JsonResponse
|
||||
public function receive(Request $request, string $secret): JsonResponse
|
||||
{
|
||||
// Аутентификация (аддитивно): URL-секрет (backward-compat) ИЛИ HMAC-подпись
|
||||
// тела (X-Webhook-Signature = hash_hmac sha256 от raw body на том же
|
||||
// supplier_webhook_secret). HMAC позволяет поставщику не слать секрет в URL
|
||||
// — тот течёт в access-логи (P2/E4). verifySecret('') всегда false.
|
||||
$sig = (string) $request->header('X-Webhook-Signature', '');
|
||||
$sig = str_starts_with($sig, 'sha256=') ? substr($sig, 7) : $sig;
|
||||
$secretRow = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();
|
||||
$expectedSecret = $secretRow !== null ? (string) $secretRow->value : '';
|
||||
$hmacValid = $sig !== ''
|
||||
&& $expectedSecret !== '__SET_ON_DEPLOY__'
|
||||
&& strlen($expectedSecret) >= 32
|
||||
&& hash_equals(hash_hmac('sha256', $request->getContent(), $expectedSecret), $sig);
|
||||
|
||||
if (! $this->verifySecret($secret) && ! $hmacValid) {
|
||||
if (! $this->verifySecret($secret)) {
|
||||
$this->logSupplierWebhook($request, null, 'rejected_secret');
|
||||
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
@@ -96,7 +83,7 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'vid' => 'required|integer|min:1',
|
||||
'project' => ['required', 'string', 'max:255'], // Phase 3: regex /^B[123]_.+$/ снят — non-B → platform=DIRECT
|
||||
'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'],
|
||||
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
|
||||
'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"],
|
||||
'tag' => 'nullable|string|max:255',
|
||||
@@ -195,12 +182,8 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
private function parsePlatform(string $project): string
|
||||
{
|
||||
// Phase 3: проекты без B-префикса → DIRECT (раньше silent fallback на 'B1'
|
||||
// приводил к неверной маршрутизации).
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
preg_match('/^(B[123])_/', $project, $m);
|
||||
|
||||
return 'DIRECT';
|
||||
return $m[1] ?? 'B1';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\SupportRequestMail;
|
||||
use App\Models\SupportRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* G7-A: приём клиентских заявок в техподдержку. Запись в БД — основной канал;
|
||||
* письмо в поддержку — best-effort (сбой SMTP не валит запрос, паттерн G1 sendCode).
|
||||
*/
|
||||
class SupportRequestController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'contact' => 'required|string|max:255',
|
||||
'message' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
$supportRequest = DB::transaction(function () use ($user, $validated): SupportRequest {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
return SupportRequest::create([
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'user_id' => $user->id,
|
||||
'name' => $validated['name'],
|
||||
'contact' => $validated['contact'],
|
||||
'message' => $validated['message'],
|
||||
]);
|
||||
});
|
||||
|
||||
// Письмо — best-effort: заявка уже в БД, сбой почты не теряет её и не валит запрос.
|
||||
try {
|
||||
Mail::to(config('services.support.email'))->queue(new SupportRequestMail($supportRequest));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SupportRequestMail queue failed', ['id' => $supportRequest->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true], 201);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LookupInnRequest;
|
||||
use App\Http\Requests\UpdateRequisitesRequest;
|
||||
use App\Http\Resources\RequisitesResource;
|
||||
use App\Models\TenantRequisites;
|
||||
use App\Services\DaData\PartyLookup;
|
||||
use App\Services\Requisites\RequisitesService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TenantRequisitesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequisitesService $service,
|
||||
private readonly PartyLookup $party,
|
||||
) {}
|
||||
|
||||
/** GET /api/tenant/requisites */
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$req = TenantRequisites::where('tenant_id', $request->user()->tenant_id)->first();
|
||||
|
||||
return response()->json(['data' => $req ? new RequisitesResource($req) : null]);
|
||||
}
|
||||
|
||||
/** PUT /api/tenant/requisites */
|
||||
public function update(UpdateRequisitesRequest $request): JsonResponse
|
||||
{
|
||||
$req = $this->service->upsert($request->user()->tenant, $request->validated());
|
||||
|
||||
return response()->json(['data' => new RequisitesResource($req)]);
|
||||
}
|
||||
|
||||
/** POST /api/tenant/requisites/lookup-inn — мягкая подтяжка, ничего не сохраняет */
|
||||
public function lookupInn(LookupInnRequest $request): JsonResponse
|
||||
{
|
||||
$res = $this->party->findByInn($request->validated()['inn']);
|
||||
if ($res === null) {
|
||||
return response()->json(['found' => false]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'found' => true,
|
||||
'legal_name' => $res->legalName,
|
||||
'kpp' => $res->kpp,
|
||||
'ogrn' => $res->ogrn,
|
||||
'legal_address' => $res->address,
|
||||
'subject_type_hint' => $res->type === 'INDIVIDUAL' ? 'sole_proprietor' : 'legal_entity',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ use App\Http\Requests\Auth\UseRecoveryCodeRequest;
|
||||
use App\Http\Requests\Auth\VerifyTwoFactorRequest;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecoveryCode;
|
||||
use App\Services\UserSessionTracker;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -98,7 +97,6 @@ class TwoFactorController extends Controller
|
||||
$request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']);
|
||||
|
||||
$user->update(['last_login_at' => now()]);
|
||||
app(UserSessionTracker::class)->record($request, $user->id);
|
||||
|
||||
$this->logAuthEvent(
|
||||
'2fa_verify_success',
|
||||
@@ -202,7 +200,6 @@ class TwoFactorController extends Controller
|
||||
$request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']);
|
||||
|
||||
$user->update(['last_login_at' => now()]);
|
||||
app(UserSessionTracker::class)->record($request, $user->id);
|
||||
|
||||
$this->logAuthEvent(
|
||||
'2fa_recovery_used',
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Публичный read-API сделок тенанта (G6). Аутентификация — middleware ApiKeyAuth
|
||||
* (tenant_id в request->attributes['api_tenant_id']). Только сделки (deals), не
|
||||
* supplier_leads.
|
||||
*/
|
||||
class DealsController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->attributes->get('api_tenant_id');
|
||||
|
||||
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
||||
|
||||
$since = trim((string) $request->query('since', ''));
|
||||
$sinceDt = null;
|
||||
if ($since !== '') {
|
||||
try {
|
||||
$sinceDt = Carbon::parse($since);
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Невалидный since.'], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$cursorRaw = (string) $request->query('cursor', '');
|
||||
$cursor = null;
|
||||
if ($cursorRaw !== '') {
|
||||
$decoded = base64_decode($cursorRaw, true);
|
||||
$parsed = $decoded === false ? null : json_decode($decoded, true);
|
||||
if (! is_array($parsed) || ! isset($parsed['r'], $parsed['i'])) {
|
||||
return response()->json(['message' => 'Невалидный cursor.'], 422);
|
||||
}
|
||||
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
|
||||
}
|
||||
|
||||
[$rows, $next] = DB::transaction(function () use ($tenantId, $limit, $sinceDt, $cursor) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$query = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with('project:id,name');
|
||||
|
||||
if ($sinceDt !== null) {
|
||||
$query->where('received_at', '>=', $sinceDt);
|
||||
}
|
||||
if ($cursor !== null) {
|
||||
$query->whereRaw('(received_at, id) < (?, ?)', [$cursor['r'], $cursor['i']]);
|
||||
}
|
||||
|
||||
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
|
||||
->limit($limit + 1)->get();
|
||||
|
||||
$hasNext = $rows->count() > $limit;
|
||||
if ($hasNext) {
|
||||
$rows = $rows->slice(0, $limit)->values();
|
||||
}
|
||||
|
||||
$next = null;
|
||||
if ($hasNext && $rows->isNotEmpty()) {
|
||||
$last = $rows->last();
|
||||
$next = base64_encode((string) json_encode([
|
||||
'r' => $last->received_at->toIso8601String(),
|
||||
'i' => $last->id,
|
||||
]));
|
||||
}
|
||||
|
||||
return [$rows, $next];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows->map(fn (Deal $d) => [
|
||||
'id' => $d->id,
|
||||
'received_at' => $d->received_at->toIso8601String(),
|
||||
'phone' => $d->phone,
|
||||
'contact_name' => $d->contact_name,
|
||||
'city' => $d->city,
|
||||
'status' => $d->status,
|
||||
'project' => $d->project?->name,
|
||||
])->all(),
|
||||
'next_cursor' => $next,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -127,16 +127,15 @@ class WebhookSettingsController extends Controller
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
// SSRF-гард + DNS-rebind пиннинг: ОДИН резолв target_url даёт причину
|
||||
// блокировки И безопасный IP. Блокируем адреса во внутренней/зарезервированной
|
||||
// сети (cloud-metadata 169.254.169.254, loopback, RFC1918), которые
|
||||
// https://-валидация на сохранении не ловит.
|
||||
$delivery = WebhookUrlGuard::safeDeliveryIp($sub->target_url);
|
||||
if ($delivery['blockReason'] !== null) {
|
||||
// SSRF-гард: target_url задаёт админ тенанта; блокируем адреса во
|
||||
// внутренней/зарезервированной сети (cloud-metadata 169.254.169.254,
|
||||
// loopback, RFC1918), которые https://-валидация на сохранении не ловит.
|
||||
$blockReason = WebhookUrlGuard::blockReason($sub->target_url);
|
||||
if ($blockReason !== null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'status' => null,
|
||||
'message' => $delivery['blockReason'],
|
||||
'message' => $blockReason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
@@ -146,19 +145,9 @@ class WebhookSettingsController extends Controller
|
||||
'message' => 'Тестовая доставка webhook от Лидерра.',
|
||||
];
|
||||
|
||||
// DNS-rebind пиннинг: подключаемся к УЖЕ проверенному IP, не давая
|
||||
// HTTP-клиенту резолвить хост повторно (TOCTOU). Host/SNI — исходный хост.
|
||||
$httpOptions = [];
|
||||
if ($delivery['ip'] !== null) {
|
||||
$host = trim((string) parse_url($sub->target_url, PHP_URL_HOST), '[]');
|
||||
$port = parse_url($sub->target_url, PHP_URL_PORT) ?? 443;
|
||||
$httpOptions['curl'] = [CURLOPT_RESOLVE => ["{$host}:{$port}:{$delivery['ip']}"]];
|
||||
}
|
||||
|
||||
// Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик).
|
||||
try {
|
||||
$response = Http::withOptions($httpOptions)
|
||||
->timeout(10)
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
|
||||
->post($sub->target_url, $testPayload);
|
||||
|
||||
|
||||
@@ -21,14 +21,6 @@ trait ResolvesAdminUserId
|
||||
{
|
||||
protected function resolveAdminUserId(Request $request, string $stubEmail, string $stubName): int
|
||||
{
|
||||
// Прод: crm_app_user не имеет прав на saas_admin_users → берём системный
|
||||
// admin-id из конфига, не обращаясь к таблице (фикс 500 на admin-сохранениях).
|
||||
// null (dev/test, суперюзер) → fallback на старую логику ниже.
|
||||
$configured = config('admin.audit_system_user_id');
|
||||
if ($configured !== null) {
|
||||
return (int) $configured;
|
||||
}
|
||||
|
||||
$requested = $request->input('admin_user_id');
|
||||
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
|
||||
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Аутентификация публичного API по ключу тенанта (G6).
|
||||
*
|
||||
* Ключ — `Authorization: Bearer lpkapi_...`. В БД лежит bcrypt key_hash + key_prefix
|
||||
* (первые 10 символов). Ищем кандидатов по префиксу через pgsql_supplier (BYPASSRLS —
|
||||
* публичный роут не ставит tenant-GUC, под RLS api_keys вернул бы пусто), затем
|
||||
* Hash::check. Успех → tenant_id в request->attributes (api_tenant_id) + last_used.
|
||||
*/
|
||||
class ApiKeyAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$key = $this->bearer($request);
|
||||
if ($key === null || $key === '') {
|
||||
return response()->json(['message' => 'Требуется API-ключ.'], 401);
|
||||
}
|
||||
|
||||
$prefix = substr($key, 0, 10);
|
||||
|
||||
$candidates = ApiKey::on('pgsql_supplier')
|
||||
->where('key_prefix', $prefix)
|
||||
->where('is_active', true)
|
||||
->where('expires_at', '>', now())
|
||||
->get();
|
||||
|
||||
$matched = null;
|
||||
foreach ($candidates as $candidate) {
|
||||
if (Hash::check($key, (string) $candidate->key_hash)) {
|
||||
$matched = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matched === null) {
|
||||
return response()->json(['message' => 'Неверный или неактивный API-ключ.'], 401);
|
||||
}
|
||||
|
||||
if (! in_array('read', (array) $matched->scopes, true)) {
|
||||
return response()->json(['message' => 'Недостаточно прав ключа.'], 403);
|
||||
}
|
||||
|
||||
ApiKey::on('pgsql_supplier')->whereKey($matched->getKey())->update([
|
||||
'last_used_at' => now(),
|
||||
'last_used_ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
$request->attributes->set('api_tenant_id', (int) $matched->tenant_id);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function bearer(Request $request): ?string
|
||||
{
|
||||
$header = (string) $request->header('Authorization', '');
|
||||
if (str_starts_with($header, 'Bearer ')) {
|
||||
return trim(substr($header, 7));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -24,41 +24,13 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
* admin_user_id для audit-trail по-прежнему резолвится трейтом
|
||||
* ResolvesAdminUserId (стаб super_admin) — это отдельная зона.
|
||||
*
|
||||
* G7-B: пока активен impersonation (маркер сессии ИЛИ машинный ключ) —
|
||||
* вход в saas-admin зону запрещён (запрет эскалации к другим тенантам).
|
||||
*
|
||||
* M-1 (приёмка 21.06): nginx-дверь дополнена app-слойным fail-closed гейтом по
|
||||
* REMOTE_USER + config-allowlist. Закрывает обходы front-controller'а
|
||||
* (/index.php/api/admin, /API/admin), где nginx basic-auth не применяется и
|
||||
* REMOTE_USER пуст. См. config/admin.php и spec 2026-06-21-m1-admin-gate-fail-closed.
|
||||
*
|
||||
* TODO (после Б-1 + DO-4): заменить nginx-дверь на настоящий saas-admin
|
||||
* guard (Yandex 360 SSO-сессия + роль).
|
||||
* guard (Yandex 360 SSO-сессия + роль), вернуть проверку в это middleware.
|
||||
*/
|
||||
class EnsureSaasAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// G7-B: пока активен impersonation (маркер сессии ИЛИ машинный ключ) —
|
||||
// вход в saas-admin зону запрещён (запрет эскалации к другим тенантам).
|
||||
$hasMarker = $request->hasSession() && $request->session()->has('impersonation');
|
||||
$hasBearer = str_starts_with((string) $request->header('Authorization', ''), 'Bearer lpimp_');
|
||||
if ($hasMarker || $hasBearer) {
|
||||
abort(403, 'Во время сессии impersonation доступ в админ-зону запрещён.');
|
||||
}
|
||||
|
||||
// M-1 (приёмка 21.06): fail-closed гейт. REMOTE_USER непуст только у запросов,
|
||||
// прошедших nginx admin-basic-auth (^~ /admin, ^~ /api/admin); обходы через
|
||||
// front-controller (/index.php/api/admin, /API/admin) попадают в auth_basic off
|
||||
// → REMOTE_USER пуст → 403. В local/testing гейт выключен (см. config/admin.php).
|
||||
if (config('admin.basic_auth_gate')) {
|
||||
$remoteUser = (string) $request->server('REMOTE_USER', '');
|
||||
$allowlist = (array) config('admin.basic_auth_allowlist', []);
|
||||
if ($remoteUser === '' || ! in_array($remoteUser, $allowlist, true)) {
|
||||
abort(403, 'Доступ в админ-зону запрещён.');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Services\Pd\ImpersonationExpiryService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* G7-B: на web-запросах с активным impersonation-маркером проверяет 60-мин
|
||||
* лимит. Истёк (или токен уже неактивен) → завершает токен, шлёт письмо,
|
||||
* разлогинивает, чистит маркер. No-op, если маркера нет.
|
||||
*
|
||||
* Отправка письма делегирована ImpersonationExpiryService (Service-слой) —
|
||||
* middleware не зависит от слоя Mail напрямую (deptrac ruleset).
|
||||
*/
|
||||
class ImpersonationContext
|
||||
{
|
||||
public function __construct(private readonly ImpersonationExpiryService $expiry) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->hasSession() || ! $request->session()->has('impersonation')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$marker = $request->session()->get('impersonation');
|
||||
$token = ImpersonationToken::on('pgsql_supplier')->find($marker['token_id'] ?? 0);
|
||||
|
||||
if ($token === null || ! $token->isSessionActive()) {
|
||||
if ($token !== null) {
|
||||
$this->expiry->endSession($token);
|
||||
}
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->forget('impersonation');
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// Завершаем текущий запрос немедленно — auth:sanctum уже мог
|
||||
// резолвить user'а из сессии, поэтому не передаём $next, а
|
||||
// возвращаем 401 явно, чтобы клиент знал о разрыве сессии.
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => 'Сессия impersonation истекла.'], 401);
|
||||
}
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Account;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
/**
|
||||
* Валидация POST /api/account/change-password (UI-аудит 21.06.2026, Security).
|
||||
*
|
||||
* current_password — текущий пароль (проверка совпадения — в контроллере через
|
||||
* Hash::check против колонки password_hash). password — новый, min 10 (ТЗ §22.4.1,
|
||||
* как reset-flow) + confirmed (password_confirmation).
|
||||
*/
|
||||
class ChangePasswordRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'current_password' => ['required', 'string'],
|
||||
'password' => ['required', 'confirmed', Password::min(10)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'current_password.required' => 'Укажите текущий пароль.',
|
||||
'password.required' => 'Укажите новый пароль.',
|
||||
'password.confirmed' => 'Пароли не совпадают.',
|
||||
'password.min' => 'Пароль должен быть не короче 10 символов.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Валидация POST /api/auth/confirm-email — подтверждение почты 6-значным кодом.
|
||||
*/
|
||||
class ConfirmEmailRequest extends FormRequest
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'email.required' => 'Укажите email.',
|
||||
'code.required' => 'Укажите код из письма.',
|
||||
'code.regex' => 'Код состоит из 6 цифр.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -18,21 +18,14 @@ class RegisterRequest extends FormRequest
|
||||
{
|
||||
use HasPasswordRules;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* NB: уникальность email НЕ через DB-rule — её решает RegistrationService
|
||||
* (активный email → 422 email_taken; неподтверждённый → перевыпуск кода).
|
||||
* Капча проверяется на КАЖДОМ register-запросе (это независимый публичный POST).
|
||||
*/
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'password' => $this->passwordRules(),
|
||||
'accept_offer' => ['required', 'accepted'],
|
||||
'accept_pdn' => ['required', 'accepted'],
|
||||
'captcha_token' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,9 +35,9 @@ class RegisterRequest extends FormRequest
|
||||
return array_merge($this->passwordMessages(), [
|
||||
'email.required' => 'Укажите email.',
|
||||
'email.email' => 'Email указан некорректно.',
|
||||
'email.unique' => 'Аккаунт с таким email уже существует.',
|
||||
'accept_offer.accepted' => 'Необходимо принять оферту.',
|
||||
'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.',
|
||||
'captcha_token.required' => 'Подтвердите, что вы не робот.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Валидация POST /api/auth/resend-code — повторная отправка кода подтверждения.
|
||||
*/
|
||||
class ResendCodeRequest extends FormRequest
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'email.required' => 'Укажите email.',
|
||||
'email.email' => 'Email указан некорректно.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LookupInnRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'inn' => ['required', 'string', 'regex:/^(\d{10}|\d{12})$/'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Support\PhoneNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -15,33 +14,6 @@ class StoreProjectRequest extends FormRequest
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Косяк 02: для типа «call» приводим введённый номер к каноничному виду
|
||||
* 7XXXXXXXXXX тем же нормализатором, что и реквизиты (PhoneNormalizer).
|
||||
* Источник проекта хранится без ведущего «+» — раздача лидов матчит
|
||||
* signal_identifier как есть (LeadRouter), поэтому «+» срезаем.
|
||||
* Невалидный мусор оставляем как ввели — финальная regex даст ошибку.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->input('signal_type') === 'call' && $this->filled('signal_identifier')) {
|
||||
$normalized = PhoneNormalizer::normalize((string) $this->input('signal_identifier'));
|
||||
if ($normalized !== null) {
|
||||
$this->merge(['signal_identifier' => ltrim($normalized, '+')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return match ($this->input('signal_type')) {
|
||||
'call' => ['signal_identifier.regex' => 'Введите номер в формате 79161234567 — цифра 7 и 10 цифр после неё. Можно вводить с +7, 8, скобками и пробелами — приведём сами.'],
|
||||
'site' => ['signal_identifier.regex' => 'Введите домен в формате example.ru — без http://, без www и без пути.'],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$signalType = $this->input('signal_type');
|
||||
@@ -56,9 +28,6 @@ class StoreProjectRequest extends FormRequest
|
||||
'regions' => ['present', 'array'],
|
||||
'regions.*' => ['integer', 'between:1,89'],
|
||||
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
|
||||
// Spec C §3.4: при перегрузке преfflight UI шлёт force_save_blocked=true →
|
||||
// проект создаётся с preflight_blocked_at=now() вместо ответа 409.
|
||||
'force_save_blocked' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
||||
if ($signalType === 'site') {
|
||||
|
||||
@@ -5,59 +5,15 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Support\PhoneNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateProjectRequest extends FormRequest
|
||||
{
|
||||
private ?Project $resolvedProject = null;
|
||||
|
||||
private bool $projectResolved = false;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Косяк 02: при редактировании call-проекта нормализуем введённый номер
|
||||
* к 7XXXXXXXXXX (тот же PhoneNormalizer, что и реквизиты; «+» срезаем —
|
||||
* раздача матчит без него). Тип signal_type immutable — берём из проекта.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if (! $this->filled('signal_identifier')) {
|
||||
return;
|
||||
}
|
||||
if ($this->resolveProject()?->signal_type === 'call') {
|
||||
$normalized = PhoneNormalizer::normalize((string) $this->input('signal_identifier'));
|
||||
if ($normalized !== null) {
|
||||
$this->merge(['signal_identifier' => ltrim($normalized, '+')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return match ($this->resolveProject()?->signal_type) {
|
||||
'call' => ['signal_identifier.regex' => 'Введите номер в формате 79161234567 — цифра 7 и 10 цифр после неё. Можно вводить с +7, 8, скобками и пробелами — приведём сами.'],
|
||||
'site' => ['signal_identifier.regex' => 'Введите домен в формате example.ru — без http://, без www и без пути.'],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveProject(): ?Project
|
||||
{
|
||||
if (! $this->projectResolved) {
|
||||
$projectId = $this->route('id');
|
||||
$this->resolvedProject = $projectId !== null ? Project::find($projectId) : null;
|
||||
$this->projectResolved = true;
|
||||
}
|
||||
|
||||
return $this->resolvedProject;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
// signal_type immutable: не валидируется в правилах, controller игнорирует поле
|
||||
@@ -72,22 +28,22 @@ class UpdateProjectRequest extends FormRequest
|
||||
'sms_senders' => ['sometimes', 'array', 'min:1'],
|
||||
'sms_senders.*' => ['string', 'max:11'],
|
||||
'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'],
|
||||
// Spec C §3.4: при перегрузке преfflight UI шлёт force_save_blocked=true →
|
||||
// проект помечается preflight_blocked_at=now() вместо ответа 409.
|
||||
'force_save_blocked' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
||||
// 18.05.2026 UX: редактирование источника (signal_identifier) для site/call.
|
||||
// Регулярки соответствуют StoreProjectRequest (domain + 7\d{10}).
|
||||
// signal_type immutable — берём из текущего проекта по route id.
|
||||
$project = $this->resolveProject();
|
||||
if ($project !== null) {
|
||||
if ($project->signal_type === 'site') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
|
||||
} elseif ($project->signal_type === 'call') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^7\d{10}$/'];
|
||||
$projectId = $this->route('id');
|
||||
if ($projectId !== null) {
|
||||
$project = Project::find($projectId);
|
||||
if ($project !== null) {
|
||||
if ($project->signal_type === 'site') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
|
||||
} elseif ($project->signal_type === 'call') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^7\d{10}$/'];
|
||||
}
|
||||
// sms: signal_identifier меняется через sms_senders/sms_keyword (см. выше)
|
||||
}
|
||||
// sms: signal_identifier меняется через sms_senders/sms_keyword (см. выше)
|
||||
}
|
||||
|
||||
return $rules;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Support\InnValidator;
|
||||
use App\Support\PhoneNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateRequisitesRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
$subjectType = (string) $this->input('subject_type');
|
||||
|
||||
return [
|
||||
'subject_type' => ['required', Rule::in(['individual', 'sole_proprietor', 'legal_entity'])],
|
||||
'contact_name' => ['required', 'string', 'max:255'],
|
||||
'contact_phone' => ['required', 'string', function ($attr, $value, $fail) {
|
||||
if (PhoneNormalizer::normalize((string) $value) === null) {
|
||||
$fail('Некорректный телефон.');
|
||||
}
|
||||
}],
|
||||
'inn' => [
|
||||
Rule::requiredIf(in_array($subjectType, ['legal_entity', 'sole_proprietor'], true)),
|
||||
'nullable', 'string',
|
||||
function ($attr, $value, $fail) use ($subjectType) {
|
||||
if (in_array($subjectType, ['legal_entity', 'sole_proprietor'], true)
|
||||
&& is_string($value) && $value !== ''
|
||||
&& ! InnValidator::isValid($value, $subjectType)) {
|
||||
$fail('Некорректный ИНН (контрольная цифра).');
|
||||
}
|
||||
},
|
||||
],
|
||||
'legal_name' => ['nullable', 'string', 'max:255'],
|
||||
'kpp' => ['nullable', 'string', 'regex:/^\d{9}$/'],
|
||||
'ogrn' => ['nullable', 'string', 'regex:/^(\d{13}|\d{15})$/'],
|
||||
'legal_address' => ['nullable', 'string'],
|
||||
'bank_name' => ['nullable', 'string', 'max:255'],
|
||||
'bank_bik' => ['nullable', 'string', 'regex:/^\d{9}$/'],
|
||||
'bank_account' => ['nullable', 'string', 'regex:/^\d{20}$/'],
|
||||
'corr_account' => ['nullable', 'string', 'regex:/^\d{20}$/'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
<?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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\Project\ProjectRuleMessages;
|
||||
use App\Services\Project\SupplierSnapshotGuard;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -15,23 +13,6 @@ class ProjectResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
// Состояние блокировки источника для UI (read-only). hasLinks — из eager-loaded
|
||||
// supplier_projects_count (анти-N+1); fallback на exists() если count не загружен.
|
||||
$hasLinks = $this->supplier_projects_count !== null
|
||||
? (int) $this->supplier_projects_count > 0
|
||||
: $this->supplierProjects()->exists();
|
||||
$sourceLock = (new SupplierSnapshotGuard)->lockState(
|
||||
hasLinks: $hasLinks,
|
||||
isActive: (bool) $this->is_active,
|
||||
pausedAt: $this->paused_at,
|
||||
);
|
||||
|
||||
// Эпик 6.3: единый текст правила смены источника (из ProjectRuleMessages) —
|
||||
// диалог подтверждения на экране тянет его отсюда, не дублирует строку в JS.
|
||||
$sourceChangeMessage = $sourceLock['locked'] && $sourceLock['unlock_at'] !== null
|
||||
? (new ProjectRuleMessages)->sourceChanged($sourceLock['unlock_at'])
|
||||
: null;
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
@@ -50,22 +31,10 @@ class ProjectResource extends JsonResource
|
||||
'delivery_days_mask' => $this->delivery_days_mask,
|
||||
'sync_status' => $this->aggregateSyncStatus(),
|
||||
'last_synced_at' => $this->aggregateLastSyncedAt(),
|
||||
// H (балансовый блок): проект приостановлен из-за нехватки баланса (read-only для UI).
|
||||
'balance_blocked' => $this->preflight_blocked_at !== null,
|
||||
'supplier_links' => $this->when(
|
||||
$request->routeIs('projects.show'),
|
||||
fn () => $this->getSupplierLinks(),
|
||||
),
|
||||
// Task 2.11 (Spec §4.2.5): dynamic attribute, не БД-поле. Установлен
|
||||
// ProjectService::update() для slepok-sensitive правок. UI показывает
|
||||
// «изменения вступят в силу с DD.MM HH:MM МСК».
|
||||
'applies_from' => $this->applies_from?->toIso8601String(),
|
||||
// Блокировка смены источника (спека 2026-06-22-project-source-edit-lock-ux).
|
||||
'source_locked' => $sourceLock['locked'],
|
||||
'source_unlock_at' => $sourceLock['unlock_at']?->toIso8601String(),
|
||||
'source_unlock_projected' => $sourceLock['projected'],
|
||||
// Эпик 6.3: единый текст правила (бэкенд — источник истины для строк).
|
||||
'source_change_message' => $sourceChangeMessage,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\TenantRequisites;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin TenantRequisites */
|
||||
class RequisitesResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'subject_type' => $this->subject_type,
|
||||
'contact_name' => $this->contact_name,
|
||||
'contact_phone' => $this->contact_phone,
|
||||
'inn' => $this->inn,
|
||||
'legal_name' => $this->legal_name,
|
||||
'kpp' => $this->kpp,
|
||||
'ogrn' => $this->ogrn,
|
||||
'legal_address' => $this->legal_address,
|
||||
'bank_name' => $this->bank_name,
|
||||
'bank_bik' => $this->bank_bik,
|
||||
'bank_account' => $this->bank_account,
|
||||
'corr_account' => $this->corr_account,
|
||||
'requisites_completed_at' => $this->requisites_completed_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<?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);
|
||||
|
||||
// Сквозной дедуп: убираем конкурентов, уже известных тенанту (в поле или предложениях
|
||||
// из прошлых прогонов) — иначе повторный подбор плодит дубли карточек. Если после
|
||||
// фильтра ничего нового не осталось — прогон пустой и НЕ списывается (как и обычное «пусто»).
|
||||
// Исключаем конкурентов ЭТОГО же прогона (иначе ретрай упавшего прогона схлопнул бы
|
||||
// собственные результаты в «пусто»). Фильтруем только чужие прогоны и ручных.
|
||||
$existingKeys = AutopodborCompetitor::where('tenant_id', $run->tenant_id)
|
||||
->where(function ($q) use ($run) {
|
||||
$q->where('search_run_id', '!=', $run->id)->orWhereNull('search_run_id');
|
||||
})
|
||||
->pluck('dedup_key')
|
||||
->all();
|
||||
$unique = array_values(array_filter(
|
||||
$unique,
|
||||
fn (array $c) => ! in_array($c['dedup_key'], $existingKeys, true),
|
||||
));
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Billing;
|
||||
|
||||
use App\Mail\BalanceFrozenFinalMail;
|
||||
use App\Mail\BalanceFrozenReminderMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Повторные письма заморозки баланса:
|
||||
* • reminder в окне 24-48ч после freeze;
|
||||
* • final в окне 72-96ч после freeze.
|
||||
*
|
||||
* Throttle через balance_freeze_log markers (event_type 'reminder_sent' / 'final_sent') —
|
||||
* один marker-row на (tenant, тип) в течение окна 5 дней. Запускается daily @ 18:30 MSK
|
||||
* (routes/console.php). См. спек §3.7.
|
||||
*
|
||||
* Re-evaluate PreflightResult: показываем клиенту АКТУАЛЬНЫЙ дефицит (он мог частично
|
||||
* пополниться — reminder отразит обновлённую цифру).
|
||||
*/
|
||||
final class BalanceFrozenReminderJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
|
||||
private const REMINDER_MIN_HOURS = 24;
|
||||
|
||||
private const REMINDER_MAX_HOURS = 48;
|
||||
|
||||
private const FINAL_MIN_HOURS = 72;
|
||||
|
||||
private const FINAL_MAX_HOURS = 96;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$service = new BalancePreflightService;
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
// Переезд на 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')
|
||||
->orderBy('id')
|
||||
->pluck('id');
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$this->processTenant((int) $tenantId, $service, $tiers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function processTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
|
||||
{
|
||||
// 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);
|
||||
|
||||
$tenant = Tenant::find($tenantId);
|
||||
if ($tenant === null || $tenant->frozen_by_balance_at === null) {
|
||||
return; // разморожен/удалён между pluck и обработкой.
|
||||
}
|
||||
|
||||
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
|
||||
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
|
||||
|
||||
$window = $this->matchWindow($hours);
|
||||
if ($window === null) {
|
||||
return; // вне окон reminder/final
|
||||
}
|
||||
|
||||
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
|
||||
if ($this->alreadySent($tenant->id, $marker)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
if ($hours >= self::REMINDER_MIN_HOURS && $hours < self::REMINDER_MAX_HOURS) {
|
||||
return 'reminder';
|
||||
}
|
||||
if ($hours >= self::FINAL_MIN_HOURS && $hours < self::FINAL_MAX_HOURS) {
|
||||
return 'final';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function alreadySent(int $tenantId, string $marker): bool
|
||||
{
|
||||
return DB::connection('pgsql_supplier')->table('balance_freeze_log')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('event_type', $marker)
|
||||
->where('created_at', '>=', now()->subDays(5))
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function mark(Tenant $tenant, string $marker, PreflightResult $result): void
|
||||
{
|
||||
DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type' => $marker,
|
||||
'triggered_by' => 'reminder_cron',
|
||||
'balance_rub_at_event' => $tenant->balance_rub,
|
||||
'required_leads' => $result->requiredLeads,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'total_daily_limit' => $result->requiredLeads,
|
||||
'details' => json_encode(['deficit_leads' => $result->deficitLeads]),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Billing;
|
||||
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Mail\BalanceFrozenMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use App\Services\Billing\ProjectBlockReleaseService;
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Ежедневный преfflight всех тенантов перед формированием заказа поставщику.
|
||||
* Запускается cron @18:00 MSK (routes/console.php). См. спек §3.5, §5.2.
|
||||
*
|
||||
* NB: бегает без tenant-RLS (системный контекст); запросы к projects/tenants
|
||||
* явные по tenant_id (урок Спека B). Переход active→frozen / frozen→active
|
||||
* шлёт письмо; стабильное состояние не трогается (идемпотентность).
|
||||
*/
|
||||
final class BalancePreflightSweepJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$service = new BalancePreflightService;
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
|
||||
// Переезд на 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(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 ($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(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $required,
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
$isFrozen = $tenant->frozen_by_balance_at !== null;
|
||||
|
||||
if (! $result->passes) {
|
||||
// Переход active → frozen (разморозку/снятие блоков здесь НЕ делаем —
|
||||
// заморозка главнее, см. иерархию J спеки balance-lock-unify-FJ).
|
||||
if (! $isFrozen) {
|
||||
$freezeAt = now();
|
||||
$tenant->frozen_by_balance_at = $freezeAt;
|
||||
$tenant->save();
|
||||
|
||||
// Stage 3 R-13 (spec §4.3.2): помечаем все непаузнутые проекты
|
||||
// тенанта моментом заморозки. Это даёт SupplierSnapshotGuard
|
||||
// зацепку (paused_at свежее grace-периода) — клиент не сможет
|
||||
// удалить/сменить источник пока хвост слепка ещё может прилететь.
|
||||
DB::connection('pgsql_supplier')->table('projects')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNull('paused_at')
|
||||
->update(['paused_at' => $freezeAt]);
|
||||
|
||||
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
|
||||
Mail::queue(new BalanceFrozenMail($tenant, $result));
|
||||
$this->dispatchSupplierSyncIfOnline($tenant);
|
||||
}
|
||||
|
||||
return; // заморожен и не хватает — стабильное состояние, блоки не трогаем.
|
||||
}
|
||||
|
||||
// passes → единый путь разблокировки (D6): разморозить клиента (если был, J)
|
||||
// + снять блоки всех проектов (F). Идемпотентно: нет замков → no-op.
|
||||
(new ProjectBlockReleaseService)->releaseForTenant($tenant->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spec C extension (26.05.2026): при переходе freeze ↔ unfreeze в режиме 'online'
|
||||
* диспатчим точечный sync с поставщиком per-project (group-recalc внутри handleOnline
|
||||
* сам учтёт шеринг через signal_identifier). В режиме 'batch' изменения уезжают
|
||||
* cut-off cron'ом @18:00 MSK через SyncSupplierProjectsJob (множественный).
|
||||
* Привязка к админ-переключателю SupplierExportMode (system_settings.supplier_export_mode).
|
||||
*
|
||||
* Вызывается ВНУТРИ DB::transaction обёртки evaluateTenant — app.current_tenant_id выставлен,
|
||||
* RLS-фильтрация projects работает.
|
||||
*/
|
||||
private function dispatchSupplierSyncIfOnline(Tenant $tenant): void
|
||||
{
|
||||
if (! SupplierExportMode::isOnline()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$projectIds = $tenant->projects()
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->pluck('id');
|
||||
|
||||
foreach ($projectIds as $id) {
|
||||
SyncSupplierProjectJob::dispatch((int) $id);
|
||||
}
|
||||
}
|
||||
|
||||
private function logEvent(Tenant $tenant, string $event, string $trigger, PreflightResult $result): void
|
||||
{
|
||||
DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type' => $event,
|
||||
'triggered_by' => $trigger,
|
||||
'balance_rub_at_event' => $tenant->balance_rub,
|
||||
'required_leads' => $result->requiredLeads,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'total_daily_limit' => $result->requiredLeads,
|
||||
'details' => json_encode(['deficit_leads' => $result->deficitLeads]),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user