Compare commits

..

8 Commits

Author SHA1 Message Date
Дмитрий e3f6227ed1 feat(rossvyaz): normalize AO / inverted republics / Saha / Kuzbass / HMAO
Extend RussianRegions::canonicalRegionName for the long tail of registry
formats: ' АО' -> ' автономный округ', generic 'Республика X' -> 'X Республика'
(Чеченская/Кабардино-Балкарская/Карачаево-Черкесская/Донецкая Народная/
Луганская Народная/Удмуртская), ХМАО marker heuristic, plus aliases for
Саха /Якутия/, Чувашия - Чувашия, Кузбасс область, Город Москва, Санкт - Петербург.
Republika-first canonicals stay as-is. Unit-tested (21 GREEN).
2026-06-03 08:16:54 +03:00
CoralMinister 7b8535eef2 Merge pull request #46 from CoralMinister/fix/phone-ranges-staging-id
fix(phone-ranges): give staging its own id sequence for repeat imports
2026-06-03 07:41:30 +03:00
Дмитрий 69c1c5b374 fix(phone-ranges): give staging its own id sequence for repeat imports
LIKE phone_ranges INCLUDING DEFAULTS copied the serial id default pointing
at the original sequence, which atomic-swap destroys (DROP phone_ranges_old
CASCADE) after the first import — the second import then hit NOT NULL on
staging.id. Now staging gets a dedicated sequence named by import_id, OWNED
BY the id column so it travels on RENAME and drops with the old table.
Reproduced via a post-swap test (live id default removed).
2026-06-03 07:39:53 +03:00
CoralMinister 8e804cc482 Merge pull request #45 from CoralMinister/chore/lead-region-ops-force
chore(lead-region-ops): add force input for phone-ranges:import
2026-06-03 06:58:02 +03:00
Дмитрий 0bf69ce6b5 chore(lead-region-ops): add force input for phone-ranges:import
Re-import skips on identical checksum without --force. Adds a 'force'
boolean dispatch input wired into the import op so the registry can be
re-mapped after the region-normalization fix (PR #44).
2026-06-03 06:12:43 +03:00
CoralMinister 07747713f0 Merge pull request #44 from CoralMinister/feat/rossvyaz-region-mapping
Feat/rossvyaz region mapping
2026-06-02 15:42:37 +03:00
Дмитрий c6d2df908a feat(rossvyaz): wire region normalizer into import + fill region_normalized
PhoneRangesImportCommand now resolves subject_code via
RussianRegions::canonicalRegionName (pipe segment + обл./alias normalization)
and persists region_normalized. messy.csv fixture covers real prod formats
(3-digit DEF codes per chk_phone_ranges_def_code). 5/5 command tests GREEN.
2026-06-02 15:39:35 +03:00
Дмитрий d4ade05446 feat(rossvyaz): normalize registry region names to subject_code
RussianRegions::canonicalRegionName + resolveSubjectCode: take last pipe
segment, expand обл.->область, alias federal cities / Удмуртская / Кузбасс.
Fixes 98% unmapped phone_ranges (exact-match -> normalized). Unit-tested.
2026-06-02 15:22:24 +03:00
1986 changed files with 22800 additions and 166156 deletions
+8 -16
View File
@@ -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] — <вывод | причина>
-68
View File
@@ -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);
-27
View File
@@ -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,
},
}));
+258 -13
View File
@@ -32,38 +32,283 @@
"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 .claude/hooks/prod-db-pointer.mjs",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
}
]
},
{
"matcher": "Edit|Write|MultiEdit|Bash",
"hooks": [
{
"type": "command",
"command": "node tools/router-tool-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-memory-coverage.mjs",
"timeout": 5
},
{
"type": "command",
"command": "node tools/enforce-tdd-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-branch-switch.mjs",
"timeout": 5
},
{
"type": "command",
"command": "node tools/enforce-verify-before-push.mjs",
"timeout": 5
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-router-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "PowerShell",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-powershell-gate.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-normative-content-rules.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-tdd-real-test-verifier.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit|Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-self-debrief-detector.mjs",
"timeout": 5
}
]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "node tools/askuser-cosmetic-detector.mjs",
"timeout": 5
}
]
},
{
"matcher": "mcp__.*",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-mcp-classification.mjs",
"timeout": 5
}
]
},
{
"matcher": "Read",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-read-path-deny.mjs",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; if(/\\\\.md$/i.test(f) && !/CLAUDE\\\\.md$/i.test(f)) { require('child_process').spawnSync('npx',['-y','markdownlint-cli2','--fix',f],{stdio:'inherit',shell:true}); }\""
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-verify-record.mjs",
"timeout": 5
},
{
"type": "command",
"command": "node tools/enforce-rationalization-audit.mjs",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-rationalization-audit.mjs",
"timeout": 5
}
]
},
{
"matcher": "Task",
"hooks": [
{
"type": "command",
"command": "node tools/enforce-subagent-return-scanner.mjs",
"timeout": 10
}
]
}
],
"PreToolUse": [
"Stop": [
{
"matcher": "Bash|PowerShell",
"hooks": [
{
"type": "command",
"command": "node .claude/hooks/prod-db-guard.mjs",
"timeout": 10,
"statusMessage": "prod-db-guard"
"command": "node tools/observer-stop-hook.mjs",
"timeout": 60
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/router-stop-gate.mjs",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-coverage-verify.mjs",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-todowrite-skill-verifier.mjs",
"timeout": 5
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/cost-stop-hook.mjs",
"timeout": 10
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node tools/router-prehook.mjs",
"timeout": 60
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node tools/enforce-prompt-injection.mjs",
"timeout": 5
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node tools/router-embedding-warmup.mjs",
"timeout": 30
}
]
}
]
}
}
}
-5
View File
@@ -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
+11 -3
View File
@@ -53,6 +53,11 @@ on:
required: false
default: true
type: boolean
force:
description: 'import: принудительно (--force, игнорировать «реестр идентичен»)'
required: false
default: false
type: boolean
phone:
description: 'smoke: телефон'
required: false
@@ -77,6 +82,7 @@ jobs:
URL: ${{ github.event.inputs.url }}
DIR: ${{ github.event.inputs.dir }}
DRY: ${{ github.event.inputs.dry_run }}
FORCE: ${{ github.event.inputs.force }}
PHONE: ${{ github.event.inputs.phone }}
steps:
@@ -344,12 +350,14 @@ jobs:
run: |
DRY_FLAG=""
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi
FORCE_FLAG=""
if [ "${FORCE}" = "true" ]; then FORCE_FLAG="--force"; 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
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' FORCE_FLAG='$FORCE_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 "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ${FORCE_FLAG} ==="
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG $FORCE_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 не парсил
-22
View File
@@ -47,16 +47,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 +129,6 @@ c--Users-*/
# ── Временные файлы ─────────────────────────────────────────────────────────
*.tmp
*.bak
.mcp.json.bak-*
*.log
tmp/
.tmp/
@@ -213,14 +202,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
View File
@@ -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 = [
+2 -3
View File
@@ -54,9 +54,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)(/|$|\\?)",
# Корневой `/` в концептах (логотип-якорь для будущей главной)
-1
View File
@@ -6,4 +6,3 @@ CLAUDE.md
.claude/skills/ccpm/
.claude/skills/data-scientist/
.claude/skills/marketingskills/
docs/superpowers/
-25
View File
@@ -54,31 +54,6 @@
},
"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": {
"command": "npx",
"args": ["-y", "@perplexity-ai/mcp-server"],
"env": {
"PERPLEXITY_API_KEY": "${PERPLEXITY_API_KEY}",
"PERPLEXITY_BASE_URL": "https://api.aitunnel.ru/v1"
},
"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)."
},
"exa": {
"command": "npx",
"args": ["-y", "exa-mcp-server"],
"env": {
"EXA_API_KEY": "${EXA_API_KEY}"
},
"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."
},
"firecrawl": {
"command": "npx",
"args": ["-y", "firecrawl-mcp"],
"env": {
"FIRECRAWL_API_KEY": "${FIRECRAWL_API_KEY}"
},
"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."
},
"_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."
}
+293 -65
View File
File diff suppressed because one or more lines are too long
-18
View File
@@ -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=
-1
View File
@@ -4,7 +4,6 @@
.env
.env.backup
.env.production
.env.testing
.phpactor.json
.phpunit.result.cache
/.deptrac.cache
+12 -12
View File
@@ -101,15 +101,13 @@ final class AuditRebuildChain extends Command
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'");
// Disable BEFORE triggers (audit_block_mutation blocks UPDATE).
// Use session-level SET so it works even inside a wrapping transaction
// (e.g. DatabaseTransactions in tests). Reset in finally.
DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'replica'");
try {
$totalUpdated = 0;
if ($partitionClause === 'PARTITION BY tenant_id') {
// Per-tenant rebuild — separate scope iteration per tenant.
@@ -130,12 +128,14 @@ final class AuditRebuildChain extends Command
);
}
} else {
// global scope (auth_log, saas_admin_audit_log).
// BYPASSRLS-таблицы (auth_log, saas_admin_audit_log) — global scope.
$totalUpdated = $this->rebuildScope($partition, $rowExpr, $fromId, null, null);
}
});
$this->info("Обновлено {$totalUpdated} строк в {$partition}.");
$this->info("Обновлено {$totalUpdated} строк в {$partition}.");
} finally {
DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'origin'");
}
$this->info('Готово. Запустите audit:verify-chains для проверки целостности.');
@@ -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');
}
}
@@ -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;
}
}
@@ -6,7 +6,6 @@ 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;
@@ -136,7 +135,7 @@ class PhoneRangesImportCommand extends Command
'error' => trim('dry-run (swap не выполнен). '.$unmatchedNote),
'completed_at' => now(),
]);
$this->info('dry-run: '.count($rows).' строк в phone_ranges_staging, swap не выполнен.');
$this->info('dry-run: '.count($rows)." строк в phone_ranges_staging, swap не выполнен.");
if ($unmatchedNote !== '') {
$this->warn($unmatchedNote);
}
@@ -172,7 +171,7 @@ class PhoneRangesImportCommand extends Command
}
/**
* @return list<string>|null Список файлов или null при ошибке валидации опций.
* @return list<string>|null Список файлов или null при ошибке валидации опций.
*/
private function resolveFiles(): ?array
{
@@ -295,7 +294,7 @@ class PhoneRangesImportCommand extends Command
*/
private function parseXlsx(string $path): array
{
$reader = new XlsxReader;
$reader = new XlsxReader();
$reader->open($path);
$out = [];
@@ -431,7 +430,7 @@ class PhoneRangesImportCommand extends Command
* SET ROLE crm_migrator для корректного ownership на проде; на dev/test роль
* отсутствует RESET и работаем как superuser (зеркало миграционного паттерна).
*/
private function elevate(Connection $c): void
private function elevate(\Illuminate\Database\Connection $c): void
{
try {
$c->statement('SET ROLE crm_migrator');
@@ -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;
}
}
@@ -28,7 +28,7 @@ final class SnapshotBackfillCommand extends Command
$weekdayBit = 1 << ($date->isoWeekday() - 1);
$count = DB::connection('pgsql_supplier')->transaction(function () use ($dateStr, $weekdayBit) {
return DB::connection('pgsql_supplier')->insert(<<<'SQL'
return DB::connection('pgsql_supplier')->insert(<<<SQL
INSERT INTO project_routing_snapshots (
snapshot_date, project_id, tenant_id,
daily_limit, delivery_days_mask, regions,
@@ -47,7 +47,7 @@ final class SnapshotRebuildCommand extends Command
->where('snapshot_date', $dateStr)
->delete();
$inserted = DB::connection('pgsql_supplier')->insert(<<<'SQL'
$inserted = DB::connection('pgsql_supplier')->insert(<<<SQL
INSERT INTO project_routing_snapshots (
snapshot_date, project_id, tenant_id,
daily_limit, delivery_days_mask, regions,
@@ -98,8 +98,6 @@ class VerifyAuditChains extends Command
$partitions = [$table];
}
$tableHadBreach = false;
foreach ($partitions as $partitionName) {
$breaches = $this->checkPartition($partitionName, $table, $config['partition']);
@@ -110,7 +108,6 @@ class VerifyAuditChains extends Command
}
$anyBreach = true;
$tableHadBreach = true;
$firstId = $breaches[0]->id;
$count = count($breaches);
@@ -125,18 +122,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.
@@ -296,38 +281,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.
* Считается отдельным запросом без фильтров (показывает глобальную картину
+36 -37
View File
@@ -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,
];
}
}
@@ -13,10 +13,6 @@ 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;
@@ -42,9 +38,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 +49,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([
@@ -336,8 +316,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,
];
});
+13 -32
View File
@@ -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();
}
}
@@ -10,12 +10,11 @@ use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\UpdateProjectRequest;
use App\Http\Resources\ProjectResource;
use App\Jobs\SyncSupplierProjectJob;
use App\Models\PricingTier;
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 +29,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'ов и т.п.)
@@ -127,13 +122,6 @@ class ProjectController extends Controller
{
$validated = $request->validated();
$tenant = $request->user()->tenant;
// 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']);
@@ -162,7 +150,7 @@ class ProjectController extends Controller
$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} */
@@ -203,7 +191,7 @@ class ProjectController extends Controller
$updated = $this->projects->update($project, $validated);
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
return response()->json(['data' => new ProjectResource($updated)]);
}
/**
@@ -211,8 +199,7 @@ class ProjectController extends Controller
*/
private function runPreflight(Tenant $tenant, int $requiredLeads): array
{
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
$tiers = PricingTier::query()->where('is_active', true)->get();
// Safe fallback: без активных pricing_tiers биллинг не настроен —
// преfflight не имеет смысла, пропускаем (legacy-окружения / тесты).
@@ -238,7 +225,6 @@ class ProjectController extends Controller
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);
@@ -279,13 +265,9 @@ class ProjectController extends Controller
// #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);
@@ -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');
-73
View File
@@ -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;
}
}
+1 -29
View File
@@ -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 цифр.',
];
}
}
+3 -10
View File
@@ -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');
+10 -51
View File
@@ -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 игнорирует поле
@@ -80,14 +36,17 @@ class UpdateProjectRequest extends FormRequest
// 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,8 +31,6 @@ 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(),
@@ -60,12 +39,6 @@ class ProjectResource extends JsonResource
// 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;
}
}
}
@@ -8,7 +8,6 @@ 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;
@@ -48,68 +47,51 @@ final class BalanceFrozenReminderJob implements ShouldQueue
public function handle(): void
{
$service = new BalancePreflightService;
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
$tiers = PricingTier::query()->where('is_active', true)->get();
// Переезд на 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')
Tenant::query()
->whereNotNull('frozen_by_balance_at')
->whereNull('deleted_at')
->orderBy('id')
->pluck('id');
foreach ($tenantIds as $tenantId) {
$this->processTenant((int) $tenantId, $service, $tiers);
}
->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
foreach ($tenants as $tenant) {
/** @var Tenant $tenant */
$this->processTenant($tenant, $service, $tiers);
}
});
}
/**
* @param Collection<int, PricingTier> $tiers
*/
private function processTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
private function processTenant(Tenant $tenant, 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);
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
$tenant = Tenant::find($tenantId);
if ($tenant === null || $tenant->frozen_by_balance_at === null) {
return; // разморожен/удалён между pluck и обработкой.
}
$window = $this->matchWindow($hours);
if ($window === null) {
return; // вне окон reminder/final
}
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
if ($this->alreadySent($tenant->id, $marker)) {
return;
}
$window = $this->matchWindow($hours);
if ($window === null) {
return; // вне окон reminder/final
}
// Re-evaluate для актуального дефицита в тексте письма.
$result = $service->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $tenant->requiredLeadsForTomorrow(),
tiers: $tiers,
);
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
if ($this->alreadySent($tenant->id, $marker)) {
return;
}
$mail = $window === 'reminder'
? new BalanceFrozenReminderMail($tenant, $result)
: new BalanceFrozenFinalMail($tenant, $result);
// Re-evaluate для актуального дефицита в тексте письма.
$result = $service->evaluate(
balanceRub: (string) $tenant->balance_rub,
deliveredInMonth: (int) $tenant->delivered_in_month,
requiredLeads: $tenant->requiredLeadsForTomorrow(),
tiers: $tiers,
);
$mail = $window === 'reminder'
? new BalanceFrozenReminderMail($tenant, $result)
: new BalanceFrozenFinalMail($tenant, $result);
Mail::queue($mail);
$this->mark($tenant, $marker, $result);
});
Mail::queue($mail);
$this->mark($tenant, $marker, $result);
}
private function matchWindow(int $hours): ?string
@@ -6,12 +6,11 @@ namespace App\Jobs\Billing;
use App\Jobs\SyncSupplierProjectJob;
use App\Mail\BalanceFrozenMail;
use App\Mail\BalanceUnfrozenMail;
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;
@@ -38,43 +37,27 @@ final class BalancePreflightSweepJob implements ShouldQueue
public function handle(): void
{
$service = new BalancePreflightService;
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
$tiers = PricingTier::query()->where('is_active', true)->get();
// Переезд на 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);
}
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
foreach ($tenants as $tenant) {
/** @var Tenant $tenant */
$this->evaluateTenant($tenant, $service, $tiers);
}
});
}
/**
* @param Collection<int, PricingTier> $tiers
*/
private function evaluateTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void
private function evaluateTenant(Tenant $tenant, 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 и обработкой — пропускаем.
}
DB::transaction(function () use ($tenant, $service, $tiers): void {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
$required = $tenant->requiredLeadsForTomorrow();
$result = $service->evaluate(
@@ -86,34 +69,51 @@ final class BalancePreflightSweepJob implements ShouldQueue
$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();
// Переход active → frozen.
if (! $result->passes && ! $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]);
// 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);
}
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
Mail::queue(new BalanceFrozenMail($tenant, $result));
$this->dispatchSupplierSyncIfOnline($tenant);
return; // заморожен и не хватает — стабильное состояние, блоки не трогаем.
return;
}
// passes → единый путь разблокировки (D6): разморозить клиента (если был, J)
// + снять блоки всех проектов (F). Идемпотентно: нет замков → no-op.
(new ProjectBlockReleaseService)->releaseForTenant($tenant->id);
// Переход frozen → active.
if ($result->passes && $isFrozen) {
// Stage 3 R-13: фиксируем frozen-moment ДО $tenant->save() — нужно
// для фильтра отката paused_at. Очищаем только те проекты,
// у которых paused_at >= frozen_at_was (== поставленные нами на паузу
// в freeze-блоке). Ручные паузы клиента ДО заморозки имеют
// paused_at < frozen_at_was и сохраняются.
$frozenAtWas = $tenant->frozen_by_balance_at;
$tenant->frozen_by_balance_at = null;
$tenant->save();
DB::connection('pgsql_supplier')->table('projects')
->where('tenant_id', $tenant->id)
->where('paused_at', '>=', $frozenAtWas)
->update(['paused_at' => null]);
$this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result);
Mail::queue(new BalanceUnfrozenMail($tenant, $result));
$this->dispatchSupplierSyncIfOnline($tenant);
return;
}
// Иначе состояние не изменилось — ничего не делаем (идемпотентность).
});
}
-110
View File
@@ -1,110 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs\External;
use App\Services\Dashboard\BalanceHealth;
use App\Services\External\BalanceProvider;
use App\Services\External\DadataBalanceProvider;
use App\Services\External\SupplierBalanceProvider;
use App\Services\External\YandexCloudBalanceProvider;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
/**
* Ежедневно собирает баланс внешних сервисов и пишет в external_service_balances.
* Каждый провайдер изолирован: fetch() не бросает; ok=false оставляет ПРОШЛЫЙ баланс
* + метку ошибки (плитка не падает, показывает «данные от ДАТА»). Пишет под
* crm_supplier_worker (BYPASSRLS) таблица системная, как supplier_sync_runs.
*
* Spec: docs/superpowers/specs/2026-06-28-external-service-balances-design.md
*/
class RefreshExternalBalancesJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS для записи системной таблицы
/** @return array<int,class-string<BalanceProvider>> */
private function providers(): array
{
return [
DadataBalanceProvider::class,
SupplierBalanceProvider::class,
YandexCloudBalanceProvider::class,
];
}
public function handle(): void
{
foreach ($this->providers() as $cls) {
/** @var BalanceProvider $p */
$p = app($cls);
$key = $p->serviceKey();
$reading = $p->fetch(); // не бросает
// Свежий query-builder на КАЖДУЮ итерацию: переиспользование одного билдера
// накапливает where-клаузы (service_key=A AND service_key=B…) → updateOrInsert
// ошибочно идёт в INSERT существующей строки → нарушение PK.
$table = DB::connection(self::DB_CONNECTION)->table('external_service_balances');
if (! $reading->ok) {
// Оставляем прошлый баланс, помечаем ok=false + ошибку.
$table->updateOrInsert(
['service_key' => $key],
[
'ok' => false,
'error' => $reading->error,
'checked_at' => $reading->checkedAt,
'updated_at' => now(),
],
);
continue;
}
[$red, $amber] = $this->floors($key);
$h = BalanceHealth::evaluate((float) $reading->balance, $reading->dailySpend, $red, $amber);
$table->updateOrInsert(
['service_key' => $key],
[
'balance_amount' => $reading->balance,
'currency' => $reading->currency,
'daily_spend_estimate' => $reading->dailySpend,
'days_left' => $h['days_left'],
'light' => $h['light'],
'ok' => true,
'error' => null,
'checked_at' => $reading->checkedAt,
'updated_at' => now(),
],
);
}
}
/** @return array{0:float,1:float} [red_floor, amber_floor] */
private function floors(string $key): array
{
return match ($key) {
'dadata' => [
(float) config('services.dadata.red_floor_rub', 500),
(float) config('services.dadata.amber_floor_rub', 2000),
],
'yandex_cloud' => [
(float) config('services.yandex_cloud.red_floor_rub', 1000),
(float) config('services.yandex_cloud.amber_floor_rub', 5000),
],
'supplier' => [
(float) config('services.supplier.red_floor_rub', 5000),
(float) config('services.supplier.amber_floor_rub', 15000),
],
default => [0.0, 0.0],
};
}
}
+1 -1
View File
@@ -605,7 +605,7 @@ class RouteSupplierLeadJob implements ShouldQueue
}
/**
* @return list<int> '{82,83}' [82,83]; '{}'/'' []
* @return list<int> '{82,83}' [82,83]; '{}'/'' []
*/
private function parseSubjectCodes(string $regionsLiteral): array
{
-78
View File
@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Deal;
use App\Models\Tenant;
use App\Services\NotificationService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* G2-A: раз в 30 минут (routes/console.php) рассылает дайджест новых сделок.
* Окно последние 30 минут по received_at.
*
* Идемпотентность по СДЕЛКЕ (N-4): окно даёт защиту только при ровно-30-мин
* прогонах; ручной/повторный прогон (R3b велит дёргать вручную) перекрывает окно.
* Поэтому каждая сделка, попавшая в дайджест, помечается в Redis (TTL 1 сутки)
* повторно в дайджест не включается. Пометка ставится ПОСЛЕ успешной отправки
* (mark-after-send): падение джоба до неё оставит сделки непомеченными очередь
* повторит (at-least-once вместо тихой потери). Один воркер гонки нет.
*/
final class SendNewLeadsDigestJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
public function handle(NotificationService $notifier): void
{
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (EloquentCollection $tenants) use ($notifier): void {
foreach ($tenants as $tenant) {
/** @var Tenant $tenant */
$this->digestForTenant($tenant, $notifier);
}
});
}
private function digestForTenant(Tenant $tenant, NotificationService $notifier): void
{
DB::transaction(function () use ($tenant, $notifier): void {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
$deals = Deal::query()
->where('tenant_id', $tenant->id)
->where('received_at', '>', now()->subMinutes(30))
->where('is_test', false)
->whereNull('deleted_at')
->orderBy('received_at')
->get();
// N-4: исключаем сделки, уже попавшие в прошлый дайджест (без side-effect).
$fresh = $deals->reject(
fn (Deal $deal): bool => Cache::has('digest_sent:'.$deal->id)
);
if ($fresh->isEmpty()) {
return;
}
$notifier->notifyNewLeadsDigest($tenant, $fresh);
// N-4: помечаем ТОЛЬКО ПОСЛЕ успешного возврата notify. Падение джоба
// до этой точки оставит сделки непомеченными → очередь повторит прогон
// (at-least-once вместо тихой потери дайджеста). Один воркер (см.
// prod-logic-map §18.4) → гонки между прогонами нет. TTL 1 сутки.
foreach ($fresh as $deal) {
Cache::put('digest_sent:'.$deal->id, 1, now()->addDay());
}
});
}
}
+2 -3
View File
@@ -20,7 +20,7 @@ use Illuminate\Support\Facades\Log;
*/
final class SnapshotProjectRoutingJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS
@@ -38,11 +38,10 @@ final class SnapshotProjectRoutingJob implements ShouldQueue
->exists();
if ($exists) {
Log::info('snapshot.already_exists', ['date' => $snapshotDate]);
return;
}
$count = DB::connection(self::DB_CONNECTION)->insert(<<<'SQL'
$count = DB::connection(self::DB_CONNECTION)->insert(<<<SQL
INSERT INTO project_routing_snapshots (
snapshot_date, project_id, tenant_id,
daily_limit, delivery_days_mask, regions,
+3 -40
View File
@@ -6,12 +6,9 @@ namespace App\Jobs\Supplier;
use App\Jobs\RouteSupplierLeadJob;
use App\Mail\CsvDriftAlertMail;
use App\Mail\SupplierCriticalAlertMail;
use App\Mail\TenantBusinessDriftAlertMail;
use App\Models\SupplierLead;
use App\Services\Supplier\SupplierCsvParser;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\CarbonInterface;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Cache\LockProvider;
use Illuminate\Contracts\Mail\Mailer;
@@ -63,9 +60,6 @@ final class CsvReconcileJob implements ShouldQueue
private const LOCK_TTL_SECONDS = 600;
/** UI-аудит 21.06: не чаще 1 алерта о падении сверки за это окно (анти-спам). */
private const FAILURE_ALERT_THROTTLE_HOURS = 6;
public function handle(
SupplierPortalClient $portal,
SupplierCsvParser $parser,
@@ -218,26 +212,6 @@ final class CsvReconcileJob implements ShouldQueue
$this->detectAndAlertBusinessDrift($mailer, $windowStart, $windowEnd);
} catch (Throwable $e) {
// UI-аудит 21.06: раньше падение сверки писалось в лог status=failed,
// но НИКОГО не уведомляло (алерт слался только на drift) — а heartbeat
// показывал «OK» (Schedule::job меряет постановку в очередь, не результат).
// Из-за этого заход к поставщику падал каждые 30 мин ~3 недели незаметно.
// Теперь: при падении шлём critical-алерт на ops-email, троттл 6ч.
$alertSent = false;
if (! $this->failureAlertRecentlySent()) {
try {
$mailer->to((string) config('services.supplier.alert_email'))
->send(new SupplierCriticalAlertMail(
alertType: 'csv_reconcile_failed',
details: 'Сверка с поставщиком (CsvReconcileJob) падает. Ошибка: '
.substr($e->getMessage(), 0, 500),
));
$alertSent = true;
} catch (Throwable $mailError) {
Log::error('csv_reconcile.failure_alert_send_failed', ['error' => $mailError->getMessage()]);
}
}
// $logId === null — упал сам insertGetId, log-строки нет, обновлять нечего.
if ($logId !== null) {
DB::connection(self::DB_CONNECTION)
@@ -247,7 +221,6 @@ final class CsvReconcileJob implements ShouldQueue
'finished_at' => now(),
'status' => 'failed',
'error_message' => substr($e->getMessage(), 0, 1000),
'alert_email_sent_at' => $alertSent ? now() : null,
]);
}
throw $e;
@@ -264,16 +237,6 @@ final class CsvReconcileJob implements ShouldQueue
return trim($phone).'|'.trim($project);
}
/** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */
private function failureAlertRecentlySent(): bool
{
return DB::connection(self::DB_CONNECTION)
->table('supplier_csv_reconcile_log')
->whereNotNull('alert_email_sent_at')
->where('alert_email_sent_at', '>=', now()->subHours(self::FAILURE_ALERT_THROTTLE_HOURS))
->exists();
}
/**
* Извлекает platform из имени проекта:
* - `B[123]_<rest>` 'B1' / 'B2' / 'B3';
@@ -311,8 +274,8 @@ final class CsvReconcileJob implements ShouldQueue
private function detectAndAlertBusinessDrift(
Mailer $mailer,
CarbonInterface $windowStart,
CarbonInterface $windowEnd,
\Carbon\CarbonInterface $windowStart,
\Carbon\CarbonInterface $windowEnd,
): void {
$from = $windowStart->toDateString();
$to = $windowEnd->toDateString();
@@ -337,7 +300,7 @@ final class CsvReconcileJob implements ShouldQueue
}
$mailer->to((string) config('services.supplier.alert_email'))
->send(new TenantBusinessDriftAlertMail(
->send(new \App\Mail\TenantBusinessDriftAlertMail(
tenantId: (int) $row->tenant_id,
snapshotDate: (string) $row->snapshot_date,
expected: $expected,
@@ -6,7 +6,6 @@ namespace App\Jobs\Supplier;
use App\Models\SupplierProject;
use App\Services\Supplier\SupplierPortalClient;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -60,20 +59,6 @@ class DeleteSupplierProjectJob implements ShouldQueue
continue;
}
// Task 2.4: пока летит хвост слепка по этому источнику — НЕ удаляем донора.
// Удалив supplier_project, мы оборвём матч хвостового лида (LeadRouter ищет
// snapshot по источнику донора). Откладываем: повтор на следующем
// CleanupInactiveSupplierProjectsJob (02:00), когда хвост уже долетит.
if ($this->hasActiveSnapshotTail($sp)) {
Log::info('supplier.delete_donor_deferred_snapshot_tail', [
'supplier_project_id' => $id,
'signal_type' => $sp->signal_type,
'unique_key' => $sp->unique_key,
]);
continue;
}
if ($sp->supplier_external_id !== null && $sp->supplier_external_id !== '') {
try {
$client->deleteProject((int) $sp->supplier_external_id);
@@ -92,48 +77,4 @@ class DeleteSupplierProjectJob implements ShouldQueue
SyncSupplierProjectsJob::dispatch();
}
}
/**
* Есть ли снимок маршрутизации за активную дату (сегодня/завтра МСК), чей источник
* матчится на этого донора. Зеркалит предикат LeadRouter::queryCandidates (source-ветка):
* sms (sms_senders->>0) || ('+'||keyword) = unique_key;
* site lower(signal_identifier) = lower(unique_key) ИЛИ поддомен '%.'||unique_key;
* call lower(signal_identifier) = lower(unique_key).
*
* Если матч есть хвостовой лид по этому источнику ещё может прийти, донор нужен.
*/
private function hasActiveSnapshotTail(SupplierProject $sp): bool
{
$today = Carbon::today('Europe/Moscow')->toDateString();
$tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString();
$query = DB::connection(self::DB_CONNECTION)
->table('project_routing_snapshots')
->whereIn('snapshot_date', [$today, $tomorrow]);
if ($sp->signal_type === 'sms') {
$query->whereRaw(
"signal_type = 'sms' AND (
(sms_senders ->> 0)
|| CASE WHEN COALESCE(sms_keyword, '') <> '' THEN '+' || sms_keyword ELSE '' END
) = ?",
[(string) $sp->unique_key],
);
} elseif ($sp->signal_type === 'site') {
$query->whereRaw(
"signal_type = 'site' AND (
LOWER(signal_identifier) = LOWER(?)
OR LOWER(signal_identifier) LIKE '%.' || LOWER(?)
)",
[(string) $sp->unique_key, (string) $sp->unique_key],
);
} else {
$query->whereRaw(
'signal_type = ? AND LOWER(signal_identifier) = LOWER(?)',
[(string) $sp->signal_type, (string) $sp->unique_key],
);
}
return $query->exists();
}
}
@@ -1,58 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Jobs\Supplier;
use App\Jobs\SyncSupplierProjectJob;
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;
use Illuminate\Support\Facades\Log;
/**
* Досыл отложенной онлайн-очереди (Task 4.2).
*
* Онлайн-правки, попавшие в окно 18:00→00:00 МСК, складываются в supplier_deferred_sync
* (SyncSupplierProjectJob::handle, Task 4.1). Этот джоб в 00:05 МСК (вне окна) для каждого
* отложенного проекта диспатчит SyncSupplierProjectJob тот уже отработает немедленно
* и очищает строку. Запускается ПОСЛЕ сброса счётчиков в 00:00.
*
* Системная очередь под crm_supplier_worker (BYPASSRLS), как все supplier-flow джобы.
*
* Spec: docs/superpowers/specs/2026-06-25-snapshot-source-routing-design.md (Эпик 4).
*/
class FlushDeferredOnlineSyncJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
public const string DB_CONNECTION = 'pgsql_supplier';
public function handle(): void
{
$projectIds = DB::connection(self::DB_CONNECTION)
->table('supplier_deferred_sync')
->orderBy('project_id')
->pluck('project_id');
foreach ($projectIds as $projectId) {
SyncSupplierProjectJob::dispatch((int) $projectId);
DB::connection(self::DB_CONNECTION)
->table('supplier_deferred_sync')
->where('project_id', $projectId)
->delete();
}
if ($projectIds->isNotEmpty()) {
Log::info('FlushDeferredOnlineSyncJob: flushed '.$projectIds->count().' deferred online sync(s)');
}
}
}
+42 -120
View File
@@ -83,14 +83,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
$this->client = app(SupplierPortalClient::class);
$consecutiveTransient = 0;
// Эпик 5: сводка по завершении заливки (для экрана SaaS-admin).
$startedAt = now();
$syncedOk = 0;
$manualQueued = 0;
$deferred = 0;
$failed = 0;
$aborted = false;
// 1. Load active Лидерра-projects via pgsql_supplier (фильтруя frozen, Billing v2 Spec C §3.10).
$projects = $this->collectEligibleProjects();
@@ -135,112 +127,56 @@ class SyncSupplierProjectsJob implements ShouldQueue
}
// 3. Sync each group
try {
foreach ($groups as $group) {
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
Log::warning('supplier.sync.time_budget_reached', [
'group' => $group['identifier'],
]);
$aborted = true;
foreach ($groups as $group) {
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
Log::warning('supplier.sync.time_budget_reached', [
'group' => $group['identifier'],
]);
break;
}
try {
$this->syncGroup($group);
$consecutiveTransient = 0;
} catch (TierEscalatedException $e) {
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
continue;
} catch (WindowDeferredException) {
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} deferred by portal window");
continue;
} catch (SupplierAuthException $e) {
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'sticky_auth',
details: $e->getMessage(),
));
report($e);
throw $e;
} catch (SupplierTransientException $e) {
$consecutiveTransient++;
$this->logGroupFailure($group, $e);
if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'mass_transient',
details: "Aborted after {$consecutiveTransient} consecutive transient failures.",
));
report(new \RuntimeException('Supplier outage suspected: mass transient failures'));
break;
}
try {
$this->syncGroup($group);
$consecutiveTransient = 0;
$syncedOk++;
} catch (TierEscalatedException $e) {
$manualQueued++;
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
continue;
} catch (SupplierClientException $e) {
$this->logGroupFailure($group, $e);
report($e);
continue;
} catch (WindowDeferredException) {
$deferred++;
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} deferred by portal window");
continue;
} catch (SupplierAuthException $e) {
$aborted = true;
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'sticky_auth',
details: $e->getMessage(),
));
report($e);
throw $e;
} catch (SupplierTransientException $e) {
$consecutiveTransient++;
$failed++;
$this->logGroupFailure($group, $e);
if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
$aborted = true;
Mail::to((string) config('services.supplier.alert_email'))
->queue(new SupplierCriticalAlertMail(
alertType: 'mass_transient',
details: "Aborted after {$consecutiveTransient} consecutive transient failures.",
));
report(new \RuntimeException('Supplier outage suspected: mass transient failures'));
break;
}
continue;
} catch (SupplierClientException $e) {
$failed++;
$this->logGroupFailure($group, $e);
report($e);
continue;
}
continue;
}
} finally {
$this->recordRunSummary(
startedAt: $startedAt,
groupsTotal: count($groups),
syncedOk: $syncedOk,
manualQueued: $manualQueued,
deferred: $deferred,
failed: $failed,
aborted: $aborted,
);
}
}
/**
* Эпик 5: одна строка-сводка в supplier_sync_runs по завершении заливки.
* Пишется и при раннем abort (time-budget / mass-fail / auth) через finally.
*/
private function recordRunSummary(
Carbon $startedAt,
int $groupsTotal,
int $syncedOk,
int $manualQueued,
int $deferred,
int $failed,
bool $aborted,
): void {
if ($aborted) {
$status = 'aborted';
} elseif ($failed > 0 && $syncedOk === 0) {
$status = 'failed';
} elseif ($failed > 0 || $manualQueued > 0) {
$status = 'partial';
} else {
$status = 'ok';
}
DB::connection(self::DB_CONNECTION)->table('supplier_sync_runs')->insert([
'started_at' => $startedAt,
'finished_at' => now(),
'groups_total' => $groupsTotal,
'synced_ok' => $syncedOk,
'manual_queued' => $manualQueued,
'deferred' => $deferred,
'failed' => $failed,
'status' => $status,
'created_at' => now(),
]);
}
/**
* Собрать eligible Лидерра-проекты для расчёта заказа поставщику.
*
@@ -284,10 +220,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
'snap.daily_limit AS snap_daily_limit',
'snap.delivery_days_mask AS snap_delivery_days_mask',
'snap.regions AS snap_regions',
// Task 2.6: источник тоже из слепка — закрыт рассинхрон 18:02→18:05.
'snap.signal_identifier AS snap_signal_identifier',
'snap.sms_senders AS snap_sms_senders',
'snap.sms_keyword AS snap_sms_keyword',
)
->orderBy('projects.id')
->get();
@@ -299,16 +231,6 @@ class SyncSupplierProjectsJob implements ShouldQueue
$project->daily_limit_target = (int) $project->getAttribute('snap_daily_limit');
$project->delivery_days_mask = (int) $project->getAttribute('snap_delivery_days_mask');
$project->regions = $this->parsePostgresIntArray((string) $project->getAttribute('snap_regions'));
// Task 2.6: источник из слепка (signal_identifier / sms_senders / sms_keyword).
// snap_sms_senders приходит как JSONB-строка ('["Caranga"]') или null —
// декодируем в массив под cast 'array' модели Project.
$project->signal_identifier = $project->getAttribute('snap_signal_identifier');
$project->sms_keyword = $project->getAttribute('snap_sms_keyword');
$rawSenders = $project->getAttribute('snap_sms_senders');
$project->sms_senders = $rawSenders === null
? null
: json_decode((string) $rawSenders, true);
}
return $projects;
-24
View File
@@ -88,36 +88,12 @@ class SyncSupplierProjectJob implements ShouldQueue
}
if (SupplierExportMode::isOnline()) {
// Task 4.1: в окне 18:00→00:00 МСК онлайн НЕ шлёт поставщику немедленно —
// он уже фиксирует заказ слепком в 21:00, мгновенная правка перезаписала бы
// зафиксированное состояние. Откладываем в очередь; FlushDeferredOnlineSyncJob
// (00:05 МСК) досылает вне окна.
if ($this->isInDeferWindow()) {
DB::connection(self::DB_CONNECTION)
->table('supplier_deferred_sync')
->insertOrIgnore([
'project_id' => $project->id,
'created_at' => now(),
]);
Log::info("SyncSupplierProjectJob: project {$project->id} deferred (online window 18:00-00:00 МСК)");
return;
}
$this->handleOnline($project, $channel);
} else {
$this->handleBatch($project, $channel);
}
}
/** Час МСК ≥ 18 (окно 18:00→00:00) — онлайн откладывает отправку до полуночи. */
public const DEFER_WINDOW_START_HOUR_MSK = 18;
private function isInDeferWindow(): bool
{
return Carbon::now('Europe/Moscow')->hour >= self::DEFER_WINDOW_START_HOUR_MSK;
}
// -------------------------------------------------------------------------
// Online mode: per-subject full-param sync
// -------------------------------------------------------------------------
-64
View File
@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Logging;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
/**
* Monolog-процессор: маскирует ПДн в логах перед записью.
*
* Закрывает Medium go-live: laravel.log (LOG_LEVEL=debug) мог сохранить телефон/email
* открытым, если они попадут в текст исключения или контекст. Процессор ловит ВСЕ
* записи каналов, к которым подключён (см. App\Logging\ScrubPii + config/logging.php),
* централизованно надёжнее правки отдельных вызовов Log::.
*/
final class PiiScrubbingProcessor implements ProcessorInterface
{
/**
* Телефоны РФ: 11 цифр в формате 7XXXXXXXXXX / 8XXXXXXXXXX / +7XXXXXXXXXX.
* Lookbehind/lookahead не дают маскировать часть более длинной цифровой строки
* (например 14-значный технический id).
*/
private const PHONE_PATTERN = '/(?<!\d)(?:\+?7|8)\d{10}(?!\d)/';
private const EMAIL_PATTERN = '/[\p{L}0-9._%+\-]+@[\p{L}0-9.\-]+\.\p{L}{2,}/u';
public function __invoke(LogRecord $record): LogRecord
{
return $record->with(
message: $this->scrub($record->message),
context: $this->scrubArray($record->context),
extra: $this->scrubArray($record->extra),
);
}
private function scrub(string $value): string
{
$value = preg_replace(self::PHONE_PATTERN, '[PHONE]', $value) ?? $value;
return preg_replace(self::EMAIL_PATTERN, '[EMAIL]', $value) ?? $value;
}
/**
* @param array<array-key, mixed> $data
* @return array<array-key, mixed>
*/
private function scrubArray(array $data): array
{
$result = [];
foreach ($data as $key => $value) {
if (is_string($value)) {
$result[$key] = $this->scrub($value);
} elseif (is_array($value)) {
$result[$key] = $this->scrubArray($value);
} else {
$result[$key] = $value;
}
}
return $result;
}
}
-25
View File
@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Logging;
use Illuminate\Log\Logger;
/**
* Tap для config/logging.php: вешает PiiScrubbingProcessor на канал.
*
* Использование: 'tap' => [\App\Logging\ScrubPii::class] в описании канала.
*/
final class ScrubPii
{
public function __invoke(Logger $logger): void
{
// Illuminate\Log\Logger::getLogger() типизирован как PSR LoggerInterface,
// но фактически возвращает Monolog\Logger (у него есть pushProcessor).
$monolog = $logger->getLogger();
if ($monolog instanceof \Monolog\Logger) {
$monolog->pushProcessor(new PiiScrubbingProcessor);
}
}
}
@@ -1,41 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо с 6-значным кодом подтверждения почты при самозаписи (G1/SP1).
*/
final class EmailVerificationCodeMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $code,
public readonly string $email,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Код подтверждения регистрации в Лидерре',
to: [$this->email],
);
}
public function content(): Content
{
return new Content(
view: 'emails.email_verification_code',
with: ['code' => $this->code],
);
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/** Код-согласие на вход поддержки в кабинет клиента (G7-B / Ю-1). */
final class ImpersonationCodeMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $code,
public readonly string $email,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Код доступа: запрос входа поддержки в ваш кабинет',
to: [$this->email],
);
}
public function content(): Content
{
return new Content(view: 'emails.impersonation_code', with: ['code' => $this->code]);
}
}
-36
View File
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/** Уведомление о завершении сессии поддержки в кабинете клиента (G7-B / Ю-1). */
final class ImpersonationEndedMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public readonly string $email,
public readonly ?string $tenantName = null,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Сессия поддержки в вашем кабинете завершена',
to: [$this->email],
);
}
public function content(): Content
{
return new Content(view: 'emails.impersonation_ended', with: ['tenantName' => $this->tenantName]);
}
}
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Deal;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Collection;
/**
* Письмо-сводка о новых сделках за окно (G2-A дайджест).
* Заменяет пер-лид NewLeadNotification как email-канал события new_lead.
*
* @property Collection<int, Deal> $deals
*/
class NewLeadsDigestMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public User $user,
public Tenant $tenant,
public Collection $deals,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Лидерра. Новые сделки — '.$this->deals->count(),
);
}
public function content(): Content
{
return new Content(
view: 'emails.new_leads_digest',
with: [
'user' => $this->user,
'tenant' => $this->tenant,
'deals' => $this->deals,
'count' => $this->deals->count(),
],
);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Reminder;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Email-уведомление о наступлении срока напоминания (ТЗ §18.5, событие reminder).
*
* Триггер: cron-команда `reminders:dispatch-due` находит rows с
* `is_sent=false AND completed_at IS NULL AND remind_at <= NOW()`,
* вызывает NotificationService::notifyReminder для каждой,
* затем ставит `is_sent=true, sent_at=NOW()`.
*/
class ReminderDueNotification extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(
public User $recipient,
public Reminder $reminder,
) {}
public function envelope(): Envelope
{
$shortText = $this->reminder->text
? mb_substr($this->reminder->text, 0, 60)
: 'Срок касания клиента';
return new Envelope(
subject: "Лидерра. Напоминание — {$shortText}",
);
}
public function content(): Content
{
return new Content(
view: 'emails.reminder',
with: [
'recipient' => $this->recipient,
'reminder' => $this->reminder,
],
);
}
}
-38
View File
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\SupportRequest;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Письмо в техподдержку о новой заявке клиента (G7-A). Адресат config('services.support.email').
*/
class SupportRequestMail extends Mailable
{
use Queueable;
use SerializesModels;
public function __construct(public SupportRequest $request) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Лидерра. Заявка в поддержку #'.$this->request->id,
);
}
public function content(): Content
{
return new Content(
view: 'emails.support_request',
with: ['r' => $this->request],
);
}
}
-54
View File
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AutopodborCompetitor extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'search_run_id',
'name',
'description',
'is_federal',
'relevance_pct',
'origin',
'site_url',
'directory_urls',
'provenance',
'dedup_key',
'study_run_id',
'studied_at',
'box',
];
protected $casts = [
'is_federal' => 'bool',
'directory_urls' => 'array',
'provenance' => 'array',
'studied_at' => 'datetime',
'created_at' => 'datetime',
];
public function sources(): HasMany
{
return $this->hasMany(AutopodborSource::class, 'competitor_id');
}
public function searchRun(): BelongsTo
{
return $this->belongsTo(AutopodborRun::class, 'search_run_id');
}
public function studyRun(): BelongsTo
{
return $this->belongsTo(AutopodborRun::class, 'study_run_id');
}
}
-40
View File
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class AutopodborRun extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'kind',
'status',
'region_code',
'params',
'competitor_id',
'price_rub_charged',
'balance_transaction_id',
'error_code',
'started_at',
'finished_at',
];
protected $casts = [
'params' => 'array',
'price_rub_charged' => 'decimal:2',
'started_at' => 'datetime',
'finished_at' => 'datetime',
'created_at' => 'datetime',
];
public function competitors(): HasMany
{
return $this->hasMany(AutopodborCompetitor::class, 'search_run_id');
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AutopodborSource extends Model
{
public $timestamps = false;
protected $fillable = [
'tenant_id',
'competitor_id',
'study_run_id',
'signal_type',
'identifier',
'phone_kind',
'phone_type',
'provenance_url',
'provenance_label',
'dedup_key',
'created_project_id',
'box',
];
protected $casts = [
'created_at' => 'datetime',
];
public function competitor(): BelongsTo
{
return $this->belongsTo(AutopodborCompetitor::class, 'competitor_id');
}
public function project(): BelongsTo
{
return $this->belongsTo(Project::class, 'created_project_id');
}
}
-2
View File
@@ -42,8 +42,6 @@ class BalanceTransaction extends Model
public const TYPE_MIGRATION = 'migration';
public const TYPE_AUTOPODBOR_CHARGE = 'autopodbor_charge';
public $timestamps = false;
protected $fillable = [
-71
View File
@@ -1,71 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* Код подтверждения почты при самозаписи клиента (G1/SP1).
*
* 6-значный код, bcrypt-хеш в code_hash, plain уходит письмом. TTL 15 мин,
* 5 попыток. Механика зеркалит ImpersonationToken. Таблица RLS-изолирована
* (через user_id→users.tenant_id) на публичном роуте читается/пишется через
* BYPASSRLS pgsql_supplier.
*
* @property int $id
* @property int $user_id
* @property string $email
* @property string $token
* @property string|null $code_hash
* @property int $failed_attempts
* @property Carbon $expires_at
* @property Carbon|null $verified_at
* @property Carbon $created_at
*/
class EmailVerification extends Model
{
/** schema-таблица не имеет updated_at. */
public const UPDATED_AT = null;
protected $fillable = [
'user_id',
'email',
'token',
'code_hash',
'failed_attempts',
'expires_at',
'verified_at',
];
protected function casts(): array
{
return [
'failed_attempts' => 'integer',
'expires_at' => 'datetime',
'verified_at' => 'datetime',
'created_at' => 'datetime',
];
}
public function isExpired(): bool
{
return $this->expires_at->isPast();
}
public function isUsable(): bool
{
return $this->verified_at === null
&& $this->failed_attempts < 5
&& ! $this->isExpired();
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
-16
View File
@@ -32,7 +32,6 @@ use Illuminate\Support\Carbon;
* @property int|null $second_approver_id
* @property Carbon|null $second_approval_at
* @property Carbon $created_at
* @property string|null $session_token_hash
*
* @mixin IdeHelperImpersonationToken
*/
@@ -55,7 +54,6 @@ class ImpersonationToken extends Model
'invalidated_at',
'second_approver_id',
'second_approval_at',
'session_token_hash',
];
protected function casts(): array
@@ -83,20 +81,6 @@ class ImpersonationToken extends Model
&& ! $this->isExpired();
}
/** Сессия impersonation активна: код подтверждён, не завершена, не инвалидирована, в пределах TTL минут. */
public function isSessionActive(int $ttlMinutes = 60): bool
{
return $this->used_at !== null
&& $this->session_ended_at === null
&& $this->invalidated_at === null
&& $this->used_at->copy()->addMinutes($ttlMinutes)->isFuture();
}
public function sessionExpiresAt(int $ttlMinutes = 60): ?Carbon
{
return $this->used_at?->copy()->addMinutes($ttlMinutes);
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
-24
View File
@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Юрлицо-получатель платежей (schema.sql table legal_entities). Без RLS.
*
* @mixin IdeHelperLegalEntity
*/
class LegalEntity extends Model
{
public $timestamps = false;
protected $fillable = [
'code', 'name', 'short_name', 'legal_form', 'inn', 'kpp', 'ogrn',
'okpo', 'legal_address', 'actual_address', 'bank_name', 'bank_account',
'bank_bik', 'bank_corr', 'director_name', 'director_post',
'director_basis', 'vat_mode',
];
}
-50
View File
@@ -1,50 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Crypt;
/**
* Платёжный шлюз (schema.sql table payment_gateways).
* `config` хранится ЗАШИФРОВАННЫМ (Crypt::encrypt JSON {shop_id, secret_key}). Без RLS.
*
* @mixin IdeHelperPaymentGateway
*/
class PaymentGateway extends Model
{
public $timestamps = false;
protected $fillable = [
'code', 'name', 'driver', 'legal_entity_id', 'config', 'is_active',
'accepts_methods', 'min_amount_rub', 'max_amount_rub', 'sort_order',
];
protected function casts(): array
{
return [
'is_active' => 'boolean',
'accepts_methods' => 'array',
'min_amount_rub' => 'decimal:2',
'max_amount_rub' => 'decimal:2',
];
}
/** Расшифрованные креденшелы шлюза; [] если config пустой/битый. */
public function credentials(): array
{
if ($this->config === null || $this->config === '') {
return [];
}
try {
$decoded = Crypt::decrypt($this->config);
} catch (\Throwable) {
return [];
}
return is_array($decoded) ? $decoded : [];
}
}
-42
View File
@@ -1,42 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\QueryException;
use Laravel\Sanctum\PersonalAccessToken as SanctumPersonalAccessToken;
/**
* Расширение Sanctum PersonalAccessToken.
*
* Перехватывает Bearer-токены с префиксом «lpimp_» (машинные ключи
* impersonation-guard, G7-B) и возвращает null без обращения к БД.
* Это предотвращает crash при отсутствии таблицы personal_access_tokens
* (проект использует SPA cookie-auth, таблица не создаётся).
*/
class PersonalAccessToken extends SanctumPersonalAccessToken
{
/**
* Find the token instance matching the given token.
*
* Returns null immediately for impersonation machine-key tokens so
* Sanctum does not attempt a DB lookup on personal_access_tokens.
*/
public static function findToken($token): ?static
{
if (str_starts_with((string) $token, 'lpimp_')) {
return null;
}
// В проекте нет таблицы personal_access_tokens (SPA cookie-auth, Sanctum
// PAT не используются). Без этого try/catch любой иной Bearer на
// sanctum-роуте ронял бы запрос в 500 (Undefined table) вместо чистого
// 401. Гасим QueryException до null — guard вернёт 401.
try {
return parent::findToken($token);
} catch (QueryException) {
return null;
}
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\ReminderFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Напоминание на сделке (schema v8.10 §17.5).
*
* Tenant-aware модель с RLS. Композитные индексы:
* - idx_reminders_due (remind_at) WHERE is_sent=FALSE AND completed_at IS NULL cron;
* - idx_reminders_deal (deal_id) UI карточки сделки;
* - idx_reminders_tenant_user_active дашборд «today/last/future».
*
* deal_id БЕЗ FK (deals партиционирована, FK на partitioned-родительскую
* таблицу не поддерживается без partition-key в составе).
*
* MVP: assignee_id всегда NULL паритет с histories[].to оригинала. Поле
* зарезервировано для Post-MVP (multi-assignee).
*
* @mixin IdeHelperReminder
*/
class Reminder extends Model
{
/** @use HasFactory<ReminderFactory> */
use HasFactory;
protected $fillable = [
'tenant_id',
'deal_id',
'text',
'remind_at',
'created_by',
'assignee_id',
'completed_at',
'is_sent',
'sent_at',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'deal_id' => 'integer',
'created_by' => 'integer',
'assignee_id' => 'integer',
'is_sent' => 'boolean',
'remind_at' => 'datetime',
'completed_at' => 'datetime',
'sent_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/** @return BelongsTo<User, $this> */
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
/** @return BelongsTo<User, $this> */
public function assignee(): BelongsTo
{
return $this->belongsTo(User::class, 'assignee_id');
}
public function isCompleted(): bool
{
return $this->completed_at !== null;
}
public function isOverdue(): bool
{
return $this->completed_at === null && $this->remind_at < now();
}
}
-45
View File
@@ -1,45 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Платёж SaaS (schema.sql table saas_transactions). RLS tenant_isolation.
* status {pending, success, failed, refunded}; type {topup, refund, manual_credit, manual_debit}.
*
* @mixin IdeHelperSaasTransaction
*/
class SaasTransaction extends Model
{
public const CREATED_AT = 'created_at';
public const UPDATED_AT = null;
public const STATUS_PENDING = 'pending';
public const STATUS_SUCCESS = 'success';
public const STATUS_FAILED = 'failed';
public const TYPE_TOPUP = 'topup';
protected $fillable = [
'tenant_id', 'type', 'amount_rub', 'balance_rub_after', 'leads_credited',
'gateway_id', 'gateway_code', 'gateway_payment_id', 'gateway_idempotence_key',
'payment_method', 'legal_entity_id', 'invoice_id', 'upd_id', 'status',
'description', 'failure_reason', 'balance_transaction_id', 'created_at', 'completed_at',
];
protected function casts(): array
{
return [
'amount_rub' => 'decimal:2',
'balance_rub_after' => 'decimal:2',
'created_at' => 'datetime',
'completed_at' => 'datetime',
];
}
}
-8
View File
@@ -18,14 +18,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
*
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5.1
*
* Поля резолва региона (lead-region) аннотированы явно ide-helper:models
* не подхватил их в стаб IdeHelperSupplierLead:
*
* @property int|null $dadata_qc
* @property string|null $phone_operator
* @property string|null $region_source
* @property int|null $resolved_subject_code
*
* @mixin IdeHelperSupplierLead
*/
class SupplierLead extends Model

Some files were not shown because too many files have changed in this diff Show More