Compare commits
174 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6262639904 | |||
| af690eaaaa | |||
| 04aed13bc4 | |||
| 6e1f5355b8 | |||
| dffefe7fc0 | |||
| d3ed266830 | |||
| e5eed0aeac | |||
| c71d830375 | |||
| 58d0561bb7 | |||
| 220fc6e9c9 | |||
| b75a677d12 | |||
| 281c4ca5ce | |||
| ebca32a212 | |||
| c7f603aa75 | |||
| 9fa5ca1a86 | |||
| 9bc090fbc3 | |||
| be8f582a50 | |||
| 224a048e56 | |||
| 92bbd64eed | |||
| 593f12ae6a | |||
| e3ec24462a | |||
| c7e02eeac9 | |||
| 352354e30b | |||
| d7d8c5edac | |||
| c89630310f | |||
| 136bad4db2 | |||
| 36ada767f4 | |||
| 5f9bd07dd9 | |||
| 71a5dd6f6d | |||
| 4dbf78b204 | |||
| b103c8819c | |||
| 554b1f4aa3 | |||
| d030dbbec4 | |||
| bec69aa565 | |||
| dd0ac43052 | |||
| 57bd85edc6 | |||
| ebca54f0fa | |||
| 7c8223bf72 | |||
| b4fb2cece9 | |||
| 89441d95c3 | |||
| bbe235b436 | |||
| 112591a0da | |||
| 7ed72a09f7 | |||
| 90cbe95598 | |||
| b3af39bdbf | |||
| 35877b7df0 | |||
| 885829815a | |||
| a68ea3964c | |||
| 688da5d38b | |||
| b8adeeb9fd | |||
| 6bd0eb59eb | |||
| d8c4736594 | |||
| c1f03061c2 | |||
| 436284c558 | |||
| e239160a2e | |||
| f6a1b3d09f | |||
| 7ac18d1103 | |||
| ccfecd5e6d | |||
| ae9d57c834 | |||
| 5883fc142e | |||
| 546ca30a7e | |||
| e59dbe03e4 | |||
| 84dbfb8691 | |||
| 4f2649aff2 | |||
| 88e77449a7 | |||
| e1fdb5ca8e | |||
| 8fce10f5a0 | |||
| bc8afbc362 | |||
| e1cc540d74 | |||
| 3fdfd92c9e | |||
| 79b252f646 | |||
| 7e0c8dde93 | |||
| d4b1e03e1c | |||
| fd660da40f | |||
| 42d736784b | |||
| 7cf9f06736 | |||
| 5746a11c22 | |||
| 6385e6fce6 | |||
| 3dd516a955 | |||
| 9bbc653640 | |||
| 17ea005bce | |||
| e24b8c168f | |||
| 9713cd5ebe | |||
| ba02d63039 | |||
| 0936855766 | |||
| 1c2bfabeec | |||
| bb9b9849ee | |||
| 3578f38b45 | |||
| a1817bf566 | |||
| 853c5f1587 | |||
| 6c6939a473 | |||
| ff2ee59e78 | |||
| 871ca6b6aa | |||
| a3151b7809 | |||
| 476f1cf25b | |||
| 497415192b | |||
| ba868e465c | |||
| 52ace2863d | |||
| f1e8eaf40a | |||
| 27eba3c6db | |||
| 383b105bf5 | |||
| 1ed96b3e16 | |||
| d726d92427 | |||
| 125e9a7948 | |||
| 31d3ea2c78 | |||
| 7011836ccb | |||
| 563b9970ae | |||
| 67a9d5ab96 | |||
| f3b94b5726 | |||
| 714e70bcef | |||
| 0b2e5edf34 | |||
| 4bf2c51b93 | |||
| 515741bb42 | |||
| cedf4ae5c4 | |||
| e3dc28d0bd | |||
| 60ab5be3eb | |||
| a299377fd7 | |||
| abf668c5c8 | |||
| 5a4ccbcbe8 | |||
| 4c24ea28df | |||
| 8706e21db7 | |||
| 9bdf0f4875 | |||
| 12ac53dfa2 | |||
| f3e79378f0 | |||
| 071bf1618c | |||
| 9cc4465b6a | |||
| 89fd9d0e42 | |||
| c3924163fb | |||
| 30af7a80d9 | |||
| 298b900c5a | |||
| aad48de6f6 | |||
| 7c3a246759 | |||
| ec54cda394 | |||
| f4602b4aa5 | |||
| 6a9df652ff | |||
| 6192d395e4 | |||
| 3ecb0134bd | |||
| 7fdf0ba971 | |||
| 4665c537e8 | |||
| c7d61a6adc | |||
| 705608b5ad | |||
| 99b758a4f4 | |||
| 7a9fef3785 | |||
| f5482f415c | |||
| 11822e3803 | |||
| 77e98afaa6 | |||
| 963379c3d9 | |||
| 596371e977 | |||
| 527f628a21 | |||
| 33462bf52e | |||
| c76038d076 | |||
| 970648b3fd | |||
| 866bf1765e | |||
| 86d8e25cb4 | |||
| ccb2efe339 | |||
| a195611d85 | |||
| 378cfba406 | |||
| d170c886bc | |||
| 0da70af053 | |||
| cfe94d9178 | |||
| fb4e711b4a | |||
| 0539951d6b | |||
| 0a641ba44f | |||
| 4a64d6a7e1 | |||
| 390cc98f94 | |||
| 298cbb3502 | |||
| 31435b4b98 | |||
| a296a499d9 | |||
| 3fde7f1dd5 | |||
| a2f6714440 | |||
| 1154c9752b | |||
| 146501bae9 | |||
| ce314034b4 | |||
| 6319230ab8 |
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: normative-sync
|
||||
description: |
|
||||
Apply 4-file normative sync (Pravila/PSR_v1/Tooling/CLAUDE.md) after a
|
||||
completed task in the Лидерра CRM project. Use when an integration epic
|
||||
closed (off-phase tooling, brain governance artefact, accepted ADR) and
|
||||
the four normative documents need synchronized version bumps, §0 cross-refs,
|
||||
footer counters, and §9 changelog entries. Does NOT commit. Does NOT touch
|
||||
code/schema/migrations. Escalates on parallel-branch version collisions
|
||||
or major-vs-minor ambiguity.
|
||||
tools: Read, Edit, Grep, Glob, Bash, TodoWrite
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Normative-sync agent — Лидерра
|
||||
|
||||
You are the normative-sync agent for the Лидерра CRM project. Your single job is to apply synchronized edits to four normative documents after a completed task, based on a one-line brief from the main controller.
|
||||
|
||||
You DO NOT commit. You DO NOT push. You DO NOT touch code, schema, migrations, ADRs, or the automation map. You DO NOT make architectural decisions — if the brief is ambiguous about major-vs-minor bump or about which structural changes belong, escalate to the main controller.
|
||||
|
||||
## Контекст проекта
|
||||
|
||||
Лидерра — Vue 3 + Laravel 13 CRM с многоуровневой системой правил. Четыре нормативных документа должны двигаться синхронно при изменении правил, добавлении инструментов или появлении governance-артефактов.
|
||||
|
||||
### Четыре файла и где у них шапка / cross-refs / footer / changelog
|
||||
|
||||
| Файл | Шапка с версией | §0 cross-refs | Footer-счётчик | Changelog |
|
||||
|------|-----------------|---------------|----------------|-----------|
|
||||
| `docs/Pravila_raboty_Claude_v1_1.md` | Шапка под `# Правила работы Claude` (версия v1.X + дата) | Шапка ссылается на свежие версии CLAUDE.md/PSR_v1/Tooling | Нет числовых счётчиков; §13 содержит N правил | «История версий» в самом конце файла |
|
||||
| `docs/Plugin_stack_rules_v1.md` | Шапка под `# Правила совместного использования плагинов Claude` (vX.Y + дата) | Шапка содержит cross-refs (Pravila/CLAUDE.md/Tooling versions) | R10.1 Блок 1/Блок 3 — таблица позиций; нет суммарного числового счётчика (тот канон в Tooling) | «История версий» в самом конце |
|
||||
| `docs/Tooling_v8_3.md` | Прил. Н v2.X шапка | §0 содержит cross-refs Pravila/PSR/CLAUDE.md | **§0 «КАНОН СЧЁТЧИКОВ»** — единственный источник правды для чисел инструментов (CLAUDE.md/Pravila/PSR_v1 пинуют, не дублируют) | §13 «История версий» (или §10 в зависимости от ветки) |
|
||||
| `CLAUDE.md` (корень репо) | Шапка `**Версия:** vY.YY от ДД.ММ.ГГГГ` | §0 «Источник истины» — таблица с версиями всех остальных | §3.3 footer-индекс / §1 priority chain row 2b / §3 title (числовые отсылки — пинуются на Tooling §0) | §9 «История версий» — пользовательский changelog |
|
||||
|
||||
### Канонические правила счётчиков
|
||||
|
||||
Числа узлов / off-phase подкатегорий живут **только** в Tooling Прил. Н §0 (anchor «КАНОН СЧЁТЧИКОВ»). Остальные файлы (CLAUDE.md / Pravila / PSR_v1) пинуют, не дублируют. Если в эпизоде добавился узел — правится только Tooling §0, остальные файлы получают ссылочный апдейт без числа.
|
||||
|
||||
### Правила version-bump
|
||||
|
||||
| Тип изменения | Bump | Пример |
|
||||
|---------------|------|--------|
|
||||
| Добавили узел / cross-ref / методический параграф / запись в changelog | **minor** (+0.01) | v2.26 → v2.27 |
|
||||
| Удалили правило / архитектурная инверсия / снят hard-rule | **major** (+1.0) | v1.7 → v2.0 (R15 motion removal 12.05.2026) |
|
||||
|
||||
По умолчанию minor. Major — только при явном указании в brief'е («сняли правило X», «архитектурное переустройство Y») или при удалении секции/правила из файла.
|
||||
|
||||
### Pravila §15 hard-rule (parallel sessions)
|
||||
|
||||
8 файлов, по которым обязателен pre-flight `git fetch && git log HEAD..origin/main --oneline`:
|
||||
|
||||
1. `docs/Pravila_raboty_Claude_v1_1.md`
|
||||
2. `CLAUDE.md`
|
||||
3. `docs/Tooling_v8_3.md`
|
||||
4. `docs/Plugin_stack_rules_v1.md`
|
||||
5. `memory/MEMORY.md` (этот файл агент не трогает)
|
||||
6. `docs/Открытые_вопросы_v8_3.md` (этот файл агент не трогает)
|
||||
7. `docs/adr/*` (этот файл агент не трогает)
|
||||
8. `db/schema.sql` (этот файл агент не трогает)
|
||||
|
||||
Если pre-flight нашёл unpushed коммиты, затрагивающие файлы 1-4 — STOP, эскалация. Файлы 5-8 — информативно, агент их не правит, но докладывает о коллизии.
|
||||
|
||||
### CLAUDE.md §5 п.10 — worktree-эксцепшн
|
||||
|
||||
Прямой `Edit` к `CLAUDE.md` разрешён ТОЛЬКО когда исполнение идёт в worktree (а не в основной checkout). Если это основная ветка / основной checkout — обязательно через `claude-md-management:claude-md-improver` skill. Проверка: `git rev-parse --show-toplevel` совпадает с основным checkout (определяется по отсутствию `worktree` слова в выводе `git worktree list | head -1`).
|
||||
|
||||
### Стиль §9 changelog-записи
|
||||
|
||||
Шаблон последних записей (из CLAUDE.md §9):
|
||||
|
||||
```
|
||||
- **vX.Y от ДД.ММ.ГГГГ** — <одно-стилевое название темы>: <1-2 фразы о сути правки>. **§N cross-refs:** <изменения cross-refs>. **§K:** <структурные изменения секции K>. **§9 +this entry.** Header vP.P→**vX.Y**. **Узлы / Суть:** <что добавилось/убралось>. ADR-XXX (если есть). Через <канал — claude-md-management / прямой Edit + worktree-эксцепшн §5 п.10>.
|
||||
```
|
||||
|
||||
## Процедура (10 шагов — выполнять последовательно)
|
||||
|
||||
1. **Pre-flight** (Pravila §15.2): `git fetch && git log HEAD..origin/main --oneline`. Если есть коммиты по файлам 1-4 из 8-файлового списка — STOP, эскалация.
|
||||
|
||||
2. **Контекст эпизода:** `git log -n 5 --oneline` + если main контроллер дал refspec для diff — прочитать `git diff <refspec> --stat` (smell для scope).
|
||||
|
||||
3. **Чтение текущего состояния** четырёх файлов: шапка + §0 cross-refs + последняя запись в changelog. Не читать целиком — только релевантные секции (экономия токенов).
|
||||
|
||||
4. **Вычисление новых версий** по правилам выше. Если major-vs-minor неясно — STOP, эскалация.
|
||||
|
||||
5. **Шапки:** обновить дату + версию в каждом из 4 файлов через `Edit`.
|
||||
|
||||
6. **§0 cross-refs в CLAUDE.md:** обновить строки таблицы «Источник истины» — версии Pravila/PSR_v1/Tooling до новых.
|
||||
|
||||
7. **Footer-счётчики** (если в brief'е сказано «добавили узел»): обновить Tooling §0 канонический счётчик; синхронно пин-ссылки в CLAUDE.md §3.3 footer / §3 title / §1 row 2b (без числовой дублировки) и в PSR_v1 R10.1 (если в нём явная запись об инструменте).
|
||||
|
||||
8. **Changelog-записи** — добавить новую запись в начало (или в правильное место) §9 / История версий в каждом из 4 файлов. Стиль — см. шаблон выше. Брать темы из brief'а.
|
||||
|
||||
9. **Lefthook cross-ref-checker:** `lefthook run cross-ref-checker || npx lefthook run cross-ref-checker`. Если красный — посмотреть в выводе, какие cross-refs дрейфуют, поправить, повторить. Максимум 3 итерации; если после трёх всё ещё красный — STOP, эскалация.
|
||||
|
||||
10. **Итоговый рапорт** (см. формат ниже). НЕ КОММИТИТЬ.
|
||||
|
||||
## Output format
|
||||
|
||||
В конце работы вернуть один рапорт ровно такого формата:
|
||||
|
||||
```
|
||||
=== NORMATIVE-SYNC RAPORT ===
|
||||
Тема эпизода: <из brief'а>
|
||||
Версии:
|
||||
- Pravila: vX.Y → vX.Z
|
||||
- PSR_v1: vX.Y → vX.Z
|
||||
- Tooling: vX.Y → vX.Z (Прил. Н)
|
||||
- CLAUDE.md: vX.YY → vX.ZZ
|
||||
Cross-refs verified: <yes | no>
|
||||
Lefthook cross-ref-checker (C2): <green | red after N iterations>
|
||||
§9-changelog: добавлены в N/4 файлов
|
||||
Footer-счётчики: <не менялись | Tooling §0 N → M>
|
||||
Файлы в рабочем дереве (uncommitted):
|
||||
- docs/Pravila_raboty_Claude_v1_1.md
|
||||
- docs/Plugin_stack_rules_v1.md
|
||||
- docs/Tooling_v8_3.md
|
||||
- CLAUDE.md
|
||||
Эскалации: <нет | <список>>
|
||||
=== END RAPORT ===
|
||||
```
|
||||
|
||||
## Boundaries (что НЕ делать)
|
||||
|
||||
- НЕ коммитить, НЕ пушить (только готовить diff в рабочем дереве)
|
||||
- НЕ править код, миграции, схему БД, конфиги Laravel/Vue
|
||||
- НЕ писать новые ADR (только цитировать уже принятые)
|
||||
- НЕ править `docs/automation-graph.html` (карта инструментов — отдельная задача)
|
||||
- НЕ править `MEMORY.md`, `Открытые_вопросы_v8_3.md`, `db/schema.sql`
|
||||
- НЕ принимать решения о major bump без явного указания в brief'е
|
||||
- НЕ добавлять «improvements» в несвязанные секции — только указанные шапки, §0, footer, changelog
|
||||
|
||||
## Escalation triggers
|
||||
|
||||
Остановиться и вернуть рапорт «требуется человек» если:
|
||||
|
||||
- Pre-flight нашёл unpushed коммиты с правкой одного из 4 файлов от параллельной сессии
|
||||
- Brief неясен: minor или major bump
|
||||
- Cross-ref-checker красный после 3 итераций
|
||||
- Brief упоминает изменения вне scope (новый ADR, правка схемы, правка карты) — отдельная задача
|
||||
- Обнаружен дрейф в счётчиках Tooling §0, который не объясняется brief'ом (значит, кто-то ещё правил)
|
||||
|
||||
## Известные эпизоды-прецеденты (для понимания стиля)
|
||||
|
||||
- CLAUDE.md v2.26 → v2.27 (22.05.2026, C1 marketing): добавили 10 узлов #74-#83, 18-я off-phase подкатегория marketing-tooling, ADR-015. Все 4 файла bumped + §9-записи. Cross-refs обновлены.
|
||||
- CLAUDE.md v2.24 → v2.25 (21.05.2026, ZAP+Ward install): сняли PENDING INSTALL на 2 узлах #68/#70. Tooling §4.43/§4.45 dormant→false. Чисто статусная правка без новых счётчиков.
|
||||
- CLAUDE.md v1.87 → v1.88 (12.05.2026, R15 motion removal): **major bump** в PSR_v1 (v1.7 → v2.0), потому что удалили целое правило R15. Пример редкого major.
|
||||
@@ -0,0 +1,219 @@
|
||||
---
|
||||
name: prod-deploy-validator
|
||||
description: |
|
||||
Pre-flight 8-check validator before deploying to liderra.ru production.
|
||||
Use BEFORE every prod deploy — main controller asks "проверь готовность боевого"
|
||||
or "ready to deploy?". Returns GO / NO-GO verdict with concrete reason and
|
||||
pointer to the relevant quirk (104-108). Does NOT deploy. Does NOT modify
|
||||
prod state. READ-ONLY by design. Driven by 24.05.2026 03:46 UTC live incident
|
||||
(portal down 18 min due to config:cache running as root, quirk 107).
|
||||
tools: Bash, Read, Grep
|
||||
model: sonnet
|
||||
---
|
||||
|
||||
# Prod-deploy-validator agent — Лидерра liderra.ru
|
||||
|
||||
You are the pre-flight validator before any deploy to the Лидерра CRM production server (`liderra.ru`). You run a fixed checklist of 8 read-only SSH checks and return a single verdict: **GO** or **NO-GO**.
|
||||
|
||||
You DO NOT deploy. You DO NOT modify production. You DO NOT execute migrations or restart services. You are READ-ONLY by design.
|
||||
|
||||
If any check returns unexpected output (not matching the documented patterns), the verdict is **NO-GO with escalation** — never guess.
|
||||
|
||||
## Контекст: 24.05.2026 03:46 UTC live-incident
|
||||
|
||||
В ночь на 24.05.2026 портал лёг на 18 минут. Корень — `php artisan config:cache` был запущен из-под пользователя `root`, а не `www-data`. Cache-файл `bootstrap/cache/config.php` получил владельца `root`, и веб-процесс под `www-data` не смог его перечитать → Laravel выпал на defaults (APP_KEY=NULL, DB=sqlite) → HTTP 500 на всех маршрутах.
|
||||
|
||||
Этот checklist — прямая защита от повторения. **П1 — самая важная проверка.**
|
||||
|
||||
## Квирки производственного окружения liderra.ru (память агента)
|
||||
|
||||
### Квирк 104 — stale `bootstrap/cache/config.php` переживает .env-фикс
|
||||
|
||||
Symptom: правишь `.env`, перезапускаешь PHP-FPM, портал всё равно ведёт себя как со старым `.env`. Cause: `bootstrap/cache/config.php` старше `.env`, Laravel читает из cache. Фикс: `php artisan config:clear && sudo -u www-data php artisan config:cache`.
|
||||
|
||||
### Квирк 105 — scp Windows→Linux кладёт CRLF в `.env`
|
||||
|
||||
Symptom: после `scp` файла с Windows на Linux появляются `\r\n` line endings в `.env`. Laravel парсит первую строку с `\r` хвостом → значение содержит `\r` → DB-имя или ключ не валиден → sqlite-fallback → 500. Фикс: `dos2unix /var/www/liderra/app/.env`.
|
||||
|
||||
### Квирк 106 — `queue:work --timeout` default 60s убивает worker сам себя
|
||||
|
||||
Symptom: `queue:work` стартует, через ~60 секунд процесс умирает с `SIGKILL`. Cause: default `--timeout=60` означает «убить если задача занимает >60 сек», но parent-loop тоже под этим контролем. Фикс: `--timeout=600` или `--max-jobs=100`.
|
||||
|
||||
### Квирк 107 — `config:cache` не из-под `www-data` → 500 на всём портале (24.05 живой инцидент)
|
||||
|
||||
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
|
||||
|
||||
Не релевантен боевому серверу, относится к dev-окружению Windows.
|
||||
|
||||
## 8 pre-flight проверок
|
||||
|
||||
Каждая проверка — это одна SSH-команда + ожидаемый формат вывода + критерий зелёного. Если вывод не совпадает с ожидаемым форматом — это автоматически NO-GO + эскалация.
|
||||
|
||||
### П1 — `bootstrap/cache/config.php` владелец и свежесть (Квирк 107, самый важный)
|
||||
|
||||
```bash
|
||||
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"
|
||||
```
|
||||
|
||||
Ожидаемый формат — 2 строки:
|
||||
|
||||
```
|
||||
www-data 1234567890
|
||||
1234567880
|
||||
```
|
||||
|
||||
Зелёный = (1) владелец `www-data` И (2) mtime config.php ≥ mtime .env.
|
||||
|
||||
Красный = владелец ≠ `www-data` ИЛИ mtime config.php < mtime .env ИЛИ файл config.php отсутствует. Цитировать квирк 107 в reason.
|
||||
|
||||
### П2 — `.env` line endings (квирк 105)
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo file /var/www/liderra/app/.env"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка — обычно `ASCII text` или `Unicode text, UTF-8 text` (UTF-8 нормально, если `.env` содержит кириллические комментарии или значения).
|
||||
|
||||
Зелёный = вывод НЕ содержит подстроку `CRLF line terminators`.
|
||||
|
||||
Красный = вывод содержит `CRLF`. Цитировать квирк 105.
|
||||
|
||||
NB: `ubuntu`-юзер не имеет read-прав на `.env` напрямую — `sudo` обязательно (sudo без пароля).
|
||||
|
||||
### П3 — Свободное место на диске
|
||||
|
||||
```bash
|
||||
ssh liderra "df -h / | tail -1"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка `/dev/... размер используется доступно %% маунт`.
|
||||
|
||||
Зелёный = использовано ≤ 85%.
|
||||
|
||||
Красный = > 85%. Reason: «диск %% занят, выкат может не уместиться».
|
||||
|
||||
### П4 — Свежесть последнего бэкапа БД
|
||||
|
||||
```bash
|
||||
ssh liderra "ls -lt /home/ubuntu/backups/ 2>/dev/null | head -2 | tail -1"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка `ls -l` (или пустая если каталог пуст).
|
||||
|
||||
Зелёный = mtime файла ≤ 24 часов назад. Распарсить дату из вывода и сравнить с текущим временем UTC.
|
||||
|
||||
Красный = бэкап старше 24 часов или каталог пуст. Reason: «бэкап несвежий, выкат с миграциями опасен».
|
||||
|
||||
### П5 — Health очереди
|
||||
|
||||
```bash
|
||||
ssh liderra "pgrep -fa queue:work; tail -50 /var/www/liderra/app/storage/logs/laravel.log | grep -ic -e failed -e error"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка процесса (от `pgrep`) + одна цифра (от `grep -c`).
|
||||
|
||||
Зелёный = есть `queue:work` процесс И цифра ≤ 5.
|
||||
|
||||
Красный = нет процесса ИЛИ цифра > 5. Reason соответственно.
|
||||
|
||||
### П6 — Nginx config syntax
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo nginx -t 2>&1"
|
||||
```
|
||||
|
||||
Ожидаемый формат: 2 строки — `nginx: the configuration file ... syntax is ok` + `nginx: configuration file ... test is successful`.
|
||||
|
||||
Зелёный = обе строки присутствуют.
|
||||
|
||||
Красный = любое иное. Reason: «nginx config сломан».
|
||||
|
||||
### П7 — fail2ban активен
|
||||
|
||||
```bash
|
||||
ssh liderra "sudo systemctl is-active fail2ban"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка — `active` ИЛИ `inactive` ИЛИ `failed`.
|
||||
|
||||
Зелёный = `active`.
|
||||
|
||||
Красный = иначе. Reason: «fail2ban не работает, выкат расширяет attack surface».
|
||||
|
||||
### П8 — Pending миграции
|
||||
|
||||
```bash
|
||||
ssh liderra "cd /var/www/liderra/app && php artisan migrate:status 2>&1 | grep -c Pending"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна цифра.
|
||||
|
||||
Зелёный = `0` ИЛИ количество совпадает с тем, что заявлено в brief'е (главный исполнитель сказал «к выкату пойдут N миграций»).
|
||||
|
||||
Красный = есть pending, не заявленные в brief'е. Reason: «N необъявленных миграций — какие?».
|
||||
|
||||
## Процедура (5 шагов)
|
||||
|
||||
1. Принять brief от главного исполнителя («готовлю выкат X — что в нём: миграции / только code / scp-патч»). Если brief не упомянул миграции — П8 ожидает 0.
|
||||
2. Прогнать 8 проверок последовательно (sequential, не parallel — упрощает отладку при сбоях SSH).
|
||||
3. Собрать результаты в таблицу из 8 строк (см. Output format).
|
||||
4. Применить решающее правило:
|
||||
- Все 8 зелёных → **GO** + список smoke-команд для пост-выкатной проверки
|
||||
- Хоть одна красная → **NO-GO** + причина + ссылка на квирк (если есть) + что нужно сделать
|
||||
- Любая «не смог проверить» (SSH timeout, неожиданный формат) → **NO-GO с эскалацией**
|
||||
5. Опционально (если в brief'е `--post-smoke`): после ответа главному исполнителю «выкат прошёл, запускай post-smoke» — повторить проверки + добавить HTTP 200 на главной (`curl -fsSL -o /dev/null -w '%{http_code}' https://liderra.ru/`).
|
||||
|
||||
## Output format
|
||||
|
||||
В конце работы вернуть один рапорт:
|
||||
|
||||
```
|
||||
=== PROD-DEPLOY-VALIDATOR RAPORT ===
|
||||
Brief: <из входных данных>
|
||||
Проверки:
|
||||
П1 config:cache владелец [GREEN / RED] — <вывод | причина>
|
||||
П2 .env line endings [GREEN / RED] — <вывод | причина>
|
||||
П3 свободное место [GREEN / RED] — <вывод | причина>
|
||||
П4 свежесть бэкапа БД [GREEN / RED] — <вывод | причина>
|
||||
П5 health очереди [GREEN / RED] — <вывод | причина>
|
||||
П6 nginx syntax [GREEN / RED] — <вывод | причина>
|
||||
П7 fail2ban active [GREEN / RED] — <вывод | причина>
|
||||
П8 pending миграции [GREEN / RED] — <вывод | причина>
|
||||
|
||||
Вердикт: GO / NO-GO
|
||||
|
||||
Если NO-GO — что делать:
|
||||
<конкретные команды для починки>
|
||||
<ссылка на квирк memory если применимо>
|
||||
|
||||
Если GO — smoke-команды для пост-выкатной проверки:
|
||||
- curl -fsSL -o /dev/null -w '%{http_code}\n' https://liderra.ru/
|
||||
- ssh liderra "cd /var/www/liderra/app && php artisan migrate:status | tail -20"
|
||||
- ssh liderra "tail -20 /var/www/liderra/app/storage/logs/laravel.log"
|
||||
=== END RAPORT ===
|
||||
```
|
||||
|
||||
## Boundaries (что НЕ делать)
|
||||
|
||||
- НЕ выкатывать (выкат — главный исполнитель)
|
||||
- НЕ менять конфиги на боевом
|
||||
- НЕ запускать миграции, не рестартить очереди, не править .env
|
||||
- НЕ угадывать: неожиданный output = NO-GO с эскалацией
|
||||
- НЕ цитировать пароли / ключи / токены если они случайно появились в выводе
|
||||
|
||||
## Escalation triggers
|
||||
|
||||
Вернуть NO-GO с пометкой «нужен человек» если:
|
||||
|
||||
- SSH-таймаут больше 30 сек (сеть лежит или сервер не отвечает)
|
||||
- 2+ проверки вернули неожиданный формат (не вписывается в документированный шаблон выше) — что-то системно изменилось, агент не должен угадывать
|
||||
- Brief сослался на проверку, которой нет в этом checklist'е (расширение checklist'а — отдельная задача)
|
||||
- Обнаружены файлы / процессы с подозрительными именами (возможный компромет) — критическая эскалация
|
||||
|
||||
## Прецеденты в проекте
|
||||
|
||||
- 24.05.2026 03:46 UTC — портал лежал 18 мин из-за квирка 107. Эта проверка (П1) — прямая защита.
|
||||
- 23.05.2026 — partition+RLS+log fix на боевом (push `7e0c8dde`). Сейчас бэкап-крон активен (П4).
|
||||
- 22.05.2026 — HTTPS + fail2ban + ModSecurity WAF активированы (см. memory `project_server_hardening.md`). П7 проверяет fail2ban.
|
||||
@@ -55,6 +55,16 @@
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-tool-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -86,6 +96,26 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-stop-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-prehook.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,33 @@ YYYY-MM-DD .. YYYY-MM-DD ({N} sessions)
|
||||
| node | times used | first / last |
|
||||
|---|---|---|
|
||||
|
||||
## Hook script breakdown (from `hook_fired.scripts`, schema v3+)
|
||||
|
||||
Per-script counts across the period. Surfaces which discipline-enforcing hooks fired (and which silently failed to fire). Aggregate from `events[].hook_fired.scripts` of v3 episodes — v2 episodes have only matcher-level `counts` and contribute nothing here.
|
||||
|
||||
| script | times fired | notes |
|
||||
|---|---|---|
|
||||
| `tools/observer-stop-hook.mjs` | N | should fire once per turn — gaps = observer drop |
|
||||
| `tools/subagent-prompt-prefix.mjs` | N | once per Task-tool call |
|
||||
| `inline:<sha-16>` | N | inline `node -e "..."` — see settings.json for body |
|
||||
|
||||
**Discipline highlights:**
|
||||
|
||||
- `tools/observer-stop-hook.mjs` count < turn count → observer skipped turns; cross-check `observerErrorCount` and STATUS.md C5.
|
||||
- `tools/subagent-prompt-prefix.mjs` count vs `Agent` tool_use count — mismatch = missing pre-flight injection.
|
||||
- Inline `claude-md`/`schema.sql` guards — fired iff someone touched those files.
|
||||
|
||||
## Recommended-node candidates (from `primary_rationale.recommended_node`, schema v3+)
|
||||
|
||||
Distinct from `missedActivations` (which aggregates): this is the per-episode signal embedded in each direct episode.
|
||||
|
||||
| recommended_node | times direct | top classifications |
|
||||
|---|---|---|
|
||||
| #19 | N | feature, planning |
|
||||
| none (v2 or no recommendation) | N | — |
|
||||
|
||||
Cross-reference with `factorMatrix.recommended_node_for_direct` and `missedActivations.byNode`. A persistent (#NN, count > threshold) — strong missed-activation pattern, candidate for retro discussion.
|
||||
|
||||
## Factor analysis matrix (v2 — from `tools/brain-retro-analyzer.mjs`)
|
||||
|
||||
Outcome distribution per factor value. Source: the analyzer’s `factorMatrix`.
|
||||
@@ -81,6 +108,8 @@ Surface candidates where a profile-classified task ran with `node_chosen === 'di
|
||||
|
||||
**NOT to be auto-applied:** these are candidates for human review in retro, not commits or hook blocks.
|
||||
|
||||
**Schema v3 NB:** since 2026-05-23, each direct episode carries `primary_rationale.recommended_node` directly. The analyzer's `missedActivations` aggregates these into `byNode`/`byClassification`. For per-episode forensics (which prompt, which session), grep episodes-*.jsonl on `"recommended_node":"#NN"`.
|
||||
|
||||
## Episodes → tasks (from analyzer `tasks`)
|
||||
|
||||
| task_ref | episodes | turns that are rework |
|
||||
|
||||
@@ -24,3 +24,18 @@ f696ca50266eb1c2974b5fc89f6fa585edaf4b6b:docs/security/nuclei-setup.md:curl-auth
|
||||
# 2026-05-22 — nuclei-setup.md curl-auth-user тот же FP что и раньше (f696ca5),
|
||||
# но коммит другой (05437ba) — параллельная сессия пере-коммитила тот же файл.
|
||||
05437ba79a26a7a7bbbe0ffb2f2573c432a9a4d1:docs/security/nuclei-setup.md:curl-auth-user:27
|
||||
|
||||
# 2026-05-23 — ru-phone-unmasked в УЖЕ ЗАПУШЕННОЙ истории (origin/main a2f67144 + старее).
|
||||
# ПИЛОТ.md: "79135XXXXXX" — НЕ ПДн клиента, а телефон-style мусор, который поставщик
|
||||
# crm.bp-gr.ru кладёт в колонку названия проекта в CSV (документирован как пример
|
||||
# лог-спама csv_reconcile.unparseable_project_skipped). В рабочей копии замаскирован
|
||||
# 23.05; исторические коммиты приняты (rewrite 1305-коммитной запушенной истории ради
|
||||
# supplier-мусора не оправдан). episodes.jsonl: observer-логи (в рабочей копии чисто).
|
||||
a2f6714440c925e8ffdec8667373511dcce1b3aa:ПИЛОТ.md:ru-phone-unmasked:11
|
||||
1154c9752b61ba7b147a5725b471a5af7d61db56:ПИЛОТ.md:ru-phone-unmasked:11
|
||||
a2f6714440c925e8ffdec8667373511dcce1b3aa:ПИЛОТ.md:ru-phone-unmasked:31
|
||||
1154c9752b61ba7b147a5725b471a5af7d61db56:ПИЛОТ.md:ru-phone-unmasked:31
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:ПИЛОТ.md:ru-phone-unmasked:31
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:46
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:48
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:76
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Идемпотентная одноразовая миграция: balance_leads → balance_rub по цене ступени 1.
|
||||
*
|
||||
* Запускается ОДИН РАЗ в проде после деплоя Billing v2 Spec A Phase A. Повторный
|
||||
* запуск — no-op (тенанты с balance_leads=0 уже не обрабатываются).
|
||||
*
|
||||
* Per-tenant атомарность: lockForUpdate(Tenant) внутри DB::transaction.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md §4.4
|
||||
* Plan: docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md Task A.11
|
||||
*/
|
||||
final class BillingMigrateLeadsToRubCommand extends Command
|
||||
{
|
||||
protected $signature = 'billing:migrate-leads-to-rub';
|
||||
|
||||
protected $description = 'Convert legacy balance_leads to balance_rub at tier 1 price (idempotent, run once in prod)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$tier1 = PricingTier::query()
|
||||
->where('is_active', true)
|
||||
->where('tier_no', 1)
|
||||
->where('effective_from', '<=', Carbon::now('Europe/Moscow')->toDateString())
|
||||
->orderBy('effective_from', 'desc')
|
||||
->first();
|
||||
|
||||
if ($tier1 === null) {
|
||||
$this->error('No active tier 1 found. Aborting.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
|
||||
Tenant::query()
|
||||
->where('balance_leads', '>', 0)
|
||||
->chunkById(100, function ($tenants) use ($tier1, &$count): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
DB::transaction(function () use ($tenant, $tier1, &$count): void {
|
||||
/** @var Tenant|null $locked */
|
||||
$locked = Tenant::query()
|
||||
->whereKey($tenant->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($locked === null || (int) $locked->balance_leads <= 0) {
|
||||
return; // idempotency — already migrated or zero
|
||||
}
|
||||
|
||||
$migratedLeads = (int) $locked->balance_leads;
|
||||
$migratedKopecks = bcmul((string) $migratedLeads, (string) $tier1->price_per_lead_kopecks, 0);
|
||||
$migratedRub = bcdiv((string) $migratedKopecks, '100', 2);
|
||||
$newBalanceRub = bcadd((string) $locked->balance_rub, $migratedRub, 2);
|
||||
|
||||
DB::table('tenants')
|
||||
->where('id', $locked->id)
|
||||
->update([
|
||||
'balance_rub' => $newBalanceRub,
|
||||
'balance_leads' => 0,
|
||||
]);
|
||||
|
||||
BalanceTransaction::create([
|
||||
'tenant_id' => $locked->id,
|
||||
'type' => BalanceTransaction::TYPE_MIGRATION,
|
||||
'amount_leads' => -$migratedLeads,
|
||||
'amount_rub' => $migratedRub,
|
||||
'balance_leads_after' => 0,
|
||||
'balance_rub_after' => $newBalanceRub,
|
||||
'description' => 'Конвертация предоплаченных лидов в ₽ (миграция модели биллинга Spec A)',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$count++;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Migrated {$count} tenant(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -4,39 +4,70 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\IncidentDetectedMail;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Сканирует failed_webhook_jobs за скользящее окно и автоматически создаёт
|
||||
* incidents_log, когда кластер падений превышает заданный порог.
|
||||
* Сканирует failed_webhook_jobs и failed_jobs за скользящее окно.
|
||||
*
|
||||
* Запускается каждые 10 минут через Schedule (routes/console.php).
|
||||
* Дедупликация: если открытый инцидент с такой же сигнатурой создан менее
|
||||
* --dedup-window минут назад, новая запись не создаётся.
|
||||
* failed_webhook_jobs: одно правило — spike ≥ threshold (200).
|
||||
* failed_jobs: три правила:
|
||||
* - spike: кол-во за окно одного job-класса ≥ threshold-spike (10) → high
|
||||
* - daily-total: за 24ч одного job-класса ≥ threshold-daily (50) → medium
|
||||
* - persistent: один exception повторяется > persistent-hours часов → medium
|
||||
*
|
||||
* Дедуп: если открытый инцидент с той же сигнатурой создан < dedup-window мин —
|
||||
* пропускаем. Письмо на kdv1@bk.ru только для severity=high.
|
||||
*/
|
||||
class IncidentsWatchFailures extends Command
|
||||
{
|
||||
protected $signature = 'incidents:watch-failures
|
||||
{--window=10 : Окно сканирования в минутах}
|
||||
{--threshold=200 : Порог числа падений за окно}
|
||||
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}';
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
protected $description = 'Сканирует failed_webhook_jobs за окно и создаёт incidents_log на превышение порога';
|
||||
protected $signature = 'incidents:watch-failures
|
||||
{--window=10 : Окно сканирования в минутах}
|
||||
{--threshold=200 : Порог спайка для failed_webhook_jobs}
|
||||
{--threshold-spike=10 : Порог спайка для failed_jobs (за окно)}
|
||||
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
|
||||
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
|
||||
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}';
|
||||
|
||||
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$windowMinutes = (int) $this->option('window');
|
||||
$threshold = (int) $this->option('threshold');
|
||||
$thresholdSpike = (int) $this->option('threshold-spike');
|
||||
$thresholdDaily = (int) $this->option('threshold-daily');
|
||||
$persistentHours = (int) $this->option('persistent-hours');
|
||||
$dedupMinutes = (int) $this->option('dedup-window');
|
||||
|
||||
$since = Carbon::now()->subMinutes($windowMinutes);
|
||||
$since24h = Carbon::now()->subHours(24);
|
||||
$dedupAt = Carbon::now()->subMinutes($dedupMinutes);
|
||||
$now = Carbon::now();
|
||||
|
||||
// Группируем упавшие (ещё не resolved) джобы за окно по сигнатуре
|
||||
$groups = DB::table('failed_webhook_jobs')
|
||||
// --- Проверяем наличие SaaS-администратора (FK NOT NULL) ---
|
||||
$adminId = DB::connection(self::DB_CONNECTION)
|
||||
->table('saas_admin_users')
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->value('id');
|
||||
|
||||
if ($adminId === null) {
|
||||
$this->warn('No active saas_admin_users found — skipping incident creation (warn-only).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
|
||||
// ===== БЛОК 1: failed_webhook_jobs (исходная логика) =====
|
||||
$webhookGroups = DB::connection(self::DB_CONNECTION)
|
||||
->table('failed_webhook_jobs')
|
||||
->selectRaw('LEFT(exception, 180) AS sig, COUNT(*) AS cnt')
|
||||
->whereNull('resolved_at')
|
||||
->where('failed_at', '>=', $since)
|
||||
@@ -44,63 +75,156 @@ class IncidentsWatchFailures extends Command
|
||||
->havingRaw('COUNT(*) >= ?', [$threshold])
|
||||
->get();
|
||||
|
||||
if ($groups->isEmpty()) {
|
||||
$this->info('No failure spikes detected.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Получаем ID первого доступного SaaS-администратора (для NOT NULL FK)
|
||||
$adminId = DB::table('saas_admin_users')
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->value('id');
|
||||
|
||||
if ($adminId === null) {
|
||||
$this->error('No active saas_admin_users found — cannot create incidents_log rows.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$created = 0;
|
||||
|
||||
foreach ($groups as $group) {
|
||||
foreach ($webhookGroups as $group) {
|
||||
$sig = $group->sig;
|
||||
$count = (int) $group->cnt;
|
||||
$dedupKey = substr($sig, 0, 80);
|
||||
|
||||
// Дедупликация: есть ли уже открытый инцидент с такой сигнатурой?
|
||||
$alreadyOpen = DB::table('incidents_log')
|
||||
->where('summary', 'like', '%'.addcslashes(substr($sig, 0, 80), '%_\\').'%')
|
||||
->whereNull('resolved_at')
|
||||
->where('detected_at', '>=', $dedupAt)
|
||||
->exists();
|
||||
|
||||
if ($alreadyOpen) {
|
||||
$this->line("Skipping (dedup): {$sig}");
|
||||
if ($this->isDup($dedupKey, $dedupAt)) {
|
||||
$this->line("Skipping webhook (dedup): {$dedupKey}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('incidents_log')->insert([
|
||||
'type' => 'other',
|
||||
'severity' => 'high',
|
||||
'summary' => "Автоматически: {$count} упавших webhook-джобов за {$windowMinutes} мин. "
|
||||
."Сигнатура: {$sig}",
|
||||
'root_cause' => null,
|
||||
'started_at' => $since,
|
||||
'detected_at' => $now,
|
||||
'resolved_at' => null,
|
||||
'created_by_admin_id' => $adminId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$summary = "Автоматически: {$count} упавших webhook-джобов за {$windowMinutes} мин. Сигнатура: {$sig}";
|
||||
|
||||
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey);
|
||||
$created++;
|
||||
$this->info("Incident created: [{$count} failures] {$sig}");
|
||||
$this->info("Webhook incident [high]: {$count} failures");
|
||||
}
|
||||
|
||||
// ===== БЛОК 2: failed_jobs — spike =====
|
||||
$spikes = DB::connection(self::DB_CONNECTION)
|
||||
->table('failed_jobs')
|
||||
->selectRaw(
|
||||
"payload::json->>'displayName' AS job_class, ".
|
||||
'LEFT(exception, 80) AS exc_sig, '.
|
||||
'COUNT(*) AS cnt'
|
||||
)
|
||||
->where('failed_at', '>=', $since)
|
||||
->groupByRaw("payload::json->>'displayName', LEFT(exception, 80)")
|
||||
->havingRaw('COUNT(*) >= ?', [$thresholdSpike])
|
||||
->get();
|
||||
|
||||
foreach ($spikes as $row) {
|
||||
$jobClass = (string) $row->job_class;
|
||||
$excSig = (string) $row->exc_sig;
|
||||
$cnt = (int) $row->cnt;
|
||||
$dedupKey = "spike:{$jobClass}:{$excSig}";
|
||||
|
||||
if ($this->isDup($dedupKey, $dedupAt)) {
|
||||
$this->line("Skipping spike (dedup): {$dedupKey}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = "Автоматически: spike {$cnt} failures job={$jobClass} за {$windowMinutes} мин. Exc: {$excSig}";
|
||||
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey);
|
||||
$created++;
|
||||
$this->info("Job spike [high]: {$jobClass} — {$cnt}");
|
||||
}
|
||||
|
||||
// ===== БЛОК 3: failed_jobs — daily-total =====
|
||||
$daily = DB::connection(self::DB_CONNECTION)
|
||||
->table('failed_jobs')
|
||||
->selectRaw(
|
||||
"payload::json->>'displayName' AS job_class, ".
|
||||
'COUNT(*) AS cnt'
|
||||
)
|
||||
->where('failed_at', '>=', $since24h)
|
||||
->groupByRaw("payload::json->>'displayName'")
|
||||
->havingRaw('COUNT(*) >= ?', [$thresholdDaily])
|
||||
->get();
|
||||
|
||||
foreach ($daily as $row) {
|
||||
$jobClass = (string) $row->job_class;
|
||||
$cnt = (int) $row->cnt;
|
||||
$dedupKey = "daily:{$jobClass}";
|
||||
|
||||
if ($this->isDup($dedupKey, $dedupAt)) {
|
||||
$this->line("Skipping daily (dedup): {$dedupKey}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = "Автоматически: daily-total {$cnt} failures job={$jobClass} за 24ч";
|
||||
$this->createIncident($adminId, 'other', 'medium', $summary, $since24h, $now, $dedupKey);
|
||||
$created++;
|
||||
$this->info("Job daily [medium]: {$jobClass} — {$cnt}");
|
||||
}
|
||||
|
||||
// ===== БЛОК 4: failed_jobs — persistent =====
|
||||
$persistentSince = Carbon::now()->subHours($persistentHours);
|
||||
|
||||
$persistent = DB::connection(self::DB_CONNECTION)
|
||||
->table('failed_jobs')
|
||||
->selectRaw(
|
||||
"payload::json->>'displayName' AS job_class, ".
|
||||
'LEFT(exception, 80) AS exc_sig, '.
|
||||
'MIN(failed_at) AS oldest_at, '.
|
||||
'COUNT(*) AS cnt'
|
||||
)
|
||||
->where('failed_at', '<=', $persistentSince)
|
||||
->groupByRaw("payload::json->>'displayName', LEFT(exception, 80)")
|
||||
->get();
|
||||
|
||||
foreach ($persistent as $row) {
|
||||
$jobClass = (string) $row->job_class;
|
||||
$excSig = (string) $row->exc_sig;
|
||||
$dedupKey = "persistent:{$jobClass}:{$excSig}";
|
||||
|
||||
if ($this->isDup($dedupKey, $dedupAt)) {
|
||||
$this->line("Skipping persistent (dedup): {$dedupKey}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = "Автоматически: persistent exception job={$jobClass} повторяется >{$persistentHours}ч. Exc: {$excSig}";
|
||||
$this->createIncident($adminId, 'other', 'medium', $summary, Carbon::parse($row->oldest_at), $now, $dedupKey);
|
||||
$created++;
|
||||
$this->info("Job persistent [medium]: {$jobClass}");
|
||||
}
|
||||
|
||||
$this->info("Done. Created {$created} incident(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function isDup(string $dedupKey, Carbon $dedupAt): bool
|
||||
{
|
||||
// Сигнатура сохраняется в root_cause для надёжного дедупа
|
||||
return DB::connection(self::DB_CONNECTION)
|
||||
->table('incidents_log')
|
||||
->where('root_cause', $dedupKey)
|
||||
->whereNull('resolved_at')
|
||||
->where('detected_at', '>=', $dedupAt)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function createIncident(
|
||||
int $adminId,
|
||||
string $type,
|
||||
string $severity,
|
||||
string $summary,
|
||||
Carbon $startedAt,
|
||||
Carbon $now,
|
||||
string $dedupKey = '',
|
||||
): void {
|
||||
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
|
||||
'type' => $type,
|
||||
'severity' => $severity,
|
||||
'summary' => $summary,
|
||||
'root_cause' => $dedupKey !== '' ? $dedupKey : null,
|
||||
'started_at' => $startedAt,
|
||||
'detected_at' => $now,
|
||||
'resolved_at' => null,
|
||||
'created_by_admin_id' => $adminId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
if ($severity === 'high') {
|
||||
Mail::to('kdv1@bk.ru')->send(new IncidentDetectedMail($summary, $severity));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,19 @@ use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Создаёт ежемесячные партиции для `deals` и `supplier_lead_costs`
|
||||
* Создаёт ежемесячные партиции для всех таблиц в MonthlyPartitionManager::PARTITIONED_TABLES
|
||||
* на N месяцев вперёд от текущей даты.
|
||||
*
|
||||
* Hole #2 (23.05.2026): расширен с 2 бизнес-таблиц до 9 (+ 7 audit-таблиц).
|
||||
*
|
||||
* Замена `pg_partman` на native Windows-стеке (расширение недоступно
|
||||
* без сборки из исходников). Запускается ежесуточно через Windows Task
|
||||
* Scheduler / cron — идемпотентна (CREATE TABLE IF NOT EXISTS).
|
||||
* Scheduler / cron — идемпотентна (проверяет pg_class перед CREATE).
|
||||
*
|
||||
* По дефолту 2 месяца вперёд (паритет с инициализацией schema.sql:
|
||||
* 6 партиций при `migrate:fresh`, последующие месяцы — этим cron'ом).
|
||||
*
|
||||
* Источник: db/schema.sql §5 (deals partition), §8.5 (supplier_lead_costs);
|
||||
* Источник: MonthlyPartitionManager::PARTITIONED_TABLES (единственный SoT списка таблиц);
|
||||
* project_phase1_strategy.md (pg_partman заменён ручным cron'ом).
|
||||
*/
|
||||
class PartitionsCreateMonths extends Command
|
||||
@@ -28,7 +30,7 @@ class PartitionsCreateMonths extends Command
|
||||
protected $signature = 'partitions:create-months {--ahead=2 : Сколько месяцев вперёд создать партиций}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Создаёт ежемесячные партиции deals и supplier_lead_costs на N месяцев вперёд (idempotent)';
|
||||
protected $description = 'Создаёт ежемесячные партиции для всех партиционированных таблиц на N месяцев вперёд (idempotent)';
|
||||
|
||||
public function handle(MonthlyPartitionManager $manager): int
|
||||
{
|
||||
@@ -41,8 +43,8 @@ class PartitionsCreateMonths extends Command
|
||||
for ($i = 0; $i <= $ahead; $i++) {
|
||||
$monthStart = $now->copy()->addMonths($i);
|
||||
|
||||
foreach (MonthlyPartitionManager::PARTITIONED_TABLES as $table) {
|
||||
$partitionName = sprintf('%s_%s', $table, $monthStart->format('Y_m'));
|
||||
foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) {
|
||||
$partitionName = $manager->partitionName($table, $monthStart);
|
||||
|
||||
if ($manager->ensureMonth($table, $monthStart)) {
|
||||
$created++;
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Удаляет устаревшие месячные партиции согласно retention-настройкам.
|
||||
*
|
||||
* Retention для каждой таблицы хранится в system_settings:
|
||||
* key = 'partition_retention_months_<table>'
|
||||
* value = количество месяцев (integer >= 1)
|
||||
*
|
||||
* Защита от опасных значений:
|
||||
* - NULL / отсутствие ключа → пропустить таблицу (не дропать ничего)
|
||||
* - 0 → пропустить (запрет стирания всего)
|
||||
* - < 0 → пропустить
|
||||
* - Минимальное значение, принятое к выполнению: 1 месяц
|
||||
*
|
||||
* Формат имени партиции: <table>_y<YYYY>_m<MM>
|
||||
* Партиция считается устаревшей, если её месяц < (текущий месяц − retention).
|
||||
*
|
||||
* Пример:
|
||||
* сейчас = 2026-05, retention = 3
|
||||
* cutoff = 2026-02 (включительно; т.е. 2026-01 и старее — дропать)
|
||||
* будет удалена: auth_log_y2026_m01, auth_log_y2025_m12, …
|
||||
* НЕ будет удалена: auth_log_y2026_m02 (граница), и всё новее
|
||||
*
|
||||
* Hole #2, 23.05.2026.
|
||||
*/
|
||||
class PartitionsDropExpired extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'partitions:drop-expired
|
||||
{--dry-run : Перечислить партиции для удаления, не удалять}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Удаляет устаревшие месячные партиции согласно system_settings (partition_retention_months_*)';
|
||||
|
||||
public function handle(MonthlyPartitionManager $manager): int
|
||||
{
|
||||
$isDryRun = (bool) $this->option('dry-run');
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->line('<fg=yellow>Dry-run: партиции будут перечислены, но NOT удалены.</>');
|
||||
}
|
||||
|
||||
$now = Carbon::now()->startOfMonth();
|
||||
$totalDropped = 0;
|
||||
$totalSkipped = 0;
|
||||
|
||||
foreach (array_keys(MonthlyPartitionManager::PARTITIONED_TABLES) as $table) {
|
||||
$retention = $this->resolveRetention($table);
|
||||
|
||||
if ($retention === null) {
|
||||
$this->line(" <fg=gray>skip</> {$table}: retention not configured");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$partitions = $manager->listPartitions($table);
|
||||
|
||||
if (empty($partitions)) {
|
||||
$this->line(" <fg=gray>skip</> {$table}: no partitions exist yet");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$dropped = 0;
|
||||
|
||||
foreach ($partitions as $partitionName) {
|
||||
$monthStart = $this->parsePartitionMonth($partitionName);
|
||||
|
||||
if ($monthStart === null) {
|
||||
// Имя не соответствует формату _yYYYY_mMM — не трогать (безопасность)
|
||||
$this->warn(" ? {$partitionName}: unrecognised name format, skipping");
|
||||
$totalSkipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Граница: всё строго старее (now - retention месяцев) — удалять.
|
||||
// Т.е. monthStart < cutoff, где cutoff = now - retention.
|
||||
$cutoff = $now->copy()->subMonths($retention);
|
||||
|
||||
if (! $monthStart->lessThan($cutoff)) {
|
||||
// Партиция ещё в пределах retention — оставить
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->line(" <fg=yellow>[dry-run] would drop</> {$partitionName}");
|
||||
} else {
|
||||
$this->dropPartition($partitionName);
|
||||
$this->line(" <fg=red>dropped</> {$partitionName}");
|
||||
}
|
||||
|
||||
$dropped++;
|
||||
$totalDropped++;
|
||||
}
|
||||
|
||||
if ($dropped === 0) {
|
||||
$this->line(" <fg=green>ok</> {$table}: all partitions within retention={$retention}mo");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
if ($isDryRun) {
|
||||
$this->info("Dry-run complete: {$totalDropped} would be dropped, {$totalSkipped} skipped (unrecognised name).");
|
||||
} else {
|
||||
$this->info("Done: {$totalDropped} dropped, {$totalSkipped} skipped (unrecognised name).");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает retention для таблицы из system_settings.
|
||||
* Возвращает null, если настройка отсутствует или небезопасна (0 / отрицательная).
|
||||
*/
|
||||
private function resolveRetention(string $table): ?int
|
||||
{
|
||||
$key = "partition_retention_months_{$table}";
|
||||
|
||||
$setting = SystemSetting::find($key);
|
||||
|
||||
if ($setting === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = (int) $setting->value;
|
||||
|
||||
if ($value < 1) {
|
||||
// 0 или отрицательное — блокируем, не дропаем ничего
|
||||
$this->warn(" ! {$table}: retention value={$value} is invalid (<1), skipping");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит имя партиции вида <anything>_y<YYYY>_m<MM> и возвращает Carbon начала месяца.
|
||||
* Возвращает null, если имя не соответствует формату.
|
||||
*/
|
||||
private function parsePartitionMonth(string $partitionName): ?Carbon
|
||||
{
|
||||
// Pattern: ends with _yYYYY_mMM (e.g. auth_log_y2026_m05)
|
||||
if (! preg_match('/_y(\d{4})_m(\d{2})$/', $partitionName, $m)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$year = (int) $m[1];
|
||||
$month = (int) $m[2];
|
||||
|
||||
if ($month < 1 || $month > 12) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Carbon::create($year, $month, 1, 0, 0, 0)->startOfMonth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет партицию (DETACH + DROP TABLE).
|
||||
*
|
||||
* Безопасность: имя проверено регекспом в parsePartitionMonth
|
||||
* (только символы \w и _ — SQL injection невозможен).
|
||||
*/
|
||||
private function dropPartition(string $partitionName): void
|
||||
{
|
||||
// DROP требует владения родителем — крутится через pgsql_supplier
|
||||
// (crm_supplier_worker — член владельца crm_migrator). См.
|
||||
// MonthlyPartitionManager::DDL_CONNECTION.
|
||||
DB::connection(MonthlyPartitionManager::DDL_CONNECTION)
|
||||
->statement("DROP TABLE IF EXISTS {$partitionName}");
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,13 @@ class RemindersDispatchDue extends Command
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$now = Carbon::now();
|
||||
|
||||
// Берём список pending-reminders. Без RLS — admin-flow на serverside.
|
||||
// Для каждой устанавливаем app.current_tenant_id внутри транзакции.
|
||||
$pending = Reminder::query()
|
||||
// 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)
|
||||
@@ -55,7 +59,7 @@ class RemindersDispatchDue extends Command
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($pending->isEmpty()) {
|
||||
if ($rows->isEmpty()) {
|
||||
$this->info('Нет due-reminders.');
|
||||
|
||||
return self::SUCCESS;
|
||||
@@ -64,22 +68,26 @@ class RemindersDispatchDue extends Command
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($pending as $reminder) {
|
||||
foreach ($rows as $row) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
' would dispatch <fg=yellow>id=%d</> tenant=%d deal=%d remind_at=%s',
|
||||
$reminder->id,
|
||||
$reminder->tenant_id,
|
||||
$reminder->deal_id,
|
||||
$reminder->remind_at?->toIso8601String() ?? '-',
|
||||
$row->id,
|
||||
$row->tenant_id,
|
||||
$row->deal_id,
|
||||
$row->remind_at ?? '-',
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($reminder, $service): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $reminder->tenant_id);
|
||||
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,
|
||||
@@ -87,10 +95,10 @@ class RemindersDispatchDue extends Command
|
||||
]);
|
||||
});
|
||||
$sent++;
|
||||
$this->info(" dispatched <fg=green>id={$reminder->id}</>");
|
||||
$this->info(" dispatched <fg=green>id={$row->id}</>");
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->error(" failed <fg=red>id={$reminder->id}</>: {$e->getMessage()}");
|
||||
$this->error(" failed <fg=red>id={$row->id}</>: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\ReportJob;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
@@ -43,7 +43,11 @@ class ReportsCleanupExpired extends Command
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = (int) $this->option('limit');
|
||||
|
||||
$jobs = ReportJob::query()
|
||||
// Cross-tenant gather via BYPASSRLS connection — crm_app_user on prod cannot
|
||||
// evaluate current_setting('app.current_tenant_id') without a GUC set.
|
||||
$rows = DB::connection('pgsql_supplier')
|
||||
->table('report_jobs')
|
||||
->select(['id', 'tenant_id', 'file_path', 'expires_at'])
|
||||
->where('status', ReportJob::STATUS_DONE)
|
||||
->whereNotNull('file_path')
|
||||
->where('expires_at', '<', Carbon::now())
|
||||
@@ -51,36 +55,45 @@ class ReportsCleanupExpired extends Command
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($jobs->isEmpty()) {
|
||||
if ($rows->isEmpty()) {
|
||||
$this->info('Нет expired report-files для удаления.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($jobs as $job) {
|
||||
foreach ($rows as $row) {
|
||||
$this->line(sprintf(
|
||||
'[%s] tenant=%d job=%d path=%s expired_at=%s',
|
||||
$dryRun ? 'DRY' : 'DEL',
|
||||
$job->tenant_id,
|
||||
$job->id,
|
||||
$job->file_path,
|
||||
$job->expires_at?->toIso8601String() ?? '?',
|
||||
$row->tenant_id,
|
||||
$row->id,
|
||||
$row->file_path,
|
||||
$row->expires_at ?? '?',
|
||||
));
|
||||
|
||||
if (! $dryRun) {
|
||||
Storage::disk('local')->delete($job->file_path);
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'deleted',
|
||||
subjectType: 'lead',
|
||||
subjectId: null,
|
||||
purpose: 'report_cleanup_expired_'.$job->id,
|
||||
tenantId: $job->tenant_id,
|
||||
actorTenantUserId: null,
|
||||
actorAdminUserId: null,
|
||||
ip: null,
|
||||
);
|
||||
$job->update(['file_path' => null]);
|
||||
Storage::disk('local')->delete($row->file_path);
|
||||
|
||||
// Both writes go through pgsql_supplier (BYPASSRLS) — this is a
|
||||
// SaaS-admin cron, not a per-user action, so no tenant GUC is
|
||||
// required. Same pattern as IncidentsWatchFailures, Reset*.
|
||||
DB::connection('pgsql_supplier')->table('pd_processing_log')->insert([
|
||||
'tenant_id' => $row->tenant_id,
|
||||
'subject_type' => 'lead',
|
||||
'subject_id' => null,
|
||||
'action' => 'deleted',
|
||||
'purpose' => 'report_cleanup_expired_'.$row->id,
|
||||
'actor_tenant_user_id' => null,
|
||||
'actor_admin_user_id' => null,
|
||||
'ip_address' => null,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
DB::connection('pgsql_supplier')
|
||||
->table('report_jobs')
|
||||
->where('id', $row->id)
|
||||
->update(['file_path' => null]);
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\SchedulerHeartbeatMissingMail;
|
||||
use App\Services\SchedulerHeartbeatTracker;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Hole #6: проверяет пульс всех зарегистрированных cron-задач.
|
||||
*
|
||||
* Критерии алерта (для каждой команды в scheduler_heartbeats):
|
||||
* 1. last_run_at IS NULL ИЛИ отсутствует > 2× ожидаемого интервала.
|
||||
* 2. consecutive_failures >= 3.
|
||||
*
|
||||
* При обнаружении:
|
||||
* • Создаёт инцидент в incidents_log (type=other, severity=high).
|
||||
* • Отправляет SchedulerHeartbeatMissingMail на kdv1@bk.ru.
|
||||
* • Дедупликация: не создаёт повторный инцидент если открытый уже есть
|
||||
* с той же командой в последние 60 минут.
|
||||
*
|
||||
* Запускается hourly через routes/console.php.
|
||||
*/
|
||||
final class SchedulerCheckHeartbeats extends Command
|
||||
{
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
private const ALERT_EMAIL = 'kdv1@bk.ru';
|
||||
|
||||
private const DEDUP_MINUTES = 60;
|
||||
|
||||
private const FAILURE_THRESHOLD = 3;
|
||||
|
||||
protected $signature = 'scheduler:check-heartbeats';
|
||||
|
||||
protected $description = 'Проверяет пульс cron-задач и алертит при пропавшем пульсе или повторных ошибках';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$intervals = SchedulerHeartbeatTracker::EXPECTED_INTERVALS;
|
||||
$db = DB::connection(self::DB_CONNECTION);
|
||||
$now = Carbon::now();
|
||||
$dedupAt = $now->copy()->subMinutes(self::DEDUP_MINUTES);
|
||||
|
||||
// Получаем adminId для FK incidents_log
|
||||
$adminId = $db->table('saas_admin_users')
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->value('id');
|
||||
|
||||
if ($adminId === null) {
|
||||
// Паттерн VerifyAuditChains (hole #1): warn + SUCCESS, не FAILURE.
|
||||
// FAILURE здесь = бесконечный цикл self-alert (consecutive_failures растёт,
|
||||
// watcher пытается алертить, снова FAILURE, инцидент не создаётся).
|
||||
$this->warn('No active saas_admin_users — alerts disabled (warn-only mode).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Загружаем все существующие heartbeats
|
||||
$rows = $db->table('scheduler_heartbeats')
|
||||
->get()
|
||||
->keyBy('command_name');
|
||||
|
||||
$alerted = 0;
|
||||
|
||||
foreach ($intervals as $commandName => $expectedMinutes) {
|
||||
$row = $rows->get($commandName);
|
||||
|
||||
// Проверка 1: пропавший пульс (нет строки вообще или last_run_at старше 2× интервала)
|
||||
$heartbeatMissing = false;
|
||||
if ($row === null) {
|
||||
$heartbeatMissing = true;
|
||||
$reason = "Команда '{$commandName}' не имеет ни одной записи heartbeat.";
|
||||
} elseif ($row->last_run_at === null) {
|
||||
$heartbeatMissing = true;
|
||||
$reason = "Команда '{$commandName}' никогда не запускалась.";
|
||||
} else {
|
||||
$lastRun = Carbon::parse($row->last_run_at);
|
||||
$ageMinutes = $lastRun->diffInMinutes($now);
|
||||
$threshold = $expectedMinutes * 2;
|
||||
|
||||
if ($ageMinutes > $threshold) {
|
||||
$heartbeatMissing = true;
|
||||
$reason = "Команда '{$commandName}' не запускалась {$ageMinutes} мин. "
|
||||
."(ожидаемый интервал: {$expectedMinutes} мин, порог: {$threshold} мин).";
|
||||
}
|
||||
}
|
||||
|
||||
// Проверка 2: consecutive_failures >= threshold
|
||||
$consecutiveFailures = $row !== null ? (int) $row->consecutive_failures : 0;
|
||||
$failureSpike = $consecutiveFailures >= self::FAILURE_THRESHOLD;
|
||||
|
||||
if (! $heartbeatMissing && ! $failureSpike) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($reason)) {
|
||||
$reason = "Команда '{$commandName}' завершилась с ошибкой {$consecutiveFailures} раз подряд.";
|
||||
} elseif ($failureSpike) {
|
||||
$reason .= " Плюс {$consecutiveFailures} последовательных ошибок.";
|
||||
}
|
||||
|
||||
$lastError = $row?->last_error;
|
||||
|
||||
// Дедупликация
|
||||
$summary = "Scheduler heartbeat: {$commandName} — {$reason}";
|
||||
$sigPrefix = substr("Scheduler heartbeat: {$commandName}", 0, 80);
|
||||
|
||||
$alreadyOpen = $db->table('incidents_log')
|
||||
->where('summary', 'like', '%'.addcslashes($sigPrefix, '%_\\').'%')
|
||||
->whereNull('resolved_at')
|
||||
->where('detected_at', '>=', $dedupAt)
|
||||
->exists();
|
||||
|
||||
if ($alreadyOpen) {
|
||||
$this->line("Dedup: {$commandName}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Создаём инцидент
|
||||
$db->table('incidents_log')->insert([
|
||||
'type' => 'other',
|
||||
'severity' => 'high',
|
||||
'summary' => $summary,
|
||||
'root_cause' => null,
|
||||
'started_at' => $now,
|
||||
'detected_at' => $now,
|
||||
'resolved_at' => null,
|
||||
'created_by_admin_id' => $adminId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Отправляем email
|
||||
Mail::to(self::ALERT_EMAIL)->send(
|
||||
new SchedulerHeartbeatMissingMail(
|
||||
commandName: $commandName,
|
||||
reason: $reason,
|
||||
lastError: $lastError,
|
||||
consecutiveFailures: $consecutiveFailures,
|
||||
)
|
||||
);
|
||||
|
||||
$this->warn("Alert: {$commandName} — {$reason}");
|
||||
$alerted++;
|
||||
unset($reason);
|
||||
}
|
||||
|
||||
$this->info("Done. {$alerted} alert(s) created.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\AuditChainBreachMail;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Проверяет целостность SHA-256 hash-chain во всех 6 audit-таблицах.
|
||||
*
|
||||
* Алгоритм на стороне PostgreSQL (не PHP) — чтобы воспроизвести ровно ту же
|
||||
* сериализацию ROW::text, что использует триггер audit_chain_hash():
|
||||
*
|
||||
* digest(COALESCE(prev_log_hash,''::bytea) || ROW(col1,...,NULL::bytea,...col_n)::text::bytea, 'sha256')
|
||||
*
|
||||
* где NULL::bytea — позиция log_hash (она была NULL в момент срабатывания
|
||||
* BEFORE INSERT триггера). Список столбцов в порядке их ordinal_position
|
||||
* из information_schema жёстко закодирован для каждой таблицы.
|
||||
*
|
||||
* ──────────────────────────────────────────────────────────────────────────────
|
||||
* ВАЖНО: per-partition scan (hole #2 adaptation).
|
||||
*
|
||||
* После перевода таблиц на RANGE-партиционирование (v8.31) каждая партиция
|
||||
* содержит строки одного месяца. Триггер audit_chain_hash() при INSERT в
|
||||
* партицию видит строки только ЭТОЙ партиции (TG_TABLE_NAME = partition name,
|
||||
* SELECT LAG по partition → prev — последняя запись той же партиции).
|
||||
*
|
||||
* Поэтому валидатор проверяет hash-chain отдельно для каждой партиции:
|
||||
* 1. Получает список партиций через pg_inherits + pg_class.
|
||||
* 2. Для каждой партиции выполняет checkPartition().
|
||||
* 3. Несоответствие в ЛЮБОЙ партиции → инцидент с указанием partition_name.
|
||||
*
|
||||
* Пустые партиции (без строк) — OK, chain пустая = intact.
|
||||
*
|
||||
* ──────────────────────────────────────────────────────────────────────────────
|
||||
* ВАЖНО: per-scope RLS partitioning.
|
||||
*
|
||||
* Триггер audit_chain_hash() делает:
|
||||
* SELECT log_hash FROM <table> ORDER BY id DESC LIMIT 1
|
||||
* Этот SELECT выполняется под ролью вставляющей сессии и подпадает под RLS.
|
||||
*
|
||||
* После партиционирования SELECT работает внутри текущей партиции — TG_TABLE_NAME.
|
||||
* RLS-scope воспроизводится так же, как до партиционирования, но область
|
||||
* видимости ограничена одной партицией → per-partition per-RLS-scope цепочка.
|
||||
*
|
||||
* Валидатор воспроизводит это через PARTITION BY RLS-scope ВНУТРИ каждой
|
||||
* partition-таблицы (те же partition_clause что раньше).
|
||||
*
|
||||
* ──────────────────────────────────────────────────────────────────────────────
|
||||
*
|
||||
* При разрыве: создаёт incidents_log (type='other', severity='high', через
|
||||
* pgsql_supplier BYPASSRLS), дедупликация 24ч, email на kdv1@bk.ru.
|
||||
* Возвращает self::FAILURE при ЛЮБОМ разрыве — независимо от успеха записи
|
||||
* инцидента (инцидент-запись best-effort, не влияет на exit code).
|
||||
*
|
||||
* Запускается daily 04:00 (routes/console.php).
|
||||
*
|
||||
* Ref: docs/superpowers/plans/2026-05-23-hole-1-hash-chain-validator.md
|
||||
* docs/superpowers/plans/2026-05-23-hole-2-audit-partitioning-plan.md §A.4
|
||||
* Паттерн: IncidentsWatchFailures + SharesSupplierPdo.
|
||||
*/
|
||||
class VerifyAuditChains extends Command
|
||||
{
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/**
|
||||
* Monitoring email для критичных алертов audit-целостности.
|
||||
*/
|
||||
private const MONITORING_EMAIL = 'kdv1@bk.ru';
|
||||
|
||||
/**
|
||||
* Дедупликация инцидентов: не создавать повторный инцидент по той же таблице
|
||||
* если прошло менее DEDUP_HOURS часов.
|
||||
*/
|
||||
private const DEDUP_HOURS = 24;
|
||||
|
||||
protected $signature = 'audit:verify-chains';
|
||||
|
||||
protected $description = 'Проверяет целостность SHA-256 hash-chain в 6 audit-таблицах (per-partition)';
|
||||
|
||||
/**
|
||||
* Конфигурация таблиц: имя таблицы → [columns, partition_clause].
|
||||
*
|
||||
* columns: список столбцов строго в порядке ordinal_position из db/schema.sql.
|
||||
* Специальное значение '__log_hash__' — маркер позиции log_hash → NULL::bytea.
|
||||
*
|
||||
* partition_clause: SQL-фрагмент для OVER (PARTITION BY … ORDER BY id),
|
||||
* воспроизводящий RLS-scope триггера внутри одной партиции.
|
||||
* Пустая строка = глобальная цепочка внутри партиции.
|
||||
*
|
||||
* @var array<string, array{columns: list<string>, partition: string}>
|
||||
*/
|
||||
private const TABLE_CONFIG = [
|
||||
// auth_log:
|
||||
// RLS: actor_type='tenant_user' AND tenant_id = current_setting(...)
|
||||
// Tenant-сессия видит только (actor_type='tenant_user', tenant_id=N).
|
||||
// saas_admin-сессия BYPASSRLS — видит всё.
|
||||
// Partition (actor_type, tenant_id) воспроизводит оба случая:
|
||||
// каждая пара образует независимую цепочку.
|
||||
'auth_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'actor_type',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'saas_admin_user_id',
|
||||
'email',
|
||||
'event',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'failure_reason',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
// global chain: auth_log пишется при ЛОГИНЕ под BYPASSRLS-роль
|
||||
// (tenant ещё не установлен — пользователь не аутентифицирован),
|
||||
// поэтому триггерный prev-SELECT видит ВСЕ строки → цепочка глобальная
|
||||
// внутри данной партиции (эмпирически подтверждено прод-smoke).
|
||||
'partition' => '',
|
||||
],
|
||||
|
||||
// activity_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'activity_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'deal_id',
|
||||
'event',
|
||||
'old_value',
|
||||
'new_value',
|
||||
'context',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// tenant_operations_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'tenant_operations_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'event',
|
||||
'payload_before',
|
||||
'payload_after',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// balance_transactions:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'balance_transactions' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'type',
|
||||
'amount_rub',
|
||||
'amount_leads',
|
||||
'balance_rub_after',
|
||||
'balance_leads_after',
|
||||
'description',
|
||||
'related_type',
|
||||
'related_id',
|
||||
'user_id',
|
||||
'admin_user_id',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// pd_processing_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'pd_processing_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'action',
|
||||
'purpose',
|
||||
'actor_tenant_user_id',
|
||||
'actor_admin_user_id',
|
||||
'ip_address',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// saas_admin_audit_log:
|
||||
// Нет RLS-политики для tenant-ролей (REVOKE ALL FROM crm_app_user).
|
||||
// Вставляет только crm_admin_user (BYPASSRLS) — триггер's SELECT
|
||||
// видит ВСЕ строки партиции → цепочка глобальная внутри партиции.
|
||||
// Partition: нет (пустая строка = ORDER BY id без PARTITION BY).
|
||||
'saas_admin_audit_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'admin_user_id',
|
||||
'action',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'target_tenant_id',
|
||||
'payload_before',
|
||||
'payload_after',
|
||||
'reason',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'requires_approval',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => '', // global chain within partition — inserting role is BYPASSRLS
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$anyBreach = false;
|
||||
$now = Carbon::now();
|
||||
|
||||
foreach (self::TABLE_CONFIG as $table => $config) {
|
||||
// Get all partitions for this table via pg_inherits.
|
||||
$partitions = $this->listPartitions($table);
|
||||
|
||||
if (empty($partitions)) {
|
||||
// Table not yet partitioned or no partitions — check parent directly (fallback).
|
||||
$partitions = [$table];
|
||||
}
|
||||
|
||||
foreach ($partitions as $partitionName) {
|
||||
$breaches = $this->checkPartition($partitionName, $config['columns'], $config['partition']);
|
||||
|
||||
if (empty($breaches)) {
|
||||
$this->line(" ✓ {$partitionName}: chain intact");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$anyBreach = true;
|
||||
$firstId = $breaches[0]->id;
|
||||
$count = count($breaches);
|
||||
|
||||
$this->error(" ✗ {$partitionName}: {$count} mismatch(es), first broken id={$firstId}");
|
||||
|
||||
// Incident write is best-effort: never let it suppress the breach signal.
|
||||
try {
|
||||
$this->recordIncident($table, $partitionName, $firstId, $count, $now);
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" Incident write failed for {$partitionName}: {$e->getMessage()}");
|
||||
}
|
||||
|
||||
$this->sendAlert($table, $partitionName, $firstId, $count);
|
||||
}
|
||||
}
|
||||
|
||||
// Exit FAILURE on ANY breach regardless of incident-write success.
|
||||
if ($anyBreach) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('All audit chains intact.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список дочерних партиций таблицы через pg_inherits.
|
||||
* Возвращает пустой массив если таблица не партиционирована или партиций нет.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function listPartitions(string $table): array
|
||||
{
|
||||
$rows = DB::connection(self::DB_CONNECTION)->select(
|
||||
'SELECT c.relname
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
JOIN pg_class p ON p.oid = i.inhparent
|
||||
WHERE p.relname = ?
|
||||
ORDER BY c.relname',
|
||||
[$table],
|
||||
);
|
||||
|
||||
return array_map(fn ($r) => $r->relname, $rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет hash-chain одной партиции (или таблицы) через SQL на стороне PostgreSQL.
|
||||
*
|
||||
* Возвращает список строк, у которых stored log_hash ≠ recomputed hash.
|
||||
*
|
||||
* SQL-логика:
|
||||
* 1. Берёт все строки партиции.
|
||||
* 2. Через LAG(log_hash) OVER (<partition> ORDER BY id) получает prev_hash
|
||||
* каждой строки в пределах её RLS-scope (partition).
|
||||
* 3. Пересчитывает: digest(COALESCE(prev_hash,''::bytea) || ROW(...)::text::bytea, 'sha256')
|
||||
* где ROW(...) имеет NULL::bytea на позиции log_hash.
|
||||
* 4. Возвращает строки, где stored IS DISTINCT FROM recomputed.
|
||||
*
|
||||
* @param list<string> $columns
|
||||
* @return list<object>
|
||||
*/
|
||||
private function checkPartition(string $partitionName, array $columns, string $partition): array
|
||||
{
|
||||
$rowExpr = $this->buildRowExpression($columns);
|
||||
|
||||
// Build OVER clause: with or without PARTITION BY depending on table's RLS scope.
|
||||
$overClause = $partition !== ''
|
||||
? "({$partition} ORDER BY id)"
|
||||
: '(ORDER BY id)';
|
||||
|
||||
$sql = <<<SQL
|
||||
WITH ordered AS (
|
||||
SELECT
|
||||
id,
|
||||
log_hash AS stored_hash,
|
||||
LAG(log_hash) OVER {$overClause} AS prev_hash
|
||||
FROM {$partitionName}
|
||||
)
|
||||
SELECT
|
||||
o.id,
|
||||
o.stored_hash,
|
||||
digest(
|
||||
COALESCE(o.prev_hash, ''::bytea)
|
||||
|| (SELECT {$rowExpr}::text::bytea FROM {$partitionName} t WHERE t.id = o.id),
|
||||
'sha256'
|
||||
) AS recomputed_hash
|
||||
FROM ordered o
|
||||
WHERE o.stored_hash IS DISTINCT FROM
|
||||
digest(
|
||||
COALESCE(o.prev_hash, ''::bytea)
|
||||
|| (SELECT {$rowExpr}::text::bytea FROM {$partitionName} t WHERE t.id = o.id),
|
||||
'sha256'
|
||||
)
|
||||
ORDER BY o.id
|
||||
SQL;
|
||||
|
||||
/** @var list<object> $results */
|
||||
$results = DB::connection(self::DB_CONNECTION)
|
||||
->select($sql);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит SQL-выражение ROW(col1, col2, ..., NULL::bytea, ..., coln)
|
||||
* с NULL::bytea на месте log_hash.
|
||||
*
|
||||
* Пример для auth_log:
|
||||
* ROW(t.id, t.actor_type, t.tenant_id, ..., NULL::bytea, t.created_at)
|
||||
*
|
||||
* @param list<string> $columns
|
||||
*/
|
||||
private function buildRowExpression(array $columns): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($columns as $col) {
|
||||
$parts[] = ($col === '__log_hash__') ? 'NULL::bytea' : "t.{$col}";
|
||||
}
|
||||
|
||||
return 'ROW('.implode(', ', $parts).')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставляет запись в incidents_log (через pgsql_supplier BYPASSRLS).
|
||||
* Дедупликация: не создаёт повторный инцидент для той же таблицы,
|
||||
* если за последние DEDUP_HOURS часов уже есть открытый инцидент.
|
||||
*
|
||||
* Вызывается внутри try/catch в handle() — исключение не подавляет
|
||||
* breach-сигнал (handle() всё равно вернёт self::FAILURE).
|
||||
*
|
||||
* @param string $table Имя родительской таблицы (для дедупликации)
|
||||
* @param string $partitionName Имя конкретной партиции где обнаружен разрыв
|
||||
*/
|
||||
private function recordIncident(
|
||||
string $table,
|
||||
string $partitionName,
|
||||
int $firstBrokenId,
|
||||
int $count,
|
||||
Carbon $now
|
||||
): void {
|
||||
$dedupSince = $now->copy()->subHours(self::DEDUP_HOURS);
|
||||
|
||||
$alreadyOpen = DB::connection(self::DB_CONNECTION)
|
||||
->table('incidents_log')
|
||||
->where('type', 'other')
|
||||
->where('severity', 'high')
|
||||
->where('summary', 'like', '%chain%'.addcslashes($table, '%_\\').'%')
|
||||
->whereNull('resolved_at')
|
||||
->where('detected_at', '>=', $dedupSince)
|
||||
->exists();
|
||||
|
||||
if ($alreadyOpen) {
|
||||
$this->line(" Skipping incident (dedup): {$partitionName}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Для NOT NULL FK created_by_admin_id берём первого активного SaaS-admin.
|
||||
// Если нет активных admins — пишем предупреждение, но НЕ пропускаем:
|
||||
// бросаем исключение, чтобы caller (try/catch в handle()) его поймал
|
||||
// и залогировал. Breach-сигнал (FAILURE exit code) уже установлен выше.
|
||||
$adminId = DB::connection(self::DB_CONNECTION)
|
||||
->table('saas_admin_users')
|
||||
->where('is_active', true)
|
||||
->whereNull('deleted_at')
|
||||
->value('id');
|
||||
|
||||
if ($adminId === null) {
|
||||
$this->warn(" No active saas_admin_users — incident not recorded for {$partitionName}");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
DB::connection(self::DB_CONNECTION)->table('incidents_log')->insert([
|
||||
'type' => 'other',
|
||||
'severity' => 'high',
|
||||
'summary' => "Автоматически: разрыв hash-chain в партиции {$partitionName} (таблица {$table}). "
|
||||
."Первый сломанный id={$firstBrokenId}, всего несовпадений={$count}. "
|
||||
.'Возможен tampering (UPDATE/DELETE в обход триггеров).',
|
||||
'root_cause' => null,
|
||||
'started_at' => $now,
|
||||
'detected_at' => $now,
|
||||
'resolved_at' => null,
|
||||
'created_by_admin_id' => $adminId,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->warn(" Incident recorded for {$partitionName} (first broken id={$firstBrokenId})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет email-алёрт на monitoring email.
|
||||
*/
|
||||
private function sendAlert(string $table, string $partitionName, int $firstBrokenId, int $count): void
|
||||
{
|
||||
try {
|
||||
Mail::to(self::MONITORING_EMAIL)
|
||||
->send(new AuditChainBreachMail($table, $firstBrokenId, $count, $partitionName));
|
||||
} catch (\Throwable $e) {
|
||||
// Не ломаем команду если почта недоступна — инцидент уже записан
|
||||
$this->warn(" Email failed: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,27 +5,30 @@ declare(strict_types=1);
|
||||
namespace App\Exceptions\Billing;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Выбрасывается LedgerService::chargeForDelivery, когда tenant не имеет
|
||||
* ни prepaid-лидов (balance_leads >= 1), ни рублей под текущую tier-цену
|
||||
* (balance_rub * 100 >= priceKopecks).
|
||||
* Выбрасывается LedgerService::chargeForDelivery, когда у tenant нет
|
||||
* рублей под текущую tier-цену (balance_rub * 100 < priceKopecks).
|
||||
*
|
||||
* Ловится в RouteSupplierLeadJob::createDealCopyForProject — инициирует
|
||||
* auto-pause flow (см. spec §4.2).
|
||||
*
|
||||
* Billing v2 Spec A: prepaid-лиды убраны, поэтому balance_leads больше не отражается
|
||||
* в сообщении/полях; источник — единый ₽-баланс.
|
||||
*/
|
||||
final class InsufficientBalanceException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $priceKopecks,
|
||||
public readonly string $balanceRub,
|
||||
public readonly int $balanceLeads,
|
||||
?\Throwable $previous = null,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct(
|
||||
sprintf(
|
||||
'Insufficient balance: price_kopecks=%d, balance_rub=%s, balance_leads=%d',
|
||||
$priceKopecks, $balanceRub, $balanceLeads,
|
||||
'Insufficient balance: price_kopecks=%d, balance_rub=%s',
|
||||
$priceKopecks,
|
||||
$balanceRub,
|
||||
),
|
||||
previous: $previous,
|
||||
);
|
||||
|
||||
@@ -25,6 +25,15 @@ class AdminIncidentsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
/**
|
||||
* SaaS-level tables (`incidents_log`, `tenants`, `saas_admin_users`) читаются
|
||||
* под BYPASSRLS-ролью `crm_supplier_worker`: у дефолтной `crm_app_user` нет
|
||||
* грантов на `incidents_log` → `permission denied`. Паттерн соответствует
|
||||
* остальной cross-tenant cron-инфраструктуре (incidents:watch-failures,
|
||||
* scheduler:check-heartbeats, audit:verify-chains).
|
||||
*/
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/** GET /api/admin/incidents?type=&severity=&unresolved_only=&limit=&offset= */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
@@ -34,7 +43,7 @@ class AdminIncidentsController extends Controller
|
||||
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
||||
$offset = max(0, (int) $request->query('offset', '0'));
|
||||
|
||||
$query = DB::table('incidents_log');
|
||||
$query = DB::connection(self::DB_CONNECTION)->table('incidents_log');
|
||||
|
||||
if ($type !== '') {
|
||||
$query->where('type', $type);
|
||||
@@ -90,7 +99,7 @@ class AdminIncidentsController extends Controller
|
||||
/** POST /api/admin/incidents/{id}/rkn-notify — зафиксировать уведомление РКН (G6, 152-ФЗ). */
|
||||
public function notifyRkn(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$row = DB::table('incidents_log')->where('id', $id)->first();
|
||||
$row = DB::connection(self::DB_CONNECTION)->table('incidents_log')->where('id', $id)->first();
|
||||
if ($row === null) {
|
||||
abort(404, 'incident not found');
|
||||
}
|
||||
@@ -103,8 +112,8 @@ class AdminIncidentsController extends Controller
|
||||
|
||||
$adminUserId = $this->resolveAdminUserId($request, 'system-incidents@liderra.local', 'System Incidents Bot');
|
||||
|
||||
DB::transaction(function () use ($row, $adminUserId, $request): void {
|
||||
DB::table('incidents_log')->where('id', $row->id)->update([
|
||||
DB::connection(self::DB_CONNECTION)->transaction(function () use ($row, $adminUserId, $request): void {
|
||||
DB::connection(self::DB_CONNECTION)->table('incidents_log')->where('id', $row->id)->update([
|
||||
'rkn_notified_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
@@ -128,7 +137,7 @@ class AdminIncidentsController extends Controller
|
||||
/** GET /api/admin/incidents/{id} — полная карточка инцидента (drill-down G5). */
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$row = DB::table('incidents_log')->where('id', $id)->first();
|
||||
$row = DB::connection(self::DB_CONNECTION)->table('incidents_log')->where('id', $id)->first();
|
||||
if ($row === null) {
|
||||
abort(404, 'incident not found');
|
||||
}
|
||||
@@ -139,10 +148,10 @@ class AdminIncidentsController extends Controller
|
||||
|
||||
$tenants = $tenantIds === []
|
||||
? collect()
|
||||
: DB::table('tenants')->whereIn('id', $tenantIds)
|
||||
: DB::connection(self::DB_CONNECTION)->table('tenants')->whereIn('id', $tenantIds)
|
||||
->select(['id', 'organization_name'])->get();
|
||||
|
||||
$admins = DB::table('saas_admin_users')
|
||||
$admins = DB::connection(self::DB_CONNECTION)->table('saas_admin_users')
|
||||
->whereIn('id', array_filter([$row->created_by_admin_id, $row->closed_by_admin_id]))
|
||||
->pluck('full_name', 'id');
|
||||
|
||||
@@ -236,7 +245,7 @@ class AdminIncidentsController extends Controller
|
||||
*/
|
||||
private function computeSummary(): array
|
||||
{
|
||||
$base = DB::table('incidents_log');
|
||||
$base = DB::connection(self::DB_CONNECTION)->table('incidents_log');
|
||||
|
||||
return [
|
||||
'open' => (clone $base)->whereNull('resolved_at')->whereNull('detected_at')->count(),
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Pd\PdErasureService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* SaaS-admin: управление обращениями субъектов ПДн (152-ФЗ).
|
||||
*
|
||||
* Saas-уровневый endpoint (НЕ tenant-aware), под middleware('saas-admin').
|
||||
* Production: middleware('auth:saas-admin') — реализуется после Б-1 + DO-4.
|
||||
*
|
||||
* Маршруты:
|
||||
* GET /api/admin/pd-subject-requests → index
|
||||
* POST /api/admin/pd-subject-requests → store
|
||||
* GET /api/admin/pd-subject-requests/{id} → show
|
||||
* POST /api/admin/pd-subject-requests/{id}/erase → executeErasure
|
||||
*/
|
||||
class AdminPdSubjectRequestsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
public function __construct(private readonly PdErasureService $erasureService) {}
|
||||
|
||||
/**
|
||||
* GET /api/admin/pd-subject-requests
|
||||
*
|
||||
* Список обращений с пагинацией. Фильтры: status, request_type.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$status = (string) $request->query('status', '');
|
||||
$requestType = (string) $request->query('request_type', '');
|
||||
$limit = max(1, min(200, (int) $request->query('limit', '50')));
|
||||
$offset = max(0, (int) $request->query('offset', '0'));
|
||||
|
||||
$query = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->orderByDesc('received_at')
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($status !== '') {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
if ($requestType !== '') {
|
||||
$query->where('request_type', $requestType);
|
||||
}
|
||||
|
||||
$total = (clone $query)->count('id');
|
||||
$rows = $query->limit($limit)->offset($offset)->get();
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows->map(fn ($r) => $this->formatRow($r)),
|
||||
'total' => $total,
|
||||
'limit' => $limit,
|
||||
'offset' => $offset,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/admin/pd-subject-requests/{id}
|
||||
*/
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$row = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if ($row === null) {
|
||||
return response()->json(['message' => 'Обращение не найдено.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['data' => $this->formatRow($row)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/pd-subject-requests
|
||||
*
|
||||
* Создать новое обращение субъекта. Deadline автоматически +30 дней
|
||||
* через PostgreSQL-триггер trg_pd_subject_requests_deadline.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'subject_email' => ['nullable', 'email', 'max:255'],
|
||||
'subject_phone' => ['nullable', 'string', 'max:20'],
|
||||
'subject_full_name' => ['nullable', 'string', 'max:255'],
|
||||
'request_type' => ['required', Rule::in(['access', 'rectification', 'deletion', 'objection'])],
|
||||
'description' => ['nullable', 'string', 'max:4096'],
|
||||
'tenant_id' => ['nullable', 'integer', 'min:1'],
|
||||
]);
|
||||
|
||||
// Минимум один идентификатор субъекта
|
||||
if (empty($validated['subject_email']) && empty($validated['subject_phone'])) {
|
||||
return response()->json([
|
||||
'message' => 'Укажите email или телефон субъекта.',
|
||||
'errors' => ['subject_email' => ['Необходимо email или телефон.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
// NB: deadline_at заполняется триггером trg_pd_subject_requests_deadline
|
||||
// (received_at + 30 дней). Передаём placeholder — триггер перезапишет.
|
||||
$id = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->insertGetId([
|
||||
'received_at' => $now,
|
||||
'subject_email' => $validated['subject_email'] ?? null,
|
||||
'subject_phone' => $validated['subject_phone'] ?? null,
|
||||
'subject_full_name' => $validated['subject_full_name'] ?? null,
|
||||
'request_type' => $validated['request_type'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'status' => 'received',
|
||||
'tenant_id' => $validated['tenant_id'] ?? null,
|
||||
'processing_restricted' => false,
|
||||
// deadline_at: trigger перезапишет, но NOT NULL требует значения
|
||||
'deadline_at' => $now->addDays(30),
|
||||
]);
|
||||
|
||||
$row = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
return response()->json(['data' => $this->formatRow($row)], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/pd-subject-requests/{id}/erase
|
||||
*
|
||||
* Выполнить анонимизацию ПДн для обращения с request_type='deletion'.
|
||||
* Возвращает counts анонимизированных записей.
|
||||
*/
|
||||
public function executeErasure(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$row = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->where('id', $id)
|
||||
->first();
|
||||
|
||||
if ($row === null) {
|
||||
return response()->json(['message' => 'Обращение не найдено.'], 404);
|
||||
}
|
||||
|
||||
if ($row->request_type !== 'deletion') {
|
||||
return response()->json([
|
||||
'message' => 'Анонимизация доступна только для обращений типа "deletion".',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if ($row->status === 'completed') {
|
||||
return response()->json([
|
||||
'message' => 'Обращение уже выполнено.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (empty($row->subject_email) && empty($row->subject_phone)) {
|
||||
return response()->json([
|
||||
'message' => 'В обращении не указан email или телефон субъекта.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$adminId = $this->resolveAdminUserId(
|
||||
$request,
|
||||
'pd-erasure-stub@system.local',
|
||||
'PD Erasure System',
|
||||
);
|
||||
|
||||
$counts = $this->erasureService->eraseSubject(
|
||||
email: $row->subject_email ?: null,
|
||||
phone: $row->subject_phone ?: null,
|
||||
tenantId: $row->tenant_id !== null ? (int) $row->tenant_id : null,
|
||||
actorAdminId: $adminId,
|
||||
requestId: (string) $id,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Анонимизация выполнена.',
|
||||
'counts' => $counts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматировать строку pd_subject_requests в массив для API.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function formatRow(object $row): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'received_at' => $row->received_at !== null
|
||||
? CarbonImmutable::parse($row->received_at)->toIso8601String() : null,
|
||||
'subject_email' => $row->subject_email,
|
||||
'subject_phone' => $row->subject_phone,
|
||||
'subject_full_name' => $row->subject_full_name,
|
||||
'request_type' => $row->request_type,
|
||||
'description' => $row->description,
|
||||
'status' => $row->status,
|
||||
'tenant_id' => $row->tenant_id !== null ? (int) $row->tenant_id : null,
|
||||
'assigned_admin_id' => $row->assigned_admin_id !== null
|
||||
? (int) $row->assigned_admin_id : null,
|
||||
'response_text' => $row->response_text,
|
||||
'deadline_at' => $row->deadline_at !== null
|
||||
? CarbonImmutable::parse($row->deadline_at)->toIso8601String() : null,
|
||||
'completed_at' => $row->completed_at !== null
|
||||
? CarbonImmutable::parse($row->completed_at)->toIso8601String() : null,
|
||||
'processing_restricted' => (bool) $row->processing_restricted,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ final class AdminPricingTiersController extends Controller
|
||||
'tiers' => ['required', 'array', 'size:7'],
|
||||
'tiers.*.tier_no' => ['required', 'integer', 'between:1,7'],
|
||||
'tiers.*.leads_in_tier' => ['nullable', 'integer', 'min:1'],
|
||||
'tiers.*.price_rub' => ['required', 'numeric', 'min:0'],
|
||||
'tiers.*.price_rub' => ['required', 'string', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],
|
||||
]);
|
||||
|
||||
@@ -101,7 +101,7 @@ final class AdminPricingTiersController extends Controller
|
||||
PricingTier::create([
|
||||
'tier_no' => $tier['tier_no'],
|
||||
'leads_in_tier' => $tier['leads_in_tier'],
|
||||
'price_per_lead_kopecks' => (int) round(((float) $tier['price_rub']) * 100),
|
||||
'price_per_lead_kopecks' => (int) bcmul((string) $tier['price_rub'], '100', 0),
|
||||
'is_active' => true,
|
||||
'effective_from' => $effectiveFrom,
|
||||
]);
|
||||
|
||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -25,6 +28,8 @@ use Illuminate\Support\Facades\DB;
|
||||
*/
|
||||
class AdminTenantsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
|
||||
/** GET /api/admin/tenants?status=&search=&limit=&offset= */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
@@ -182,6 +187,87 @@ class AdminTenantsController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/tenants/{id}/balance — установить точный ₽-баланс тенанта.
|
||||
*
|
||||
* Семантика «set absolute»: админ передаёт целевой balance_rub, сервер
|
||||
* считает знаковую дельту (target − current) и пишет её append-only строкой
|
||||
* balance_transactions(type='manual_adjustment') + saas_admin_audit_log.
|
||||
*
|
||||
* SaaS-уровневый: НЕ tenant-aware. Money — bcmath, lockForUpdate (конвенция
|
||||
* LedgerService / AdminBillingController::refund). balance_leads не трогаем
|
||||
* (Billing v2 Spec A — лиды vestigial, удаляются в Phase B).
|
||||
*/
|
||||
public function updateBalance(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'balance_rub' => ['required', 'string', 'regex:/^-?\d+(\.\d{1,2})?$/'],
|
||||
'reason' => ['nullable', 'string', 'max:500'],
|
||||
]);
|
||||
|
||||
$target = bcadd((string) $validated['balance_rub'], '0', 2);
|
||||
$reason = isset($validated['reason']) && trim((string) $validated['reason']) !== ''
|
||||
? trim((string) $validated['reason'])
|
||||
: 'Ручная корректировка баланса (админ)';
|
||||
$adminUserId = $this->resolveAdminUserId($request, 'system-balance@liderra.local', 'System Balance Bot');
|
||||
|
||||
/** @var array{balance_rub:string, delta:string, transaction_id:int} $result */
|
||||
$result = DB::transaction(function () use ($id, $target, $reason, $adminUserId, $request): array {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$id);
|
||||
|
||||
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')
|
||||
->lockForUpdate()->first();
|
||||
if ($tenant === null) {
|
||||
abort(404, 'tenant not found');
|
||||
}
|
||||
|
||||
$current = (string) $tenant->balance_rub;
|
||||
$delta = bcsub($target, $current, 2);
|
||||
if (bccomp($delta, '0', 2) === 0) {
|
||||
abort(422, 'balance unchanged');
|
||||
}
|
||||
|
||||
DB::table('tenants')->where('id', $id)->update([
|
||||
'balance_rub' => $target,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$tx = BalanceTransaction::create([
|
||||
'tenant_id' => $id,
|
||||
'type' => BalanceTransaction::TYPE_MANUAL_ADJUSTMENT,
|
||||
'amount_rub' => $delta,
|
||||
'amount_leads' => null,
|
||||
'balance_rub_after' => $target,
|
||||
'balance_leads_after' => null,
|
||||
'description' => $reason,
|
||||
'admin_user_id' => $adminUserId,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
SaasAdminAuditLog::create([
|
||||
'admin_user_id' => $adminUserId,
|
||||
'action' => 'tenant.balance_adjusted',
|
||||
'target_type' => 'tenant',
|
||||
'target_id' => $id,
|
||||
'target_tenant_id' => $id,
|
||||
'payload_before' => ['balance_rub' => $current],
|
||||
'payload_after' => ['balance_rub' => $target, 'delta' => $delta, 'transaction_id' => $tx->id],
|
||||
'reason' => $reason,
|
||||
'ip_address' => $request->ip() ?? '127.0.0.1',
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
|
||||
return ['balance_rub' => $target, 'delta' => $delta, 'transaction_id' => (int) $tx->id];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'id' => $id,
|
||||
'balance_rub' => $result['balance_rub'],
|
||||
'delta' => $result['delta'],
|
||||
'transaction_id' => $result['transaction_id'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
private function fetchUsers(int $tenantId): array
|
||||
{
|
||||
|
||||
@@ -8,9 +8,12 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalanceToLeadsConverter;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
@@ -62,7 +65,11 @@ class BillingController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/billing/wallet — балансы тенанта + текущий тариф + runway.
|
||||
* GET /api/billing/wallet — единый ₽-баланс + рассчитанные «≈ N лидов» + 7-ступенчатый превью.
|
||||
*
|
||||
* Billing v2 Spec A: `balance_leads` ушёл из ответа; конверсия ₽ → лиды
|
||||
* считается на лету через BalanceToLeadsConverter (точный расчёт по
|
||||
* ступеням, не «по текущей»). Тариф унифицирован до name+features.
|
||||
*/
|
||||
public function wallet(Request $request): JsonResponse
|
||||
{
|
||||
@@ -71,23 +78,48 @@ class BillingController extends Controller
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::query()->with('tariff')->findOrFail((int) $user->tenant_id);
|
||||
|
||||
$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,
|
||||
);
|
||||
|
||||
$tiersPreview = $activeTiers
|
||||
->sortBy('tier_no')
|
||||
->values()
|
||||
->map(static fn ($t) => [
|
||||
'tier_no' => (int) $t->tier_no,
|
||||
'leads_in_tier' => $t->leads_in_tier === null ? null : (int) $t->leads_in_tier,
|
||||
'price_rub' => bcdiv((string) $t->price_per_lead_kopecks, '100', 2),
|
||||
])
|
||||
->all();
|
||||
|
||||
return response()->json([
|
||||
'balance_rub' => $tenant->balance_rub,
|
||||
'balance_leads' => $tenant->balance_leads,
|
||||
'runway_days' => $this->runwayDays($tenant),
|
||||
'affordable_leads' => $conversion['leads'],
|
||||
'current_tier' => $conversion['current_tier'],
|
||||
'next_tier' => $conversion['next_tier'],
|
||||
'delivered_in_month' => (int) ($tenant->delivered_in_month ?? 0),
|
||||
'runway_days' => $this->runwayDays($tenant, $conversion['leads']),
|
||||
'tiers_preview' => $tiersPreview,
|
||||
'tariff' => $tenant->tariff === null ? null : [
|
||||
'code' => $tenant->tariff->code,
|
||||
'name' => $tenant->tariff->name,
|
||||
'price_monthly' => $tenant->tariff->price_monthly,
|
||||
'billing_model' => $tenant->tariff->billing_model,
|
||||
'features' => $tenant->tariff->features ?? [],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/billing/transactions?type=topup|lead_charge|refund&page=N
|
||||
* GET /api/billing/transactions?type=topup|lead_charge|migration&page=N
|
||||
* — пагинированная история balance_transactions тенанта (20/страница).
|
||||
*
|
||||
* Billing v2 Spec A: 'refund' убран из whitelist (возвраты не реализуются);
|
||||
* 'migration' добавлен (тип одноразовой конвертации balance_leads → balance_rub).
|
||||
* Поле display_amount_rub в каждой строке — UI-показ суммы; для исторических
|
||||
* prepaid lead_charge (amount_rub='0.00') возвращается '0.00' для маркера
|
||||
* «бесплатное списание».
|
||||
*/
|
||||
public function transactions(Request $request): JsonResponse
|
||||
{
|
||||
@@ -103,23 +135,35 @@ class BillingController extends Controller
|
||||
->orderBy('id', 'desc');
|
||||
|
||||
$type = $request->query('type');
|
||||
if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'refund'], true)) {
|
||||
if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'migration'], true)) {
|
||||
$query->where('type', $type);
|
||||
}
|
||||
|
||||
$page = $query->paginate(20);
|
||||
|
||||
return response()->json([
|
||||
'data' => array_map(static fn (BalanceTransaction $tx): array => [
|
||||
'id' => $tx->id,
|
||||
'code' => 'TX-'.$tx->id,
|
||||
'type' => $tx->type,
|
||||
'description' => $tx->description,
|
||||
'amount_rub' => $tx->amount_rub,
|
||||
'amount_leads' => $tx->amount_leads,
|
||||
'balance_rub_after' => $tx->balance_rub_after,
|
||||
'created_at' => $tx->created_at,
|
||||
], $page->items()),
|
||||
'data' => array_map(static function (BalanceTransaction $tx): array {
|
||||
// Historic prepaid rows: type=lead_charge AND amount_rub=='0.00' (deduction в leads).
|
||||
// display_amount_rub возвращает явное '0.00' для UI-маркера «бесплатное списание»,
|
||||
// несмотря на то что значение совпадает с amount_rub.
|
||||
$displayAmountRub = (string) $tx->amount_rub;
|
||||
if ($tx->type === BalanceTransaction::TYPE_LEAD_CHARGE
|
||||
&& bccomp((string) $tx->amount_rub, '0', 2) === 0) {
|
||||
$displayAmountRub = '0.00';
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $tx->id,
|
||||
'code' => 'TX-'.$tx->id,
|
||||
'type' => $tx->type,
|
||||
'description' => $tx->description,
|
||||
'amount_rub' => $tx->amount_rub,
|
||||
'amount_leads' => $tx->amount_leads,
|
||||
'balance_rub_after' => $tx->balance_rub_after,
|
||||
'display_amount_rub' => $displayAmountRub,
|
||||
'created_at' => $tx->created_at,
|
||||
];
|
||||
}, $page->items()),
|
||||
'meta' => [
|
||||
'current_page' => $page->currentPage(),
|
||||
'last_page' => $page->lastPage(),
|
||||
@@ -160,27 +204,35 @@ class BillingController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Прогноз «на сколько дней хватит баланса» — оценочный UX-показатель.
|
||||
* Прогноз «на сколько дней хватит affordable_leads» — оценочный UX-показатель.
|
||||
*
|
||||
* = balance_rub / (рублёвые списания за 30 дней / 30). NULL, если списаний
|
||||
* не было. Float здесь допустим: грубая оценка для шапки, НЕ мутация
|
||||
* баланса (мутации баланса — строго bcmath, см. BillingTopupService).
|
||||
* Отрицательный баланс → 0 (тенант уже в минусе, runway не может быть < 0).
|
||||
* Billing v2 Spec A: считаем по affordable_leads (выход BalanceToLeadsConverter)
|
||||
* делённому на среднюю скорость списания за 30 дней (count(lead_charges)/30).
|
||||
* Раньше формула была balance_rub / per-day-rub-spend — после унификации
|
||||
* единицы измерения «лиды» более показательны и устраняют дрейф между
|
||||
* рублёвой шапкой и тарифной ступенью.
|
||||
*
|
||||
* - affordable_leads ≤ 0 → 0 (тенант не может купить ни одного лида).
|
||||
* - leadsLast30Days = 0 → null (нет истории, не от чего считать).
|
||||
* - иначе → floor(affordable_leads / (leadsLast30Days / 30)).
|
||||
*/
|
||||
private function runwayDays(Tenant $tenant): ?int
|
||||
private function runwayDays(Tenant $tenant, int $affordableLeads): ?int
|
||||
{
|
||||
$spent = abs((float) DB::table('balance_transactions')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_LEAD_CHARGE)
|
||||
->where('created_at', '>=', now()->subDays(30))
|
||||
->sum('amount_rub'));
|
||||
if ($affordableLeads <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($spent <= 0.0) {
|
||||
$leadsLast30Days = (int) DB::table('lead_charges')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('charged_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
if ($leadsLast30Days <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$perDay = $spent / 30.0;
|
||||
$avgPerDay = $leadsLast30Days / 30.0;
|
||||
|
||||
return max(0, (int) floor((float) $tenant->balance_rub / $perDay));
|
||||
return max(0, (int) floor($affordableLeads / $avgPerDay));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,9 @@ use Illuminate\Support\Facades\DB;
|
||||
* вынесены в `DealBulkActionController`, `export()` — в `DealExportController`.
|
||||
* Этот класс остаётся только для CRUD по одной записи.
|
||||
*
|
||||
* NB: webhook-flow (автосоздание из crm.bp-gr.ru) — отдельный endpoint
|
||||
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
|
||||
* с advisory lock + dedup). Этот controller — для ручных action'ов из UI.
|
||||
* NB: webhook-flow (приём из crm.bp-gr.ru) — отдельный endpoint
|
||||
* `SupplierWebhookController` + `RouteSupplierLeadJob` (шеринг-канал).
|
||||
* Этот controller — для ручных action'ов из UI.
|
||||
*
|
||||
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
|
||||
*
|
||||
|
||||
@@ -173,7 +173,7 @@ class ReportJobController extends Controller
|
||||
|
||||
// Sync queue на dev — Job выполняется немедленно.
|
||||
// На prod queue.driver=redis/database — async через worker.
|
||||
GenerateReportJob::dispatch($job->id);
|
||||
GenerateReportJob::dispatch($job->id, (int) $user->tenant_id);
|
||||
|
||||
return response()->json([
|
||||
'job' => $this->toResource($job->fresh()),
|
||||
@@ -254,7 +254,7 @@ class ReportJobController extends Controller
|
||||
'status' => ReportJob::STATUS_PENDING,
|
||||
]);
|
||||
|
||||
GenerateReportJob::dispatch($newJob->id);
|
||||
GenerateReportJob::dispatch($newJob->id, (int) $user->tenant_id);
|
||||
|
||||
return response()->json([
|
||||
'job' => $this->toResource($newJob->fresh()),
|
||||
|
||||
@@ -29,8 +29,8 @@ use Symfony\Component\HttpFoundation\IpUtils;
|
||||
* Идемпотентность: UNIQUE INDEX на supplier_leads.vid. При дубле возвращаем
|
||||
* 200 OK без re-dispatch (поставщик может ретранслировать одни и те же лиды).
|
||||
*
|
||||
* Backward-compat: legacy /api/webhook/{token} (per-tenant) живёт параллельно
|
||||
* на WebhookReceiveController — не пересекается.
|
||||
* Единственный приёмник входящих лидов от crm.bp-gr.ru (legacy per-tenant
|
||||
* webhook был удалён вместе с ProcessWebhookJob).
|
||||
*
|
||||
* Plan 2.6 fix #ii (10.05.2026): пустой `supplier_ip_allowlist = '[]'` на
|
||||
* production env теперь fail-closed (`verifyIpAllowlist` возвращает false если
|
||||
|
||||
@@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -84,26 +86,57 @@ class TenantChargesController extends Controller
|
||||
|
||||
// Explicit tenant_id фильтр — defense-in-depth поверх RLS
|
||||
// (см. комментарий в index()).
|
||||
$query = LeadCharge::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->orderBy('charged_at', 'desc');
|
||||
$this->applyPeriodFilter($query, $period);
|
||||
// LEFT JOIN balance_transactions для заполнения balance_rub_after.
|
||||
// Условие type='lead_charge' исключает topup/refund которые тоже
|
||||
// могут ссылаться на deal через related_id.
|
||||
$query = DB::table('lead_charges as lc')
|
||||
->select([
|
||||
'lc.id',
|
||||
'lc.charged_at',
|
||||
'lc.deal_id',
|
||||
'lc.tier_no',
|
||||
'lc.charge_source',
|
||||
'lc.price_per_lead_kopecks',
|
||||
'bt.balance_rub_after',
|
||||
])
|
||||
->leftJoin('balance_transactions as bt', function ($j) use ($tenantId) {
|
||||
$j->on('bt.related_id', '=', 'lc.deal_id')
|
||||
->where('bt.related_type', '=', Deal::class)
|
||||
->where('bt.type', '=', BalanceTransaction::TYPE_LEAD_CHARGE)
|
||||
->where('bt.tenant_id', '=', $tenantId);
|
||||
})
|
||||
->where('lc.tenant_id', $tenantId)
|
||||
->orderBy('lc.charged_at', 'desc')
|
||||
->orderBy('lc.id', 'desc');
|
||||
|
||||
if (is_string($period) && $period !== '') {
|
||||
$now = Carbon::now('Europe/Moscow');
|
||||
if ($period === 'current_month') {
|
||||
$query->where('lc.charged_at', '>=', $now->copy()->startOfMonth());
|
||||
} elseif ($period === 'last_month') {
|
||||
$query->whereBetween('lc.charged_at', [
|
||||
$now->copy()->subMonth()->startOfMonth(),
|
||||
$now->copy()->subMonth()->endOfMonth(),
|
||||
]);
|
||||
} elseif ($period === '90d') {
|
||||
$query->where('lc.charged_at', '>=', $now->copy()->subDays(90));
|
||||
}
|
||||
}
|
||||
if ($source !== null && $source !== '') {
|
||||
$query->where('charge_source', $source);
|
||||
$query->where('lc.charge_source', $source);
|
||||
}
|
||||
|
||||
$query->chunkById(500, function ($charges) use ($out) {
|
||||
foreach ($charges as $c) {
|
||||
/** @var LeadCharge $c */
|
||||
// chunk() вместо chunkById() — chunkById несовместим с JOIN-запросами
|
||||
// (ломает пагинацию при неуникальном id в select).
|
||||
$query->chunk(500, function ($rows) use ($out) {
|
||||
foreach ($rows as $r) {
|
||||
fputcsv($out, [
|
||||
$c->charged_at->toIso8601String(),
|
||||
(string) $c->deal_id,
|
||||
(string) $c->tier_no,
|
||||
(string) $c->getAttribute('charge_source'),
|
||||
number_format($c->price_per_lead_kopecks / 100, 2, '.', ''),
|
||||
// balance_rub_after — нет в lead_charges (доступно через
|
||||
// balance_transactions). MVP оставляем пустым.
|
||||
'',
|
||||
Carbon::parse($r->charged_at)->toIso8601String(),
|
||||
(string) $r->deal_id,
|
||||
(string) $r->tier_no,
|
||||
(string) $r->charge_source,
|
||||
number_format($r->price_per_lead_kopecks / 100, 2, '.', ''),
|
||||
$r->balance_rub_after ?? '',
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Receive endpoint для входящих webhook'ов от crm.bp-gr.ru (narrative §5.5).
|
||||
*
|
||||
* URL: POST /api/webhook/{token}
|
||||
* Token = `tenants.webhook_token` (UUID per tenant; ротация через
|
||||
* `webhook_token_rotated_at` — старый токен живёт 24ч после ротации).
|
||||
*
|
||||
* Шаги:
|
||||
* 1. Резолв tenant по token (404 если не найден).
|
||||
* 2. Per-token rate-limit (system_settings.webhook_rate_limit_rps × 60 ≈ per-minute).
|
||||
* Превышение → 429 + Retry-After.
|
||||
* 3. Валидация payload (vid/project/phone/time).
|
||||
* 4. HMAC-валидация (опциональная) — если header `X-Webhook-Signature: sha256=<hex>`
|
||||
* пришёл, проверяем `hash_hmac('sha256', raw_body, webhook_token)`.
|
||||
* Невалидная подпись → 401. Отсутствие header — пропускаем (backward-compat
|
||||
* для существующих интеграций; на prod через `system_settings.webhook_hmac_required`
|
||||
* сделаем обязательной).
|
||||
* 5. INSERT в webhook_log (RLS-обёрнутый), dispatch ProcessWebhookJob → 202.
|
||||
*/
|
||||
class WebhookReceiveController extends Controller
|
||||
{
|
||||
/** POST /api/webhook/{token} */
|
||||
public function receive(Request $request, string $token): JsonResponse
|
||||
{
|
||||
$tenant = Tenant::query()
|
||||
->where('webhook_token', $token)
|
||||
->first();
|
||||
|
||||
if ($tenant === null) {
|
||||
return response()->json([
|
||||
'message' => 'Webhook token не найден или ротирован.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Per-token rate-limit. Лимит из system_settings.webhook_rate_limit_rps
|
||||
// (RPS), приводим к per-minute через ×60. Decay 60 сек.
|
||||
$rpsLimit = $this->getRateLimitRps();
|
||||
$perMinuteLimit = $rpsLimit * 60;
|
||||
$rateKey = "webhook:{$tenant->id}";
|
||||
|
||||
if (RateLimiter::tooManyAttempts($rateKey, $perMinuteLimit)) {
|
||||
$retryAfter = RateLimiter::availableIn($rateKey);
|
||||
|
||||
return response()->json([
|
||||
'message' => "Превышен лимит ({$rpsLimit} RPS). Повтор через {$retryAfter} сек.",
|
||||
'retry_after' => $retryAfter,
|
||||
], 429)->header('Retry-After', (string) $retryAfter);
|
||||
}
|
||||
|
||||
RateLimiter::hit($rateKey, 60);
|
||||
|
||||
// HMAC-валидация. Опциональная по умолчанию (backward-compat); при
|
||||
// `system_settings.webhook_hmac_required = true` — обязательная,
|
||||
// запросы без X-Webhook-Signature → 401.
|
||||
$signature = $request->header('X-Webhook-Signature');
|
||||
$hmacRequired = $this->isHmacRequired();
|
||||
|
||||
if ($signature === null && $hmacRequired) {
|
||||
return response()->json([
|
||||
'message' => 'X-Webhook-Signature header требуется (HMAC обязателен в этой инсталляции).',
|
||||
], 401);
|
||||
}
|
||||
if ($signature !== null) {
|
||||
$rawBody = $request->getContent();
|
||||
$expected = 'sha256='.hash_hmac('sha256', $rawBody, $token);
|
||||
if (! hash_equals($expected, $signature)) {
|
||||
return response()->json(['message' => 'Невалидная HMAC-подпись.'], 401);
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация payload (после tenant lookup чтобы посчитать rate-limit
|
||||
// даже на bad payload — иначе rate-limit можно обойти 422-ответами).
|
||||
$validated = $request->validate([
|
||||
'vid' => 'required|integer|min:1',
|
||||
'project' => 'required|string|max:255',
|
||||
'phone' => 'required|string|max:50',
|
||||
'time' => 'required|integer|min:1',
|
||||
'tag' => 'nullable|string|max:100',
|
||||
'phones' => 'nullable|array',
|
||||
]);
|
||||
|
||||
$logId = $this->insertWebhookLogStub($tenant->id, $validated);
|
||||
|
||||
ProcessWebhookJob::dispatch($tenant->id, $validated, $logId);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'accepted',
|
||||
'tenant_id' => $tenant->id,
|
||||
'webhook_log_id' => $logId,
|
||||
], 202);
|
||||
}
|
||||
|
||||
private function getRateLimitRps(): int
|
||||
{
|
||||
$setting = SystemSetting::find('webhook_rate_limit_rps');
|
||||
if ($setting === null) {
|
||||
return 100; // sensible default из seed v8.7
|
||||
}
|
||||
|
||||
return max(1, (int) $setting->value);
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-обязательность. Audit-fix B3: если ключ отсутствует в БД — default
|
||||
* TRUE (HMAC обязателен по умолчанию). Отключить можно только явной
|
||||
* установкой webhook_hmac_required=false. Неизвестное значение → fail-secure
|
||||
* (HMAC требуется).
|
||||
*/
|
||||
private function isHmacRequired(): bool
|
||||
{
|
||||
$setting = SystemSetting::find('webhook_hmac_required');
|
||||
if ($setting === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ! in_array($setting->value, ['false', '0'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальный INSERT-stub в webhook_log (если таблица существует).
|
||||
* На MVP webhook_log необязателен — возвращаем null если таблицы нет.
|
||||
*
|
||||
* @param array<string,mixed> $payload
|
||||
*/
|
||||
private function insertWebhookLogStub(int $tenantId, array $payload): ?int
|
||||
{
|
||||
if (! Schema::hasTable('webhook_log')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// RLS требует SET LOCAL — оборачиваем в транзакцию.
|
||||
return (int) DB::transaction(function () use ($tenantId, $payload) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
return DB::table('webhook_log')->insertGetId([
|
||||
'tenant_id' => $tenantId,
|
||||
'received_at' => now(),
|
||||
'raw_payload' => json_encode($payload, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Throwable;
|
||||
@@ -37,65 +38,73 @@ class GenerateReportJob implements ShouldQueue
|
||||
|
||||
public function __construct(
|
||||
public readonly int $reportJobId,
|
||||
public readonly int $tenantId,
|
||||
) {}
|
||||
|
||||
public function handle(ReportGeneratorRegistry $registry): void
|
||||
{
|
||||
$job = ReportJob::query()->find($this->reportJobId);
|
||||
if ($job === null) {
|
||||
Log::warning('GenerateReportJob: report_job not found', ['id' => $this->reportJobId]);
|
||||
// SET LOCAL inside a transaction establishes the tenant GUC for the
|
||||
// duration of this block — required by RLS on report_jobs for
|
||||
// crm_app_user (non-BYPASSRLS) on production.
|
||||
DB::transaction(function () use ($registry): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($job->status, ReportJob::ACTIVE_STATUSES, true)) {
|
||||
// Уже terminal — повторный dispatch (например, Horizon retry) пропускаем.
|
||||
return;
|
||||
}
|
||||
|
||||
$job->update(['status' => ReportJob::STATUS_PROCESSING]);
|
||||
|
||||
$startedAt = microtime(true);
|
||||
try {
|
||||
$params = $job->parameters ?? [];
|
||||
$format = (string) ($params['format'] ?? 'csv');
|
||||
|
||||
if (! $registry->isSupported($job->type, $format)) {
|
||||
$this->markFailed($job, "Неподдерживаемая комбинация: {$job->type}/{$format}", $startedAt);
|
||||
$job = ReportJob::query()->find($this->reportJobId);
|
||||
if ($job === null) {
|
||||
Log::warning('GenerateReportJob: report_job not found', ['id' => $this->reportJobId]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $registry->provider($job->type);
|
||||
$formatter = $registry->formatter($format);
|
||||
if (! in_array($job->status, ReportJob::ACTIVE_STATUSES, true)) {
|
||||
// Уже terminal — повторный dispatch (например, Horizon retry) пропускаем.
|
||||
return;
|
||||
}
|
||||
|
||||
$headers = $provider->headers();
|
||||
$rows = $provider->rows($job);
|
||||
$content = $formatter->format($headers, $rows);
|
||||
$job->update(['status' => ReportJob::STATUS_PROCESSING]);
|
||||
|
||||
$relativePath = sprintf(
|
||||
'reports/%d/%d.%s',
|
||||
$job->tenant_id,
|
||||
$job->id,
|
||||
$formatter->fileExtension()
|
||||
);
|
||||
Storage::disk('local')->put($relativePath, $content);
|
||||
$startedAt = microtime(true);
|
||||
try {
|
||||
$params = $job->parameters ?? [];
|
||||
$format = (string) ($params['format'] ?? 'csv');
|
||||
|
||||
$job->update([
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => $relativePath,
|
||||
'file_size' => strlen($content),
|
||||
'generation_seconds' => max(1, (int) round(microtime(true) - $startedAt)),
|
||||
'finished_at' => Carbon::now(),
|
||||
'expires_at' => Carbon::now()->addDays(30),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->markFailed($job, mb_substr($e->getMessage(), 0, 1000), $startedAt);
|
||||
Log::error('GenerateReportJob failed', [
|
||||
'id' => $this->reportJobId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
if (! $registry->isSupported($job->type, $format)) {
|
||||
$this->markFailed($job, "Неподдерживаемая комбинация: {$job->type}/{$format}", $startedAt);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$provider = $registry->provider($job->type);
|
||||
$formatter = $registry->formatter($format);
|
||||
|
||||
$headers = $provider->headers();
|
||||
$rows = $provider->rows($job);
|
||||
$content = $formatter->format($headers, $rows);
|
||||
|
||||
$relativePath = sprintf(
|
||||
'reports/%d/%d.%s',
|
||||
$job->tenant_id,
|
||||
$job->id,
|
||||
$formatter->fileExtension()
|
||||
);
|
||||
Storage::disk('local')->put($relativePath, $content);
|
||||
|
||||
$job->update([
|
||||
'status' => ReportJob::STATUS_DONE,
|
||||
'file_path' => $relativePath,
|
||||
'file_size' => strlen($content),
|
||||
'generation_seconds' => max(1, (int) round(microtime(true) - $startedAt)),
|
||||
'finished_at' => Carbon::now(),
|
||||
'expires_at' => Carbon::now()->addDays(30),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
$this->markFailed($job, mb_substr($e->getMessage(), 0, 1000), $startedAt);
|
||||
Log::error('GenerateReportJob failed', [
|
||||
'id' => $this->reportJobId,
|
||||
'exception' => $e,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function markFailed(ReportJob $job, string $message, float $startedAt): void
|
||||
|
||||
@@ -26,7 +26,7 @@ use Throwable;
|
||||
*
|
||||
* Жизненный цикл import_log: pending → processing → done | failed.
|
||||
* RLS: каждый доступ к БД задаёт SET LOCAL app.current_tenant_id (воркер
|
||||
* вне middleware-контекста — паритет с ProcessWebhookJob).
|
||||
* вне middleware-контекста — паритет с RouteSupplierLeadJob).
|
||||
*/
|
||||
class ImportLeadsJob implements ShouldQueue
|
||||
{
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Deal;
|
||||
use App\Models\FailedWebhookJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\RejectedDealsLog;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\SupplierResolver;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Асинхронная обработка webhook'а от crm.bp-gr.ru (narrative §5.5 v8.7).
|
||||
*
|
||||
* Архитектура:
|
||||
* 1. RLS: SET LOCAL app.current_tenant_id внутри транзакции (PgBouncer-safe).
|
||||
* 2. Lock на tenant + балансовая проверка → RejectedDealsLog при balance=0.
|
||||
* 3. findOrCreate проекта (префикс B[123]_ обрезан).
|
||||
* 4. Идемпотентный upsert через pg_advisory_xact_lock (см. upsertDeal()).
|
||||
* 5. Для НОВОЙ сделки: списание баланса + BalanceTransaction +
|
||||
* SupplierLeadCost (Ю-2) + ActivityLog(deal.created).
|
||||
*
|
||||
* Антифрод-дедуп Биз-19 (§10.8.1): при создании НОВОЙ сделки `DuplicateDetector`
|
||||
* ищет master по `(tenant_id, phone)` в окне 24 ч. Если master найден — новой
|
||||
* сделке проставляется `duplicate_of_id`, баланс НЕ списывается, SupplierLeadCost
|
||||
* НЕ создаётся. ActivityLog пишется с context.duplicate_of=master.id.
|
||||
*
|
||||
* Уведомления (ТЗ §18.5, событие new_lead): после успешного chargeNewLead
|
||||
* вызывается NotificationService::notifyNewLead, который рассылает email
|
||||
* всем активным user'ам тенанта с включённым каналом email для new_lead.
|
||||
*
|
||||
* Не входит в текущий PoC (отдельные ветви фазы 1):
|
||||
* - Sentry::captureException в failed() (нет Sentry-DSN на dev-стеке)
|
||||
* - SystemSetting fallback для supplier_id (сейчас лукап через project_suppliers)
|
||||
*/
|
||||
class ProcessWebhookJob implements ShouldQueue
|
||||
{
|
||||
use FoundationQueueable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data Webhook payload: vid, project, tag, phone, phones, time
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public array $data,
|
||||
public ?int $webhookLogId = null,
|
||||
) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$duplicateDetector = app(DuplicateDetector::class);
|
||||
|
||||
DB::transaction(function () use ($duplicateDetector): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
|
||||
|
||||
$tenant = Tenant::query()
|
||||
->whereKey($this->tenantId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($tenant === null) {
|
||||
throw new RuntimeException("Tenant {$this->tenantId} not found");
|
||||
}
|
||||
|
||||
if ((int) $tenant->balance_leads <= 0) {
|
||||
$this->logRejection($tenant, RejectedDealsLog::REASON_ZERO_BALANCE);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$cleanProjectName = preg_replace('/^B[123]_/', '', (string) $this->data['project']);
|
||||
$project = Project::firstOrCreate(
|
||||
['tenant_id' => $tenant->id, 'name' => $cleanProjectName],
|
||||
['type' => 'webhook'],
|
||||
);
|
||||
|
||||
$receivedAt = Carbon::createFromTimestamp((int) $this->data['time']);
|
||||
$sourceCrmId = (int) $this->data['vid'];
|
||||
|
||||
$deal = $this->upsertDeal(
|
||||
tenant: $tenant,
|
||||
project: $project,
|
||||
sourceCrmId: $sourceCrmId,
|
||||
receivedAt: $receivedAt,
|
||||
);
|
||||
|
||||
if (! $deal->wasRecentlyCreated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Биз-19: master-сделка по phone в окне 24 ч → дубль, без charge.
|
||||
$master = $duplicateDetector->findMaster(
|
||||
tenantId: $tenant->id,
|
||||
phone: (string) $this->data['phone'],
|
||||
now: $receivedAt,
|
||||
);
|
||||
|
||||
// Сам только что созданный $deal попадает в выборку DuplicateDetector
|
||||
// (он уже в БД к моменту lookup'а), поэтому master может ===$deal.
|
||||
// Дубль — только если master найден И это НЕ сам deal.
|
||||
if ($master !== null && $master->id !== $deal->id) {
|
||||
$this->markAsDuplicate($tenant, $deal, $master);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->chargeNewLead($tenant, $project, $deal);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Биз-19: помечаем сделку как дубль master'а. БЕЗ списания баланса
|
||||
* и БЕЗ SupplierLeadCost (не наша закупка). ActivityLog пишется с
|
||||
* `context.duplicate_of=master.id` для аудита.
|
||||
*/
|
||||
private function markAsDuplicate(Tenant $tenant, Deal $deal, Deal $master): void
|
||||
{
|
||||
$deal->update(['duplicate_of_id' => $master->id]);
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_CREATED,
|
||||
'context' => [
|
||||
'source' => 'webhook',
|
||||
'duplicate_of' => $master->id,
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
}
|
||||
|
||||
private function logRejection(Tenant $tenant, string $reason): void
|
||||
{
|
||||
$rejected = RejectedDealsLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'webhook_log_id' => $this->webhookLogId,
|
||||
'reason' => $reason,
|
||||
'payload' => $this->data,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
Log::info("webhook.rejected.{$reason}", [
|
||||
'tenant_id' => $tenant->id,
|
||||
'vid' => $this->data['vid'] ?? null,
|
||||
]);
|
||||
|
||||
// ТЗ §18.5: zero_balance — уведомить тенант. Anti-spam: не более
|
||||
// 1 email/час на тенант. Исключаем только что вставленную запись
|
||||
// через id (timestamp-сравнение ненадёжно из-за microsecond precision).
|
||||
if ($reason === RejectedDealsLog::REASON_ZERO_BALANCE) {
|
||||
$previousCount = RejectedDealsLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('reason', $reason)
|
||||
->where('created_at', '>=', now()->subHour())
|
||||
->where('id', '!=', $rejected->id)
|
||||
->count();
|
||||
|
||||
if ($previousCount === 0) {
|
||||
app(NotificationService::class)->notifyZeroBalance($tenant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Списание баланса при создании НОВОЙ сделки + аудит-записи.
|
||||
*
|
||||
* Все INSERT'ы в одной транзакции — целостность гарантирована (Ю-2):
|
||||
* deal + supplier_lead_cost + balance_transaction появляются атомарно.
|
||||
*/
|
||||
private function chargeNewLead(Tenant $tenant, Project $project, Deal $deal): void
|
||||
{
|
||||
$tenant->decrement('balance_leads');
|
||||
$tenant->refresh();
|
||||
|
||||
BalanceTransaction::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
|
||||
'amount_leads' => -1,
|
||||
'balance_leads_after' => (int) $tenant->balance_leads,
|
||||
'related_type' => Deal::class,
|
||||
'related_id' => $deal->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$resolver = app(SupplierResolver::class);
|
||||
$supplierId = $resolver->resolveForProject($project);
|
||||
if ($supplierId !== null) {
|
||||
SupplierLeadCost::create([
|
||||
'deal_id' => $deal->id,
|
||||
'received_at' => $deal->received_at,
|
||||
'supplier_id' => $supplierId,
|
||||
'cost_rub' => $resolver->costRubSnapshot($supplierId),
|
||||
'supplier_lead_id' => (int) $this->data['vid'],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
Log::warning('webhook.no_active_supplier', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'project_id' => $project->id,
|
||||
'deal_id' => $deal->id,
|
||||
]);
|
||||
}
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_CREATED,
|
||||
'context' => ['source' => 'webhook'],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_webhook', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
// Уведомление о новом лиде (ТЗ §18.5). Отправляется ПОСЛЕ всех записей
|
||||
// в БД, чтобы при ошибке отправки транзакция уже была зафиксирована.
|
||||
// NotificationService сам ловит Throwable от Mail::send и логирует —
|
||||
// отказ канала не должен валить webhook.
|
||||
$deal->setRelation('project', $project);
|
||||
$service = app(NotificationService::class);
|
||||
$service->notifyNewLead($tenant, $deal);
|
||||
|
||||
// ТЗ §18.5: low_balance — после lead_charge проверяем порог. Триггерим
|
||||
// ТОЛЬКО когда баланс пересекает порог сверху-вниз: balance_after <=
|
||||
// threshold AND (balance_after + 1) > threshold. Иначе шлёт спам после
|
||||
// каждого lead_charge при balance < threshold.
|
||||
$threshold = $this->lowBalanceThreshold();
|
||||
$balanceAfter = (int) $tenant->balance_leads;
|
||||
if ($balanceAfter <= $threshold && ($balanceAfter + 1) > $threshold) {
|
||||
$service->notifyLowBalance($tenant, $threshold);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Читает порог из system_settings.low_balance_threshold_leads.
|
||||
* Default 10 (см. schema.sql:2239 seed).
|
||||
*/
|
||||
private function lowBalanceThreshold(): int
|
||||
{
|
||||
$setting = SystemSetting::query()->where('key', 'low_balance_threshold_leads')->first();
|
||||
if ($setting === null) {
|
||||
return 10;
|
||||
}
|
||||
|
||||
return (int) $setting->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Идемпотентная upsert-логика через advisory lock (§5.5 v8.7).
|
||||
*
|
||||
* Стратегия:
|
||||
* 1. pg_advisory_xact_lock(tenant_id, vid) — сериализует все операции
|
||||
* с (tenant_id, source_crm_id) на время транзакции.
|
||||
* 2. SELECT в webhook_dedup_keys — атомарно из-за lock.
|
||||
* 3a. Если найдено — UPDATE deal по composite-ключу (id, received_at).
|
||||
* 3b. Иначе — INSERT deal первым (FK immediate OK), затем INSERT dedup_key.
|
||||
*
|
||||
* См. db/CHANGELOG_schema.md §W для архитектурного обоснования
|
||||
* (PG savepoint+DEFERRED quirk, отказ от двустадийного INSERT-в-dedup-keys-первым).
|
||||
*/
|
||||
private function upsertDeal(
|
||||
Tenant $tenant,
|
||||
Project $project,
|
||||
int $sourceCrmId,
|
||||
Carbon $receivedAt,
|
||||
): Deal {
|
||||
// pg_advisory_xact_lock(bigint): комбинируем (tenant_id, source_crm_id)
|
||||
// в один bigint — верхние 32 бита tenant_id, нижние 32 — source_crm_id.
|
||||
$lockKey = (($tenant->id & 0xFFFFFFFF) << 32) | ($sourceCrmId & 0xFFFFFFFF);
|
||||
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
|
||||
|
||||
$existing = DB::selectOne(
|
||||
'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
|
||||
[$tenant->id, $sourceCrmId],
|
||||
);
|
||||
|
||||
if ($existing !== null) {
|
||||
$deal = Deal::query()
|
||||
->where('id', $existing->deal_id)
|
||||
->where('received_at', $existing->deal_received_at)
|
||||
->firstOrFail();
|
||||
|
||||
$deal->update([
|
||||
'phone' => (string) $this->data['phone'],
|
||||
'phones' => $this->data['phones'] ?? [(string) $this->data['phone']],
|
||||
// status НЕ перезаписываем — менеджер мог изменить.
|
||||
]);
|
||||
|
||||
DB::table('webhook_dedup_keys')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('source_crm_id', $sourceCrmId)
|
||||
->update(['updated_at' => now()]);
|
||||
|
||||
$deal->wasRecentlyCreated = false;
|
||||
|
||||
return $deal;
|
||||
}
|
||||
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'source_crm_id' => $sourceCrmId,
|
||||
'project_id' => $project->id,
|
||||
'phone' => (string) $this->data['phone'],
|
||||
'phones' => $this->data['phones'] ?? [(string) $this->data['phone']],
|
||||
'status' => 'new',
|
||||
'received_at' => $receivedAt,
|
||||
]);
|
||||
|
||||
DB::table('webhook_dedup_keys')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'source_crm_id' => $sourceCrmId,
|
||||
'deal_id' => $deal->id,
|
||||
'deal_received_at' => $deal->received_at,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return $deal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Финальный callback после исчерпания всех ретраев ($tries=3).
|
||||
*
|
||||
* Сохраняет упавший job в `failed_webhook_jobs` для ручного разбора и
|
||||
* возможного повторного запуска через админку SaaS. RLS не задаём —
|
||||
* tenant_id из job-state передаётся как есть (failed-callback запускается
|
||||
* вне транзакции воркера). На production добавляется Sentry::captureException.
|
||||
*
|
||||
* NB: записывается через DB::table (не через FailedWebhookJob::create),
|
||||
* чтобы избежать RLS-фильтрации при отсутствии app.current_tenant_id —
|
||||
* запись должна попасть в БД даже в катастрофическом сценарии.
|
||||
*/
|
||||
public function failed(Throwable $e): void
|
||||
{
|
||||
DB::table('failed_webhook_jobs')->insert([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'webhook_log_id' => $this->webhookLogId,
|
||||
'raw_payload' => json_encode($this->data, JSON_UNESCAPED_UNICODE),
|
||||
'exception' => $e->getMessage(),
|
||||
'retry_count' => $this->tries,
|
||||
'failed_at' => now(),
|
||||
]);
|
||||
|
||||
Log::error('webhook.job_failed_permanently', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'vid' => $this->data['vid'] ?? null,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// TODO(production): Sentry::captureException($e);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
@@ -44,9 +43,7 @@ use Throwable;
|
||||
* 5. Для каждого Project — DB::transaction с SET LOCAL app.current_tenant_id:
|
||||
* - lockForUpdate Tenant.
|
||||
* - Создать Deal (source_crm_id=vid).
|
||||
* - DuplicateDetector::findMaster — если найден master !== deal, mark
|
||||
* duplicate_of_id (без charge/counter/notify, ActivityLog с duplicate_of).
|
||||
* - Иначе: LedgerService::chargeForDelivery(tenant, deal, lead) — dual-balance
|
||||
* - LedgerService::chargeForDelivery(tenant, deal, lead) — dual-balance
|
||||
* списание (prepaid balance_leads-- ИЛИ rub balance_rub-=tier_price), INSERT
|
||||
* lead_charges + balance_transactions + supplier_lead_costs внутри той же
|
||||
* транзакции. На InsufficientBalanceException — Log::warning + rethrow
|
||||
@@ -86,7 +83,6 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
public function handle(
|
||||
LeadRouter $router,
|
||||
SupplierProjectResolver $resolver,
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
LeadDistributor $distributor,
|
||||
@@ -135,7 +131,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
$failures = [];
|
||||
foreach ($selected as $project) {
|
||||
try {
|
||||
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)) {
|
||||
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $subjectCode)) {
|
||||
$createdCount++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
@@ -205,19 +201,18 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
|
||||
/**
|
||||
* Создаёт deal-копию в одной транзакции для конкретного Project.
|
||||
* Возвращает true — если копия не дубль (баланс списан, счётчики выросли).
|
||||
* false — если копия помечена дублем (без списания).
|
||||
* Возвращает true — если deal создан и баланс списан, счётчики выросли.
|
||||
* false — если лимит исчерпан под блокировкой (deal не создаётся).
|
||||
*/
|
||||
private function createDealCopyForProject(
|
||||
SupplierLead $lead,
|
||||
Project $project,
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
?int $subjectCode,
|
||||
): bool {
|
||||
try {
|
||||
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode): bool {
|
||||
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $subjectCode): bool {
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
|
||||
|
||||
/** @var Tenant $tenant */
|
||||
@@ -250,6 +245,22 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
}
|
||||
$project = $lockedProject;
|
||||
|
||||
// Spec B: per-(supplier_lead, tenant) lock — одна поставка одному клиенту = один раз.
|
||||
// insertOrIgnore вернёт 0, если строка уже существует (повтор/гонка/CSV-recovery).
|
||||
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
if ($locked === 0) {
|
||||
Log::info('supplier_lead.delivery_already_locked', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $lead->raw_payload ?? [];
|
||||
$receivedAt = isset($payload['time'])
|
||||
? Carbon::createFromTimestamp((int) $payload['time'])
|
||||
@@ -271,39 +282,10 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'subject_code' => $subjectCode,
|
||||
]);
|
||||
|
||||
$master = $duplicateDetector->findMaster(
|
||||
tenantId: (int) $tenant->id,
|
||||
phone: (string) $lead->phone,
|
||||
now: $receivedAt,
|
||||
);
|
||||
|
||||
// Только что созданный $deal сам попадает в выборку DuplicateDetector
|
||||
// (он уже в БД к моменту lookup'а), поэтому master может ===$deal.
|
||||
// Дубль — только если master найден И это НЕ сам deal.
|
||||
if ($master !== null && $master->id !== $deal->id) {
|
||||
$deal->update(['duplicate_of_id' => $master->id]);
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_CREATED,
|
||||
'context' => [
|
||||
'source' => 'supplier_webhook',
|
||||
'duplicate_of' => $master->id,
|
||||
'supplier_lead_id' => $lead->id,
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_supplier', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
DB::table('supplier_lead_deliveries')
|
||||
->where('supplier_lead_id', $lead->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->update(['deal_id' => $deal->id]);
|
||||
|
||||
// Task 6: $ledger->chargeForDelivery бросит InsufficientBalanceException —
|
||||
// транзакция откатится, и outer catch ниже отловит для auto-pause flow.
|
||||
@@ -330,8 +312,8 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
// ProcessWebhookJob-pattern: setRelation чтобы NotificationService
|
||||
// мог подтянуть deal->project без N+1 lookup'а под RLS.
|
||||
// setRelation чтобы NotificationService мог подтянуть
|
||||
// deal->project без N+1 lookup'а под RLS.
|
||||
$deal->setRelation('project', $project);
|
||||
$notifier->notifyNewLead($tenant, $deal);
|
||||
|
||||
@@ -384,7 +366,6 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'price_kopecks' => $e->priceKopecks,
|
||||
'balance_rub' => $e->balanceRub,
|
||||
'balance_leads' => $e->balanceLeads,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,9 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
foreach ($missing as $row) {
|
||||
$platform = $this->extractPlatform((string) $row['project']);
|
||||
if ($platform === null) {
|
||||
Log::warning('csv_reconcile.unparseable_project_skipped', [
|
||||
// Поставщик иногда кладёт в `project` нестандартные имена (телефон, URL).
|
||||
// Не warning — это не наш баг, processing продолжается, paper-trail на info уровне.
|
||||
Log::info('csv_reconcile.unparseable_project_skipped', [
|
||||
'project' => $row['project'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -189,10 +189,16 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Platforms skipped for a transient reason (not escalation/defer) — non-empty at the
|
||||
// end (with an active group) means the supplier set is incomplete → throw to retry.
|
||||
$retryWorthy = [];
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: one save PER platform with that platform's divided share
|
||||
// (single-flag save → exactly one rt-project, reliable id via listProjects match).
|
||||
$idMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $platforms);
|
||||
$createResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $platforms);
|
||||
$idMap = $createResult['ids'];
|
||||
$retryWorthy = array_merge($retryWorthy, $createResult['failed']);
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
@@ -233,7 +239,9 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
if ($deadSps->isNotEmpty()) {
|
||||
$deadPlatforms = array_values($deadSps->pluck('platform')->all());
|
||||
$recreatedIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $deadPlatforms);
|
||||
$deadResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $deadPlatforms);
|
||||
$recreatedIdMap = $deadResult['ids'];
|
||||
$retryWorthy = array_merge($retryWorthy, $deadResult['failed']);
|
||||
|
||||
foreach ($deadSps as $sp) {
|
||||
$newId = $recreatedIdMap[$sp->platform] ?? null;
|
||||
@@ -248,7 +256,9 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingIdMap = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $missingPlatforms);
|
||||
$missingResult = $this->createPerPlatform($client, $project, $identifier, $tag, $workdays, $allRegions, $shares, $missingPlatforms);
|
||||
$missingIdMap = $missingResult['ids'];
|
||||
$retryWorthy = array_merge($retryWorthy, $missingResult['failed']);
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
@@ -348,6 +358,21 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$project->{$column} = $sp->id;
|
||||
}
|
||||
$project->save();
|
||||
|
||||
// Atomicity guard: the 3 platforms are created by 3 sequential supplier calls. If one
|
||||
// failed for a TRANSIENT reason (network/timeout/5xx/id-not-found), the others are
|
||||
// already persisted above (progress kept) — but the supplier set is incomplete and the
|
||||
// group under-orders ~1/N. Throw so Laravel retries (backoff 15/60/300s); on retry the
|
||||
// partial-set recovery branch fills the missing platform — closing the gap in minutes
|
||||
// instead of waiting for the nightly batch. Escalation/window-defer are NOT here (they
|
||||
// have their own recovery), so they never trigger a retry.
|
||||
if ($retryWorthy !== [] && $groupActive) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'SyncSupplierProjectJob: project %d incomplete platform set (transient miss: %s) — retrying for partial-set recovery',
|
||||
$project->id,
|
||||
implode(',', array_values(array_unique($retryWorthy))),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -428,7 +453,11 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
*
|
||||
* @param array<string, int> $shares [platform => лимит площадки]
|
||||
* @param list<string> $platformsToCreate
|
||||
* @return array<string, int> [platform => external_id] для успешно созданных
|
||||
* @return array{ids: array<string, int>, failed: list<string>}
|
||||
* ids — [platform => external_id] для успешно созданных;
|
||||
* failed — площадки, пропущенные по TRANSIENT-причине (сеть/таймаут/id-not-found),
|
||||
* НЕ из-за escalation/window-defer (у тех свой механизм восстановления).
|
||||
* Непустой failed → handleOnline бросит retry-исключение.
|
||||
*/
|
||||
private function createPerPlatform(
|
||||
SupplierPortalClient $client,
|
||||
@@ -441,6 +470,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
array $platformsToCreate,
|
||||
): array {
|
||||
$idMap = [];
|
||||
$legitimateSkips = []; // escalation / window-defer — НЕ retry-worthy
|
||||
|
||||
foreach ($platformsToCreate as $platform) {
|
||||
$dto = new SupplierProjectDto(
|
||||
@@ -460,13 +490,17 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$result = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} escalated to manual queue #{$e->queueRowId}");
|
||||
$legitimateSkips[] = $platform;
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} deferred by portal window");
|
||||
$legitimateSkips[] = $platform;
|
||||
|
||||
continue;
|
||||
} catch (\Throwable $e) {
|
||||
// Transient (network/timeout/portal 5xx). NOT added to legitimateSkips →
|
||||
// remains in `failed` → handleOnline throws → Laravel retry re-runs.
|
||||
Log::warning("SyncSupplierProjectJob: online per-platform save failed for project {$project->id} {$platform} (".get_class($e).'): '.$e->getMessage());
|
||||
|
||||
continue;
|
||||
@@ -475,9 +509,13 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
if (isset($result[$platform])) {
|
||||
$idMap[$platform] = $result[$platform];
|
||||
}
|
||||
// else: save returned no id for this platform (id-not-found in listProjects) —
|
||||
// treat as transient: not in idMap, not in legitimateSkips → falls into `failed`.
|
||||
}
|
||||
|
||||
return $idMap;
|
||||
$failed = array_values(array_diff($platformsToCreate, array_keys($idMap), $legitimateSkips));
|
||||
|
||||
return ['ids' => $idMap, 'failed' => $failed];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Уведомление о разрыве hash-chain в audit-таблице.
|
||||
*
|
||||
* Триггер: команда audit:verify-chains обнаружила несовпадение
|
||||
* stored vs recomputed SHA-256 hash — признак tampering.
|
||||
*
|
||||
* Отправляется на kdv1@bk.ru (monitoring email).
|
||||
*/
|
||||
final class AuditChainBreachMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $tableName,
|
||||
public readonly int $firstBrokenId,
|
||||
public readonly int $mismatchCount,
|
||||
public readonly ?string $partitionName = null, // v8.31: partition where breach was detected
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = $this->partitionName !== null && $this->partitionName !== $this->tableName
|
||||
? "[Лидерра CRITICAL] Разрыв hash-chain в {$this->partitionName}"
|
||||
: "[Лидерра CRITICAL] Разрыв hash-chain в {$this->tableName}";
|
||||
|
||||
return new Envelope(subject: $subject);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'emails.audit_chain_breach_text',
|
||||
with: [
|
||||
'tableName' => $this->tableName,
|
||||
'partitionName' => $this->partitionName ?? $this->tableName,
|
||||
'firstBrokenId' => $this->firstBrokenId,
|
||||
'mismatchCount' => $this->mismatchCount,
|
||||
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Уведомление об автоматически обнаруженном инциденте.
|
||||
*
|
||||
* Отправляется только для severity=high командой incidents:watch-failures.
|
||||
* Subject: [Лидерра HIGH] Incident: {summary first 100}.
|
||||
*/
|
||||
final class IncidentDetectedMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $summary,
|
||||
public readonly string $severity,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subjectSnippet = mb_substr($this->summary, 0, 100);
|
||||
|
||||
return new Envelope(
|
||||
subject: "[Лидерра HIGH] Incident: {$subjectSnippet}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'emails.incident_detected_text',
|
||||
with: [
|
||||
'summary' => $this->summary,
|
||||
'severity' => $this->severity,
|
||||
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Email-уведомление о низком балансе (ТЗ §18.5, событие low_balance).
|
||||
*
|
||||
* Триггер: tenant.balance_leads <= system_settings.low_balance_threshold_leads
|
||||
* (default 10) после lead_charge в ProcessWebhookJob.
|
||||
*/
|
||||
class LowBalanceNotification extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $recipient,
|
||||
public Tenant $tenant,
|
||||
public int $thresholdLeads,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Лидерра. Низкий баланс — пополните, чтобы не пропустить лиды',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.low_balance',
|
||||
with: [
|
||||
'recipient' => $this->recipient,
|
||||
'tenant' => $this->tenant,
|
||||
'thresholdLeads' => $this->thresholdLeads,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use Illuminate\Queue\SerializesModels;
|
||||
*
|
||||
* Отправляется получателям тенанта, у которых в notification_preferences
|
||||
* включён канал email для события new_lead. Триггер — успешное создание
|
||||
* сделки в ProcessWebhookJob::chargeNewLead.
|
||||
* сделки в RouteSupplierLeadJob.
|
||||
*/
|
||||
class NewLeadNotification extends Mailable
|
||||
{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
|
||||
/**
|
||||
* Уведомление о пропавшем или постоянно падающем cron-задаче.
|
||||
*
|
||||
* Триггер: SchedulerCheckHeartbeats обнаружил:
|
||||
* • отсутствие пульса > 2× ожидаемого интервала, ИЛИ
|
||||
* • consecutive_failures >= 3.
|
||||
*
|
||||
* Отправляется на kdv1@bk.ru (monitoring email).
|
||||
*/
|
||||
final class SchedulerHeartbeatMissingMail extends Mailable
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $commandName,
|
||||
public readonly string $reason,
|
||||
public readonly ?string $lastError,
|
||||
public readonly int $consecutiveFailures,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: "[Лидерра HIGH] Scheduler heartbeat missing: {$this->commandName}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
text: 'emails.scheduler_heartbeat_missing_text',
|
||||
with: [
|
||||
'commandName' => $this->commandName,
|
||||
'reason' => $this->reason,
|
||||
'lastError' => $this->lastError,
|
||||
'consecutiveFailures' => $this->consecutiveFailures,
|
||||
'now' => now()->timezone('Europe/Moscow')->toIso8601String(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Email-уведомление о нулевом балансе и отклонении лидов (ТЗ §18.5,
|
||||
* событие zero_balance).
|
||||
*
|
||||
* Триггер: ProcessWebhookJob::logRejection(reason=zero_balance) — после
|
||||
* первого RejectedDealsLog в течение последнего часа (anti-spam: не больше
|
||||
* 1 email в час на тенант).
|
||||
*/
|
||||
class ZeroBalanceNotification extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $recipient,
|
||||
public Tenant $tenant,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Лидерра. Баланс закончился — лиды отклоняются',
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.zero_balance',
|
||||
with: [
|
||||
'recipient' => $this->recipient,
|
||||
'tenant' => $this->tenant,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ class BalanceTransaction extends Model
|
||||
|
||||
public const TYPE_CHARGEBACK_REPAYMENT = 'chargeback_repayment';
|
||||
|
||||
public const TYPE_MIGRATION = 'migration';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
|
||||
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Webhook-job упавший после 3 ретраев (см. ProcessWebhookJob::failed()).
|
||||
* Webhook-job упавший после 3 ретраев (см. RouteSupplierLeadJob::failed()).
|
||||
*
|
||||
* Tenant-aware с RLS. Хранит raw payload + текст исключения для ручного
|
||||
* retry из админки SaaS (`retried_at`/`retried_by` заполняются админом).
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Обращение субъекта ПДн (152-ФЗ).
|
||||
*
|
||||
* SaaS-уровневая таблица — RLS не применяется. Доступ только из
|
||||
* AdminPdSubjectRequestsController под saas-admin middleware.
|
||||
*
|
||||
* @property int $id
|
||||
* @property string $received_at
|
||||
* @property string|null $subject_email
|
||||
* @property string|null $subject_phone
|
||||
* @property string|null $subject_full_name
|
||||
* @property string $request_type access|rectification|deletion|objection
|
||||
* @property string|null $description
|
||||
* @property string $status received|in_progress|completed|rejected
|
||||
* @property int|null $tenant_id
|
||||
* @property int|null $assigned_admin_id
|
||||
* @property string|null $response_sent_at
|
||||
* @property string|null $response_text
|
||||
* @property string $deadline_at
|
||||
* @property string|null $completed_at
|
||||
* @property bool $processing_restricted
|
||||
*/
|
||||
class PdSubjectRequest extends Model
|
||||
{
|
||||
/**
|
||||
* SaaS-уровневая таблица — crm_app_user (default) не имеет INSERT/UPDATE прав.
|
||||
* Используем pgsql_supplier (BYPASSRLS / crm_supplier_worker), который имеет
|
||||
* полный доступ. Альтернатива — GRANT для crm_app_user, но это размывает
|
||||
* границу tenant-уровня (см. db/00_create_roles.sql).
|
||||
*/
|
||||
protected $connection = 'pgsql_supplier';
|
||||
|
||||
protected $table = 'pd_subject_requests';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
/** @var list<string> */
|
||||
protected $fillable = [
|
||||
'received_at',
|
||||
'subject_email',
|
||||
'subject_phone',
|
||||
'subject_full_name',
|
||||
'request_type',
|
||||
'description',
|
||||
'status',
|
||||
'tenant_id',
|
||||
'assigned_admin_id',
|
||||
'response_sent_at',
|
||||
'response_text',
|
||||
'deadline_at',
|
||||
'completed_at',
|
||||
'processing_restricted',
|
||||
];
|
||||
|
||||
/** @var array<string, string> */
|
||||
protected $casts = [
|
||||
'received_at' => 'datetime',
|
||||
'response_sent_at' => 'datetime',
|
||||
'deadline_at' => 'datetime',
|
||||
'completed_at' => 'datetime',
|
||||
'processing_restricted' => 'boolean',
|
||||
'tenant_id' => 'integer',
|
||||
'assigned_admin_id' => 'integer',
|
||||
];
|
||||
|
||||
/** Тенант, к которому относится обращение (nullable). */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* SaaS-админ, назначенный исполнителем.
|
||||
*
|
||||
* NB: модель SaasAdminUser не создана — используем User как фиктивный базис.
|
||||
* В реальном коде — DB::table('saas_admin_users') напрямую в контроллере.
|
||||
*/
|
||||
// assignedAdmin: нет Eloquent-модели SaasAdminUser — читается напрямую через DB
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Лог отвергнутых webhook'ов (примеры reason: zero_balance, validation_failed).
|
||||
*
|
||||
* Хранится бессрочно (опционально 12 месяцев) — при пополнении баланса
|
||||
* админка SaaS может массово восстановить отвергнутые лиды.
|
||||
*
|
||||
* Tenant-aware с RLS. webhook_log_id — soft FK на webhook_log
|
||||
* (опциональный, NULL для прямых validation-отказов).
|
||||
*
|
||||
* Источник: db/schema.sql v8.7 §6, table `rejected_deals_log`.
|
||||
*
|
||||
* @mixin IdeHelperRejectedDealsLog
|
||||
*/
|
||||
class RejectedDealsLog extends Model
|
||||
{
|
||||
public const REASON_ZERO_BALANCE = 'zero_balance';
|
||||
|
||||
public const REASON_VALIDATION_FAILED = 'validation_failed';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $table = 'rejected_deals_log';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'webhook_log_id',
|
||||
'reason',
|
||||
'payload',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => 'integer',
|
||||
'webhook_log_id' => 'integer',
|
||||
'payload' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* Себестоимость каждого лида (Ю-2: реселлерская модель).
|
||||
*
|
||||
* Партиционирована по `received_at` синхронно с `deals` — composite PK
|
||||
* (id, received_at). В `ProcessWebhookJob` создаётся в той же транзакции,
|
||||
* что и Deal + BalanceTransaction.
|
||||
* (id, received_at). Создаётся в `LedgerService::chargeForDelivery` в той же
|
||||
* транзакции, что и Deal + BalanceTransaction.
|
||||
*
|
||||
* cost_rub — snapshot suppliers.cost_rub на момент приёма (исторические
|
||||
* записи не пересчитываются при изменении закупочной цены, см. §20.12.5).
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Замок «поставка ↔ клиент» (Billing v2 Spec B). Композитный PK без автоинкремента.
|
||||
* Пишется в шеринг-пути (RouteSupplierLeadJob) через insertOrIgnore под RLS-контекстом.
|
||||
*
|
||||
* @property int $supplier_lead_id
|
||||
* @property int $tenant_id
|
||||
* @property int|null $deal_id
|
||||
* @property string $created_at
|
||||
*/
|
||||
class SupplierLeadDelivery extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = null;
|
||||
|
||||
protected $fillable = ['supplier_lead_id', 'tenant_id', 'deal_id', 'created_at'];
|
||||
}
|
||||
@@ -31,8 +31,6 @@ class Tenant extends Model
|
||||
'subdomain',
|
||||
'organization_name',
|
||||
'contact_email',
|
||||
'webhook_token',
|
||||
'webhook_token_rotated_at',
|
||||
'timezone',
|
||||
'locale',
|
||||
'current_tariff_id',
|
||||
@@ -61,7 +59,6 @@ class Tenant extends Model
|
||||
'api_key_limit' => 'integer',
|
||||
// JSONB: {"max_users":5,"max_projects":10,"api_rps":60}
|
||||
'limits' => 'array',
|
||||
'webhook_token_rotated_at' => 'datetime',
|
||||
'last_activity_at' => 'datetime',
|
||||
'last_webhook_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
/**
|
||||
* Pure: «при балансе ₽ и доставленных в этом месяце N — сколько лидов клиент
|
||||
* получит, проходя ступени pricing_tiers».
|
||||
*
|
||||
* Все мутации денег — bcmath (string-int копейки), без PHP float.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md §3.3.1
|
||||
*/
|
||||
final class BalanceToLeadsConverter
|
||||
{
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
* @return array{
|
||||
* leads: int,
|
||||
* breakdown: list<array{tier_no:int, leads:int, price_rub:string}>,
|
||||
* current_tier: array{no:int, price_rub:string, leads_left_in_tier:int}|null,
|
||||
* next_tier: array{no:int, price_rub:string, leads_in_tier:int}|null
|
||||
* }
|
||||
*/
|
||||
public function convert(string $balanceRub, int $deliveredInMonth, Collection $tiers): array
|
||||
{
|
||||
$balanceKopecks = bcmul($balanceRub, '100', 0);
|
||||
/** @var Collection<int, PricingTier> $sorted */
|
||||
$sorted = $tiers
|
||||
->filter(fn (PricingTier $t) => (bool) $t->is_active)
|
||||
->sortBy('tier_no')
|
||||
->values();
|
||||
|
||||
$totalLeads = 0;
|
||||
$breakdown = [];
|
||||
$cumulative = 0;
|
||||
$currentTier = null;
|
||||
$currentTierIndex = null;
|
||||
|
||||
foreach ($sorted as $index => $tier) {
|
||||
$tierCap = $tier->leads_in_tier === null ? PHP_INT_MAX : (int) $tier->leads_in_tier;
|
||||
$tierEnd = $cumulative + $tierCap;
|
||||
|
||||
// «Текущая ступень» — первая ступень, в которую попадает следующий лид
|
||||
// (deliveredInMonth + 1), т.е. первая где deliveredInMonth < tierEnd.
|
||||
if ($currentTier === null && $deliveredInMonth < $tierEnd) {
|
||||
$slotsLeftForInfo = $tier->leads_in_tier === null
|
||||
? PHP_INT_MAX
|
||||
: max(0, $tierEnd - max($cumulative, $deliveredInMonth));
|
||||
$currentTier = [
|
||||
'no' => (int) $tier->tier_no,
|
||||
'price_rub' => self::kopecksToRub((int) $tier->price_per_lead_kopecks),
|
||||
'leads_left_in_tier' => $slotsLeftForInfo,
|
||||
];
|
||||
$currentTierIndex = $index;
|
||||
}
|
||||
|
||||
// Слоты в этой ступени, доступные для новых лидов
|
||||
$slotsLeftInTier = max(0, $tierEnd - max($cumulative, $deliveredInMonth));
|
||||
if ($slotsLeftInTier <= 0) {
|
||||
$cumulative = $tierEnd;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$priceKopecks = (int) $tier->price_per_lead_kopecks;
|
||||
if ($priceKopecks <= 0) {
|
||||
$totalLeads += $slotsLeftInTier;
|
||||
$breakdown[] = [
|
||||
'tier_no' => (int) $tier->tier_no,
|
||||
'leads' => $slotsLeftInTier,
|
||||
'price_rub' => '0.00',
|
||||
];
|
||||
if ($tier->leads_in_tier === null) {
|
||||
break;
|
||||
}
|
||||
$cumulative = $tierEnd;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$affordableInTier = (int) bcdiv($balanceKopecks, (string) $priceKopecks, 0);
|
||||
$take = min($slotsLeftInTier, $affordableInTier);
|
||||
|
||||
if ($take > 0) {
|
||||
$totalLeads += $take;
|
||||
$breakdown[] = [
|
||||
'tier_no' => (int) $tier->tier_no,
|
||||
'leads' => $take,
|
||||
'price_rub' => self::kopecksToRub($priceKopecks),
|
||||
];
|
||||
$balanceKopecks = bcsub(
|
||||
$balanceKopecks,
|
||||
bcmul((string) $priceKopecks, (string) $take, 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
if ($take < $slotsLeftInTier) {
|
||||
// Balance exhausted within this tier — stop
|
||||
break;
|
||||
}
|
||||
|
||||
if ($tier->leads_in_tier === null) {
|
||||
// Unlimited tier fully consumed (shouldn't happen with real balance)
|
||||
break;
|
||||
}
|
||||
|
||||
$cumulative = $tierEnd;
|
||||
}
|
||||
|
||||
// next_tier: the first active tier whose tier_no > current_tier, if any
|
||||
$nextTier = null;
|
||||
if ($currentTier !== null && $currentTierIndex !== null) {
|
||||
for ($i = $currentTierIndex + 1; $i < $sorted->count(); $i++) {
|
||||
/** @var PricingTier $candidate */
|
||||
$candidate = $sorted[$i];
|
||||
$nextTier = [
|
||||
'no' => (int) $candidate->tier_no,
|
||||
'price_rub' => self::kopecksToRub((int) $candidate->price_per_lead_kopecks),
|
||||
'leads_in_tier' => $candidate->leads_in_tier === null ? 0 : (int) $candidate->leads_in_tier,
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'leads' => $totalLeads,
|
||||
'breakdown' => $breakdown,
|
||||
'current_tier' => $currentTier,
|
||||
'next_tier' => $nextTier,
|
||||
];
|
||||
}
|
||||
|
||||
private static function kopecksToRub(int $kopecks): string
|
||||
{
|
||||
return bcdiv((string) $kopecks, '100', 2);
|
||||
}
|
||||
}
|
||||
@@ -7,12 +7,14 @@ namespace App\Services\Billing;
|
||||
use App\Models\PricingTier;
|
||||
|
||||
/**
|
||||
* Read-only DTO с результатом charge'а: source (prepaid/rub), снимок ступени, цена в копейках.
|
||||
* Read-only DTO с результатом charge'а: снимок ступени и цена в копейках.
|
||||
*
|
||||
* Billing v2 Spec A: поле `$source` убрано (prepaid-ветка ликвидирована,
|
||||
* все списания всегда rub). Источник списания смотри в `LeadCharge::charge_source`.
|
||||
*/
|
||||
final readonly class ChargeResult
|
||||
{
|
||||
public function __construct(
|
||||
public string $source,
|
||||
public PricingTier $tier,
|
||||
public int $priceKopecks,
|
||||
) {}
|
||||
|
||||
@@ -19,14 +19,15 @@ use Illuminate\Support\Facades\DB;
|
||||
* Командный сервис биллинга на горячем пути доставки лида.
|
||||
*
|
||||
* Контракт: вызывается ВНУТРИ открытой DB-транзакции под lockForUpdate(Tenant).
|
||||
* Применяет dual-balance flow:
|
||||
* Применяет always-rub flow (Billing v2 Spec A — prepaid-лиды ликвидированы):
|
||||
* 1. tier-lookup по tenants.delivered_in_month + 1
|
||||
* 2. prepaid: balance_leads--, lead_charges (price=0)
|
||||
* 3. rub: balance_rub -= price/100 (bcmath), lead_charges (price=tier)
|
||||
* 4. INSERT supplier_lead_costs (gap-fix sharing-flow)
|
||||
* 5. INSERT balance_transactions (universal ledger движения баланса)
|
||||
* 2. bcmath проверка balance_rub × 100 ≥ priceKopecks; иначе throw
|
||||
* 3. balance_rub -= price/100 (bcmath)
|
||||
* 4. INSERT lead_charges (charge_source='rub')
|
||||
* 5. INSERT balance_transactions (amount_leads=null, amount_rub отрицательное)
|
||||
* 6. INSERT supplier_lead_costs (gap-fix sharing-flow)
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §3
|
||||
* Spec: docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md §4.2
|
||||
*/
|
||||
final class LedgerService
|
||||
{
|
||||
@@ -36,7 +37,7 @@ final class LedgerService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws InsufficientBalanceException когда balance_leads=0 AND balance_rub*100<priceKopecks.
|
||||
* @throws InsufficientBalanceException когда balance_rub * 100 < priceKopecks.
|
||||
* До throw НЕ модифицирует tenant/charges/transactions/costs.
|
||||
*
|
||||
* @precondition caller wraps in DB::transaction with lockForUpdate($lockedTenant).
|
||||
@@ -48,54 +49,55 @@ final class LedgerService
|
||||
Deal $deal,
|
||||
?SupplierLead $lead = null,
|
||||
): ChargeResult {
|
||||
// 1. tier-resolution для (delivered_in_month + 1)-го лида
|
||||
$activeTiers = $this->tiers->activeAt(Carbon::now('Europe/Moscow'));
|
||||
$tier = $this->resolver->resolveForCount($activeTiers, ($lockedTenant->delivered_in_month ?? 0) + 1);
|
||||
$tier = $this->resolver->resolveForCount(
|
||||
$activeTiers,
|
||||
($lockedTenant->delivered_in_month ?? 0) + 1
|
||||
);
|
||||
$priceKopecks = (int) $tier->price_per_lead_kopecks;
|
||||
|
||||
// 2. Decide chargeSource (bcmath — НЕ PHP float)
|
||||
$source = $this->decideSource($lockedTenant, $priceKopecks);
|
||||
|
||||
// 3. Apply (bcmath для money; raw DB::update — Eloquent decrement() требует float|int,
|
||||
// что несовместимо с string-precision arithmetic для копеек/рублей).
|
||||
if ($source === 'prepaid') {
|
||||
$lockedTenant->decrement('balance_leads', 1);
|
||||
} else {
|
||||
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
|
||||
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
|
||||
DB::table('tenants')
|
||||
->where('id', $lockedTenant->id)
|
||||
->update(['balance_rub' => $newBalanceRub]);
|
||||
// bcmath: balance_rub × 100 ≥ priceKopecks — единственный путь списания.
|
||||
// Billing v2 Spec A: prepaid-лиды убраны, balance_leads НЕ читается и НЕ изменяется.
|
||||
$balanceKopecks = bcmul((string) $lockedTenant->balance_rub, '100', 0);
|
||||
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) < 0) {
|
||||
throw new InsufficientBalanceException(
|
||||
priceKopecks: $priceKopecks,
|
||||
balanceRub: (string) $lockedTenant->balance_rub,
|
||||
);
|
||||
}
|
||||
|
||||
$amountRub = bcdiv((string) $priceKopecks, '100', 2);
|
||||
$newBalanceRub = bcsub((string) $lockedTenant->balance_rub, $amountRub, 2);
|
||||
DB::table('tenants')
|
||||
->where('id', $lockedTenant->id)
|
||||
->update(['balance_rub' => $newBalanceRub]);
|
||||
|
||||
$lockedTenant->increment('delivered_in_month', 1);
|
||||
$lockedTenant->refresh();
|
||||
|
||||
// 4. INSERT lead_charges (always)
|
||||
LeadCharge::create([
|
||||
'tenant_id' => $lockedTenant->id,
|
||||
'deal_id' => $deal->id,
|
||||
'deal_received_at' => $deal->received_at,
|
||||
'tier_no' => $tier->tier_no,
|
||||
'price_per_lead_kopecks' => $source === 'prepaid' ? 0 : $priceKopecks,
|
||||
'charge_source' => $source,
|
||||
'price_per_lead_kopecks' => $priceKopecks,
|
||||
'charge_source' => 'rub',
|
||||
'charged_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// 5. INSERT balance_transactions (универсальный ledger)
|
||||
BalanceTransaction::create([
|
||||
'tenant_id' => $lockedTenant->id,
|
||||
'type' => BalanceTransaction::TYPE_LEAD_CHARGE,
|
||||
'amount_leads' => $source === 'prepaid' ? -1 : 0,
|
||||
'amount_rub' => $source === 'rub' ? '-'.bcdiv((string) $priceKopecks, '100', 2) : '0.00',
|
||||
'balance_leads_after' => (int) $lockedTenant->balance_leads,
|
||||
'amount_leads' => null,
|
||||
'amount_rub' => '-'.$amountRub,
|
||||
'balance_leads_after' => null,
|
||||
'balance_rub_after' => (string) $lockedTenant->balance_rub,
|
||||
'related_type' => Deal::class,
|
||||
'related_id' => $deal->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// 6. INSERT supplier_lead_costs (gap-fix Plan 2/3 sharing-flow)
|
||||
if ($lead !== null) {
|
||||
$supplierId = $this->resolveSupplierId($lead);
|
||||
if ($supplierId !== null) {
|
||||
@@ -111,26 +113,7 @@ final class LedgerService
|
||||
}
|
||||
}
|
||||
|
||||
return new ChargeResult($source, $tier, $source === 'prepaid' ? 0 : $priceKopecks);
|
||||
}
|
||||
|
||||
private function decideSource(Tenant $tenant, int $priceKopecks): string
|
||||
{
|
||||
if ((int) $tenant->balance_leads >= 1) {
|
||||
return 'prepaid';
|
||||
}
|
||||
|
||||
// bcmath: balance_rub (DECIMAL string) * 100 ≥ priceKopecks → можем списать rub
|
||||
$balanceKopecks = bcmul((string) $tenant->balance_rub, '100', 0);
|
||||
if (bccomp($balanceKopecks, (string) $priceKopecks, 0) >= 0) {
|
||||
return 'rub';
|
||||
}
|
||||
|
||||
throw new InsufficientBalanceException(
|
||||
priceKopecks: $priceKopecks,
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
balanceLeads: (int) $tenant->balance_leads,
|
||||
);
|
||||
return new ChargeResult($tier, $priceKopecks);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Deal;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Антифрод-дедуп лидов по `(tenant_id, phone)` в окне 24 ч (Биз-19, §10.8.1).
|
||||
*
|
||||
* Цель: в pay-per-lead-сегменте поставщик может прислать одно физлицо дважды
|
||||
* (двойной submit формы / повторный звонок) — без защиты клиент платит за оба.
|
||||
*
|
||||
* Стратегия: ищем master-сделку (запись без `duplicate_of_id`) с тем же
|
||||
* `(tenant_id, phone)` и `received_at >= NOW() - INTERVAL '24 hours'`.
|
||||
* Если найдена — новая сделка получает `duplicate_of_id = master.id` и
|
||||
* НЕ списывает с баланса.
|
||||
*
|
||||
* Окно фиксированное 24 ч (не настраивается на MVP) — компромисс между
|
||||
* антифродом и легитимными повторными интересами.
|
||||
*
|
||||
* Цепочки не строятся: дубль ссылается ТОЛЬКО на master (запись без
|
||||
* `duplicate_of_id`), не на другой дубль. Если master найден среди дублей —
|
||||
* берётся его собственный `duplicate_of_id` (root master).
|
||||
*
|
||||
* Performance: существующий индекс `(tenant_id, phone)` достаточен, см. §10.8.1.
|
||||
*/
|
||||
class DuplicateDetector
|
||||
{
|
||||
public const WINDOW_HOURS = 24;
|
||||
|
||||
/**
|
||||
* Поиск master-сделки для (tenantId, phone) в окне 24 ч.
|
||||
*
|
||||
* Возвращает Deal-объект master'а либо null если master не найден.
|
||||
* Текущий момент `now` параметризуется для тестируемости — в production
|
||||
* по умолчанию `Carbon::now()`.
|
||||
*/
|
||||
public function findMaster(int $tenantId, string $phone, ?Carbon $now = null): ?Deal
|
||||
{
|
||||
$now ??= Carbon::now();
|
||||
$windowStart = $now->copy()->subHours(self::WINDOW_HOURS);
|
||||
|
||||
return Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('phone', $phone)
|
||||
->where('received_at', '>=', $windowStart)
|
||||
->whereNull('duplicate_of_id')
|
||||
->orderBy('received_at')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ final class CsvLeadsParser
|
||||
return null;
|
||||
}
|
||||
|
||||
// Префикс B[123]_ из названия проекта срезается (паритет с ProcessWebhookJob).
|
||||
// Префикс B[123]_ из названия проекта срезается (паритет с RouteSupplierLeadJob парсером).
|
||||
$projectName = (string) preg_replace('/^B[123]_/', '', trim($project));
|
||||
if ($projectName === '') {
|
||||
$errors[] = ['line' => $dataLine, 'message' => 'Пустое название проекта'];
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
|
||||
@@ -28,6 +29,17 @@ use Illuminate\Support\Collection;
|
||||
class LeadRouter
|
||||
{
|
||||
/**
|
||||
* Возвращает ONE project per tenant_id — тот, у которого наибольший остаток
|
||||
* дневного лимита (DISTINCT ON (tenant_id) с ORDER BY remaining DESC, created_at, id).
|
||||
*
|
||||
* Семантика (Spec B Task 3): один лид продаётся не более чем 3 РАЗЛИЧНЫМ тенантам
|
||||
* (клиентам), каждый тенант получает ровно ОДИН проект — с наибольшим остатком.
|
||||
* LeadDistributor::selectRecipients (CAP=3) теперь ограничивает число тенантов,
|
||||
* а не число проектов, потому что входные данные уже one-per-tenant.
|
||||
*
|
||||
* Запрос через pgsql_supplier (BYPASSRLS crm_supplier_worker) — tenant ещё не
|
||||
* определён, SELECT видит проекты всех tenant'ов.
|
||||
*
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
|
||||
@@ -35,30 +47,31 @@ class LeadRouter
|
||||
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
/** @var Collection<int, Project> $candidates */
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->whereExists(function ($q) use ($supplierProject): void {
|
||||
$q->selectRaw('1')
|
||||
->from('project_supplier_links')
|
||||
->whereColumn('project_supplier_links.project_id', 'projects.id')
|
||||
->where('project_supplier_links.supplier_project_id', $supplierProject->id);
|
||||
})
|
||||
->where('is_active', true)
|
||||
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
|
||||
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
|
||||
->whereExists(function ($q): void {
|
||||
$q->selectRaw('1')
|
||||
->from('tenants')
|
||||
->whereColumn('tenants.id', 'projects.tenant_id')
|
||||
->where(function ($qq): void {
|
||||
$qq->where('tenants.balance_leads', '>', 0)
|
||||
->orWhere('tenants.balance_rub', '>', 0);
|
||||
});
|
||||
})
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM project_supplier_links psl
|
||||
WHERE psl.project_id = projects.id
|
||||
AND psl.supplier_project_id = ?
|
||||
)
|
||||
AND projects.is_active = true
|
||||
AND (projects.delivery_days_mask & ?) <> 0
|
||||
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = projects.tenant_id
|
||||
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
|
||||
)
|
||||
ORDER BY
|
||||
projects.tenant_id,
|
||||
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
|
||||
return $candidates->values();
|
||||
$rows = DB::connection('pgsql_supplier')->select($sql, [$supplierProject->id, $todayBit]);
|
||||
|
||||
return Project::hydrate($rows)->values();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,19 +9,55 @@ use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Создаёт месячные RANGE-партиции для таблиц, партиционированных по received_at.
|
||||
* Создаёт месячные RANGE-партиции для таблиц, партиционированных помесячно.
|
||||
*
|
||||
* Native-замена pg_partman (расширение недоступно на Windows-стеке без сборки
|
||||
* из исходников). Идемпотентна: партиция, которая уже есть, пропускается.
|
||||
*
|
||||
* Используется:
|
||||
* - cron `partitions:create-months` — N месяцев вперёд;
|
||||
* - `partitions:drop-expired` — дропает старые партиции;
|
||||
* - HistoricalImportService — под исторический диапазон дат CSV.
|
||||
*
|
||||
* Hole #2 (23.05.2026): расширен до 9 таблиц (+7 audit-таблиц).
|
||||
* Ключ партиционирования теперь задаётся per-table в PARTITIONED_TABLES map.
|
||||
*/
|
||||
class MonthlyPartitionManager
|
||||
{
|
||||
/** @var array<int, string> Таблицы, партиционированные по received_at помесячно. */
|
||||
public const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
|
||||
/**
|
||||
* Connection used for partition DDL (CREATE / DROP).
|
||||
*
|
||||
* На проде партиционированные родители принадлежат `crm_migrator`;
|
||||
* `crm_supplier_worker` — член `crm_migrator` (см. db/02_grants.sql),
|
||||
* поэтому через `pgsql_supplier` создаёт/дропает партиции, а
|
||||
* дефолтный `crm_app_user` — нет. На dev/тестах `pgsql_supplier`
|
||||
* фоллбэчит на `postgres` (superuser) — DDL также проходит.
|
||||
*
|
||||
* Тесты, триггерящие CREATE/DROP через менеджер, должны подключать
|
||||
* `Tests\Concerns\SharesSupplierPdo`, иначе DDL уйдёт мимо
|
||||
* test-транзакции (см. trait doc).
|
||||
*/
|
||||
public const DDL_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/**
|
||||
* Таблицы, партиционированные помесячно.
|
||||
* Ключ → имя таблицы, значение → колонка-ключ партиционирования.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
public const PARTITIONED_TABLES = [
|
||||
// Бизнес-таблицы (исходные)
|
||||
'deals' => 'received_at',
|
||||
'supplier_lead_costs' => 'received_at',
|
||||
// Audit-таблицы (hole #2, 23.05.2026)
|
||||
'auth_log' => 'created_at',
|
||||
'activity_log' => 'created_at',
|
||||
'tenant_operations_log' => 'created_at',
|
||||
// webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts (legacy direct webhook removal)
|
||||
'balance_transactions' => 'created_at',
|
||||
'pd_processing_log' => 'created_at',
|
||||
'saas_admin_audit_log' => 'created_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* Гарантирует наличие месячных партиций таблицы для всех месяцев,
|
||||
@@ -31,9 +67,7 @@ class MonthlyPartitionManager
|
||||
*/
|
||||
public function ensureRange(string $table, CarbonInterface $from, CarbonInterface $to): int
|
||||
{
|
||||
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
|
||||
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
|
||||
}
|
||||
$this->assertKnownTable($table);
|
||||
|
||||
$month = $from->copy()->startOfMonth();
|
||||
$last = $to->copy()->startOfMonth();
|
||||
@@ -53,13 +87,14 @@ class MonthlyPartitionManager
|
||||
*/
|
||||
public function ensureMonth(string $table, CarbonInterface $monthStart): bool
|
||||
{
|
||||
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
|
||||
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
|
||||
}
|
||||
$this->assertKnownTable($table);
|
||||
|
||||
$partitionKey = self::PARTITIONED_TABLES[$table];
|
||||
$start = $monthStart->copy()->startOfMonth();
|
||||
$end = $start->copy()->addMonth();
|
||||
$partition = sprintf('%s_%s', $table, $start->format('Y_m'));
|
||||
|
||||
// Partition naming: <table>_y<YYYY>_m<MM>
|
||||
$partition = sprintf('%s_y%s_m%s', $table, $start->format('Y'), $start->format('m'));
|
||||
|
||||
$exists = DB::selectOne(
|
||||
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
|
||||
@@ -70,7 +105,7 @@ class MonthlyPartitionManager
|
||||
return false;
|
||||
}
|
||||
|
||||
DB::statement(sprintf(
|
||||
DB::connection(self::DDL_CONNECTION)->statement(sprintf(
|
||||
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
|
||||
$partition,
|
||||
$table,
|
||||
@@ -80,4 +115,45 @@ class MonthlyPartitionManager
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает имя партиции для заданной таблицы и месяца.
|
||||
* Утилита для тестов и команды drop-expired.
|
||||
*/
|
||||
public function partitionName(string $table, CarbonInterface $monthStart): string
|
||||
{
|
||||
$this->assertKnownTable($table);
|
||||
$start = $monthStart->copy()->startOfMonth();
|
||||
|
||||
return sprintf('%s_y%s_m%s', $table, $start->format('Y'), $start->format('m'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список существующих партиций для таблицы через pg_inherits.
|
||||
*
|
||||
* @return list<string> Имена партиций (relname).
|
||||
*/
|
||||
public function listPartitions(string $table): array
|
||||
{
|
||||
$this->assertKnownTable($table);
|
||||
|
||||
$rows = DB::select(
|
||||
'SELECT c.relname
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
JOIN pg_class p ON p.oid = i.inhparent
|
||||
WHERE p.relname = ?
|
||||
ORDER BY c.relname',
|
||||
[$table],
|
||||
);
|
||||
|
||||
return array_map(fn ($r) => $r->relname, $rows);
|
||||
}
|
||||
|
||||
private function assertKnownTable(string $table): void
|
||||
{
|
||||
if (! array_key_exists($table, self::PARTITIONED_TABLES)) {
|
||||
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,9 @@ declare(strict_types=1);
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\InvoicePaidNotification;
|
||||
use App\Mail\LowBalanceNotification;
|
||||
use App\Mail\NewLeadNotification;
|
||||
use App\Mail\ReminderDueNotification;
|
||||
use App\Mail\TopupSuccessNotification;
|
||||
use App\Mail\ZeroBalanceNotification;
|
||||
use App\Mail\ZeroBalancePausedMail;
|
||||
use App\Models\Deal;
|
||||
use App\Models\InAppNotification;
|
||||
@@ -147,52 +145,6 @@ class NotificationService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомление о низком балансе. Триггер: ProcessWebhookJob после
|
||||
* lead_charge, если balance_leads <= threshold.
|
||||
*
|
||||
* Получатели: все активные user'ы тенанта с new_lead.email=true
|
||||
* (на MVP: те же что и для new_lead — обычно владелец и менеджеры).
|
||||
* По prefs `low_balance.email`.
|
||||
*/
|
||||
public function notifyLowBalance(Tenant $tenant, int $thresholdLeads): void
|
||||
{
|
||||
$title = "Низкий баланс — {$tenant->balance_leads} лидов осталось";
|
||||
$body = "Порог уведомления: {$thresholdLeads} лидов";
|
||||
|
||||
foreach ($this->recipientsForEvent($tenant, self::EVENT_LOW_BALANCE, self::CHANNEL_EMAIL) as $user) {
|
||||
$this->sendEmail($user, self::EVENT_LOW_BALANCE, new LowBalanceNotification($user, $tenant, $thresholdLeads));
|
||||
}
|
||||
foreach ($this->recipientsForEvent($tenant, self::EVENT_LOW_BALANCE, self::CHANNEL_INAPP) as $user) {
|
||||
$this->notifyInApp($user, self::EVENT_LOW_BALANCE, $title, $body, [
|
||||
'tenant_id' => $tenant->id,
|
||||
'balance_leads' => $tenant->balance_leads,
|
||||
'threshold_leads' => $thresholdLeads,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомление о нулевом балансе и отклонении лидов.
|
||||
* Триггер: ProcessWebhookJob::logRejection(zero_balance) в первом
|
||||
* RejectedDealsLog за последний час (anti-spam: не более 1 email/час
|
||||
* на тенант, проверка в caller).
|
||||
*/
|
||||
public function notifyZeroBalance(Tenant $tenant): void
|
||||
{
|
||||
$title = 'Баланс закончился — лиды отклоняются';
|
||||
$body = 'Пополните баланс в разделе Биллинг';
|
||||
|
||||
foreach ($this->recipientsForEvent($tenant, self::EVENT_ZERO_BALANCE, self::CHANNEL_EMAIL) as $user) {
|
||||
$this->sendEmail($user, self::EVENT_ZERO_BALANCE, new ZeroBalanceNotification($user, $tenant));
|
||||
}
|
||||
foreach ($this->recipientsForEvent($tenant, self::EVENT_ZERO_BALANCE, self::CHANNEL_INAPP) as $user) {
|
||||
$this->notifyInApp($user, self::EVENT_ZERO_BALANCE, $title, $body, [
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Уведомление об auto-pause проекта на нулевом балансе (Plan 4 Task 6).
|
||||
*
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Pd;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Сервис анонимизации ПДн субъекта по 152-ФЗ (право на удаление, ст.21).
|
||||
*
|
||||
* Использует соединение pgsql_supplier (BYPASSRLS / crm_supplier_worker),
|
||||
* чтобы читать и писать cross-tenant без RLS-ограничений.
|
||||
*
|
||||
* Реальные колонки схемы v8.19:
|
||||
* users: email, first_name, last_name, phone
|
||||
* supplier_leads: phone, raw_payload (JSONB) — нет contact_email/contact_phone
|
||||
* deals: phone, contact_name — нет отдельного contact_email
|
||||
* (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
|
||||
*/
|
||||
class PdErasureService
|
||||
{
|
||||
private const DB = 'pgsql_supplier';
|
||||
|
||||
/**
|
||||
* Анонимизировать все ПДн субъекта по email и/или телефону.
|
||||
*
|
||||
* @param string|null $email Email субъекта (один из двух обязателен)
|
||||
* @param string|null $phone Телефон субъекта (один из двух обязателен)
|
||||
* @param int|null $tenantId Ограничить поиск одним тенантом (null = все)
|
||||
* @param int $actorAdminId ID saas_admin_users
|
||||
* @param string|null $requestId ID pd_subject_requests для авто-закрытия
|
||||
* @return array{users: int, leads: int, deals: int}
|
||||
*
|
||||
* @throws InvalidArgumentException если оба email и phone null
|
||||
*/
|
||||
public function eraseSubject(
|
||||
?string $email,
|
||||
?string $phone,
|
||||
?int $tenantId,
|
||||
int $actorAdminId,
|
||||
?string $requestId = null,
|
||||
): array {
|
||||
if ($email === null && $phone === null) {
|
||||
throw new InvalidArgumentException('Необходимо указать email или телефон субъекта.');
|
||||
}
|
||||
|
||||
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0];
|
||||
|
||||
DB::connection(self::DB)->transaction(function () use (
|
||||
$email, $phone, $tenantId, $actorAdminId, $requestId, &$counts
|
||||
): void {
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 1. users
|
||||
// ------------------------------------------------------------------
|
||||
$userQuery = DB::connection(self::DB)->table('users');
|
||||
$userQuery->where(function ($q) use ($email, $phone): void {
|
||||
if ($email !== null) {
|
||||
$q->orWhere('email', $email);
|
||||
}
|
||||
if ($phone !== null) {
|
||||
$q->orWhere('phone', $phone);
|
||||
}
|
||||
});
|
||||
if ($tenantId !== null) {
|
||||
$userQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
$users = $userQuery->get(['id', 'tenant_id']);
|
||||
|
||||
foreach ($users as $user) {
|
||||
$userId = (int) $user->id;
|
||||
$userTenantId = (int) $user->tenant_id;
|
||||
|
||||
DB::connection(self::DB)->table('users')
|
||||
->where('id', $userId)
|
||||
->update([
|
||||
'email' => 'erased-'.$userId.'@deleted.local',
|
||||
'first_name' => 'Удалено',
|
||||
'last_name' => null,
|
||||
'phone' => '+7000'.str_pad((string) $userId, 7, '0', STR_PAD_LEFT),
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$this->writePdLog(
|
||||
tenantId: $userTenantId,
|
||||
subjectType: 'user',
|
||||
subjectId: $userId,
|
||||
actorAdminId: $actorAdminId,
|
||||
now: $now,
|
||||
);
|
||||
}
|
||||
|
||||
$counts['users'] = $users->count();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 2. supplier_leads (phone + raw_payload JSONB)
|
||||
// NB: нет contact_email / contact_phone — поиск только по phone
|
||||
// ------------------------------------------------------------------
|
||||
$leadQuery = DB::connection(self::DB)->table('supplier_leads');
|
||||
if ($phone !== null) {
|
||||
$leadQuery->where('phone', $phone);
|
||||
} else {
|
||||
// Только email — ищем в raw_payload JSONB
|
||||
$leadQuery->whereRaw('raw_payload::text LIKE ?', ['%'.$email.'%']);
|
||||
}
|
||||
|
||||
$leads = $leadQuery->get(['id']);
|
||||
|
||||
foreach ($leads as $lead) {
|
||||
$leadId = (int) $lead->id;
|
||||
|
||||
DB::connection(self::DB)->table('supplier_leads')
|
||||
->where('id', $leadId)
|
||||
->update([
|
||||
'phone' => '+7000XXXXXXX',
|
||||
'raw_payload' => DB::connection(self::DB)->raw(
|
||||
"JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)"
|
||||
),
|
||||
]);
|
||||
|
||||
$this->writePdLog(
|
||||
tenantId: $tenantId,
|
||||
subjectType: 'lead',
|
||||
subjectId: $leadId,
|
||||
actorAdminId: $actorAdminId,
|
||||
now: $now,
|
||||
);
|
||||
}
|
||||
|
||||
$counts['leads'] = $leads->count();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 3. deals (phone + contact_name)
|
||||
// Deals партиционированы — UPDATE без WHERE на партиции через
|
||||
// parent table работает начиная с PG 11+.
|
||||
// ------------------------------------------------------------------
|
||||
$dealQuery = DB::connection(self::DB)->table('deals');
|
||||
$dealQuery->where(function ($q) use ($email, $phone): void {
|
||||
if ($phone !== null) {
|
||||
$q->orWhere('phone', $phone);
|
||||
}
|
||||
if ($email !== null) {
|
||||
// Дополнительно: UTM/phones JSONB может хранить email, но в
|
||||
// минимуме ищем только по phone. Email в deals не хранится
|
||||
// в отдельной колонке.
|
||||
}
|
||||
});
|
||||
if ($tenantId !== null) {
|
||||
$dealQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
// Исключаем строки без совпадения по phone (когда phone=null — ничего не ищем)
|
||||
if ($phone === null) {
|
||||
// deals не имеет email-колонки, пропускаем
|
||||
$dealQuery->whereRaw('FALSE');
|
||||
}
|
||||
|
||||
$deals = $dealQuery->get(['id']);
|
||||
|
||||
foreach ($deals as $deal) {
|
||||
$dealId = (int) $deal->id;
|
||||
|
||||
DB::connection(self::DB)->table('deals')
|
||||
->where('id', $dealId)
|
||||
->update([
|
||||
'phone' => '+7000XXXXXXX',
|
||||
'contact_name' => 'Удалено',
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
|
||||
$counts['deals'] = $deals->count();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. Обновить pd_subject_requests если requestId передан
|
||||
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
|
||||
// ------------------------------------------------------------------
|
||||
if ($requestId !== null) {
|
||||
$summary = "Удалено: users={$counts['users']}, leads={$counts['leads']}, "
|
||||
."deals={$counts['deals']}";
|
||||
|
||||
DB::connection(self::DB)->table('pd_subject_requests')
|
||||
->where('id', $requestId)
|
||||
->update([
|
||||
'status' => 'completed',
|
||||
'completed_at' => $now,
|
||||
'response_text' => $summary,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставить запись в pd_processing_log через BYPASSRLS-соединение.
|
||||
*/
|
||||
private function writePdLog(
|
||||
?int $tenantId,
|
||||
string $subjectType,
|
||||
int $subjectId,
|
||||
int $actorAdminId,
|
||||
CarbonImmutable $now,
|
||||
): void {
|
||||
DB::connection(self::DB)->table('pd_processing_log')->insert([
|
||||
'tenant_id' => $tenantId,
|
||||
'subject_type' => $subjectType,
|
||||
'subject_id' => $subjectId,
|
||||
'action' => 'deleted',
|
||||
'purpose' => '152-FZ erasure',
|
||||
'actor_admin_user_id' => $actorAdminId,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Трекер пульса планировщика задач (hole #6).
|
||||
*
|
||||
* Оборачивает каждую cron-задачу: фиксирует время запуска, длительность,
|
||||
* результат (успех / ошибка) и consecutive_failures в scheduler_heartbeats.
|
||||
*
|
||||
* Использует pgsql_supplier (BYPASSRLS, crm_supplier_worker) — SaaS-level таблица,
|
||||
* RLS не применяется. Паттерн аналогичен IncidentsWatchFailures.
|
||||
*/
|
||||
final class SchedulerHeartbeatTracker
|
||||
{
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/**
|
||||
* Ожидаемые интервалы cron-задач в минутах.
|
||||
* Используется SchedulerCheckHeartbeats для детекции пропавшего пульса.
|
||||
*/
|
||||
public const EXPECTED_INTERVALS = [
|
||||
'projects:reset-delivered-today' => 1440, // daily
|
||||
'projects:reset-monthly' => 43200, // monthly (~30 days)
|
||||
'partitions:create-months' => 1440, // daily
|
||||
'App\Jobs\Supplier\RefreshSupplierSessionJob@hourly' => 60,
|
||||
'App\Jobs\Supplier\RefreshSupplierSessionJob@daily' => 1440,
|
||||
'App\Jobs\Supplier\SyncSupplierProjectsJob' => 1440,
|
||||
'App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob' => 1440,
|
||||
'supplier:retry-failed' => 60, // hourly
|
||||
'App\Jobs\Supplier\CsvReconcileJob' => 30, // every 30 min
|
||||
'incidents:watch-failures' => 10, // every 10 min
|
||||
'audit:verify-chains' => 1440, // daily
|
||||
'partitions:drop-expired' => 10080, // weekly (Sunday 03:00 MSK)
|
||||
'scheduler:check-heartbeats' => 60, // hourly (self-check)
|
||||
];
|
||||
|
||||
/**
|
||||
* Выполняет $work, записывает heartbeat (успех или ошибку).
|
||||
* Исключение пробрасывается наружу после сохранения.
|
||||
* Используется в тестах и при прямой инвокации.
|
||||
*/
|
||||
public function recordRun(string $name, callable $work): void
|
||||
{
|
||||
$startedAt = now();
|
||||
$error = null;
|
||||
|
||||
try {
|
||||
$work();
|
||||
} catch (Throwable $e) {
|
||||
$error = substr($e->getMessage(), 0, 2000);
|
||||
throw $e;
|
||||
} finally {
|
||||
$runtimeMs = (int) ($startedAt->diffInMilliseconds(now()));
|
||||
$this->saveHeartbeat($name, $startedAt, $error, $runtimeMs);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Записывает результат запуска напрямую (без обёртки callable).
|
||||
* Используется из before/after/onFailure хуков routes/console.php.
|
||||
*
|
||||
* @param bool $success true = успех, false = ошибка
|
||||
* @param string|null $errorMsg сообщение ошибки при $success=false
|
||||
* @param int|null $runtimeMs длительность в мс (null если неизвестна)
|
||||
*/
|
||||
public function recordRunResult(
|
||||
string $name,
|
||||
bool $success,
|
||||
?string $errorMsg,
|
||||
?int $runtimeMs,
|
||||
): void {
|
||||
$this->saveHeartbeat($name, now(), $success ? null : $errorMsg, $runtimeMs ?? 0);
|
||||
}
|
||||
|
||||
private function saveHeartbeat(
|
||||
string $name,
|
||||
\DateTimeInterface $startedAt,
|
||||
?string $error,
|
||||
int $runtimeMs,
|
||||
): void {
|
||||
$now = now();
|
||||
$db = DB::connection(self::DB_CONNECTION);
|
||||
|
||||
if ($error === null) {
|
||||
// Успех: сбрасываем consecutive_failures
|
||||
$db->statement(
|
||||
<<<'SQL'
|
||||
INSERT INTO scheduler_heartbeats
|
||||
(command_name, last_run_at, last_success_at, last_error, runtime_ms, consecutive_failures, created_at, updated_at)
|
||||
VALUES
|
||||
(?, ?, ?, NULL, ?, 0, ?, ?)
|
||||
ON CONFLICT (command_name) DO UPDATE SET
|
||||
last_run_at = EXCLUDED.last_run_at,
|
||||
last_success_at = EXCLUDED.last_success_at,
|
||||
last_error = NULL,
|
||||
runtime_ms = EXCLUDED.runtime_ms,
|
||||
consecutive_failures = 0,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
SQL,
|
||||
[$name, $startedAt, $now, $runtimeMs, $now, $now],
|
||||
);
|
||||
} else {
|
||||
// Ошибка: инкрементируем consecutive_failures
|
||||
$db->statement(
|
||||
<<<'SQL'
|
||||
INSERT INTO scheduler_heartbeats
|
||||
(command_name, last_run_at, last_success_at, last_error, runtime_ms, consecutive_failures, created_at, updated_at)
|
||||
VALUES
|
||||
(?, ?, NULL, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT (command_name) DO UPDATE SET
|
||||
last_run_at = EXCLUDED.last_run_at,
|
||||
last_error = EXCLUDED.last_error,
|
||||
runtime_ms = EXCLUDED.runtime_ms,
|
||||
consecutive_failures = scheduler_heartbeats.consecutive_failures + 1,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
SQL,
|
||||
[$name, $startedAt, $error, $runtimeMs, $now, $now],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class BalanceTransactionFactory extends Factory
|
||||
'amount_rub' => '100.00',
|
||||
'amount_leads' => 0,
|
||||
'balance_rub_after' => '100.00',
|
||||
'balance_leads_after' => 0,
|
||||
'balance_leads_after' => null,
|
||||
'description' => 'Тестовая транзакция',
|
||||
'created_at' => now(),
|
||||
];
|
||||
|
||||
@@ -22,7 +22,6 @@ class TenantFactory extends Factory
|
||||
'subdomain' => 'tenant-'.Str::lower(Str::random(8)),
|
||||
'organization_name' => fake()->company(),
|
||||
'contact_email' => fake()->unique()->safeEmail(),
|
||||
'webhook_token' => Str::random(64),
|
||||
'timezone' => 'Europe/Moscow',
|
||||
'locale' => 'ru',
|
||||
'is_trial' => true,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
@@ -59,6 +60,12 @@ return new class extends Migration
|
||||
// с deals), поэтому DB::unprepared его успешно применил — повторный ALTER
|
||||
// здесь не нужен. Если в будущем PDO начнёт глотать FK на partitioned —
|
||||
// повторить паттерн webhook_dedup_keys с try/catch ('уже существует' RU + EN).
|
||||
|
||||
// v8.31 (hole #2): создаём начальные партиции для 9 партиционированных таблиц
|
||||
// (deals, supplier_lead_costs + 7 audit-таблиц). Без этого первый INSERT
|
||||
// упадёт с "no partition found for row". Cron partitions:create-months
|
||||
// поддерживает их далее (текущий + ahead, default 2 месяца вперёд).
|
||||
Artisan::call('partitions:create-months', ['--ahead' => 2]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
|
||||
@@ -7,6 +7,14 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Idempotency guard: if schema.sql was loaded first, the table already exists
|
||||
// (as a partitioned table from hole #2 migration). Skip creation in that case.
|
||||
// The 2026_05_23_000002_partition_audit_tables migration will handle the partitioned
|
||||
// form when it runs.
|
||||
if (DB::selectOne('SELECT 1 AS ok FROM pg_class WHERE relname = ?', ['tenant_operations_log']) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sql = file_get_contents(base_path('../db/migrations/2026_05_22_001_tenant_operations_log.sql'));
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException('Migration SQL file not found.');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Hole #6: таблица пульса планировщика.
|
||||
*
|
||||
* SaaS-level, без RLS. Одна строка на cron-задачу (PK = command_name).
|
||||
* Обновляется SchedulerHeartbeatTracker при каждом запуске задачи.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS scheduler_heartbeats (
|
||||
command_name VARCHAR(200) NOT NULL PRIMARY KEY,
|
||||
last_run_at TIMESTAMPTZ,
|
||||
last_success_at TIMESTAMPTZ,
|
||||
last_error TEXT,
|
||||
runtime_ms INT,
|
||||
consecutive_failures INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE scheduler_heartbeats IS
|
||||
'Пульс планировщика: одна строка на cron-задачу, обновляется при каждом запуске. '
|
||||
'SaaS-level, без RLS. Используется SchedulerCheckHeartbeats для детекции '
|
||||
'пропавших или постоянно падающих задач (hole #6).';
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::unprepared('DROP TABLE IF EXISTS scheduler_heartbeats;');
|
||||
}
|
||||
};
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement(
|
||||
'ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check'
|
||||
);
|
||||
DB::statement(
|
||||
'ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check '.
|
||||
'CHECK (type IN ('.
|
||||
"'trial_bonus','topup','lead_charge','refund',".
|
||||
"'manual_adjustment','historical_import',".
|
||||
"'chargeback_writedown','chargeback_repayment',".
|
||||
"'migration'".
|
||||
'))'
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement(
|
||||
'ALTER TABLE balance_transactions DROP CONSTRAINT IF EXISTS balance_transactions_type_check'
|
||||
);
|
||||
DB::statement(
|
||||
'ALTER TABLE balance_transactions ADD CONSTRAINT balance_transactions_type_check '.
|
||||
'CHECK (type IN ('.
|
||||
"'trial_bonus','topup','lead_charge','refund',".
|
||||
"'manual_adjustment','historical_import',".
|
||||
"'chargeback_writedown','chargeback_repayment'".
|
||||
'))'
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Idempotency guard: skip if table already exists (e.g. loaded via schema.sql).
|
||||
if (DB::selectOne('SELECT 1 AS ok FROM pg_class WHERE relname = ?', ['supplier_lead_deliveries']) !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sql = file_get_contents(base_path('../db/migrations/2026_05_23_200_supplier_lead_deliveries.sql'));
|
||||
if ($sql === false) {
|
||||
throw new RuntimeException('Migration SQL file not found.');
|
||||
}
|
||||
// Prod: crm_app_user (default pgsql) не имеет CREATE на schema public.
|
||||
// Используем pgsql_supplier (crm_supplier_worker, BYPASSRLS, имеет CREATE).
|
||||
// На dev pgsql_supplier тоже = postgres superuser → работает идентично.
|
||||
DB::connection('pgsql_supplier')->unprepared($sql);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::connection('pgsql_supplier')->unprepared('DROP TABLE IF EXISTS supplier_lead_deliveries CASCADE;');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* No-op миграция-маркер (Billing v2 Spec B): телефонный дедуп удалён,
|
||||
* индекс deals_duplicate_of_id_idx становится неиспользуемым (колонка
|
||||
* deals.duplicate_of_id оставлена спящей — drop отдельной DBA-задачей,
|
||||
* mirrors Spec A balance_leads two-phase).
|
||||
*
|
||||
* Почему миграция no-op: индекс владельца crm_migrator, DROP требует прав
|
||||
* owner; .env не имеет crm_migrator credentials, а pgsql_supplier
|
||||
* (crm_supplier_worker) не владеет индексами на partitioned deals. Запуск
|
||||
* DROP отложен — выполняется напрямую psql под postgres-superuser отдельно.
|
||||
* Эта миграция только маркер «обработано», чтобы migrate --force не падал.
|
||||
*
|
||||
* На dev (postgres-superuser) индекс уже отсутствует из schema.sql v8.34,
|
||||
* поэтому ничего не делаем — тоже корректно.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Intentionally empty.
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// No-op: recreation is unnecessary (concept removed).
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Удаление legacy-артефактов прямого webhook-канала.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-24-legacy-direct-webhook-removal-design.md
|
||||
* Plan: docs/superpowers/plans/2026-05-24-legacy-direct-webhook-removal.md
|
||||
*
|
||||
* Что удаляем (финальный список по результатам Phase 1 impact-checks):
|
||||
* - webhook_log (partitioned, 13 партиций) — пустая на проде, источник = только удалённый ProcessWebhookJob
|
||||
* - rejected_deals_log — writer только ProcessWebhookJob, нет readers
|
||||
* - tenants.webhook_token + tenants.webhook_token_rotated_at — нет в UI/API, тесты почищены ниже
|
||||
* - system_settings.low_balance_threshold_leads (seed) — только legacy
|
||||
*
|
||||
* Phase 1 RED FLAG: webhook_dedup_keys ОСТАЁТСЯ (HistoricalImportService — CSV-канал).
|
||||
*
|
||||
* pgsql_supplier connection — BYPASSRLS-роль crm_supplier_worker (паттерн Спека B):
|
||||
* под обычной crm_app_user DROP/ALTER без app.current_tenant_id GUC не пройдёт.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
// Partitioned table — DROP TABLE каскадит все 13 партиций.
|
||||
$conn->statement('DROP TABLE IF EXISTS webhook_log CASCADE');
|
||||
|
||||
// NB: webhook_dedup_keys НЕ дропаем — Phase 1 RED FLAG, живой через HistoricalImportService (CSV-канал).
|
||||
|
||||
// RejectedDealsLog — writer только удалённый ProcessWebhookJob, readers нет.
|
||||
$conn->statement('DROP TABLE IF EXISTS rejected_deals_log CASCADE');
|
||||
|
||||
// tenants.webhook_token + webhook_token_rotated_at — нет в UI/API.
|
||||
$conn->statement('ALTER TABLE tenants DROP COLUMN IF EXISTS webhook_token, DROP COLUMN IF EXISTS webhook_token_rotated_at');
|
||||
|
||||
// Legacy threshold-cross seed (caller — удалённый ProcessWebhookJob).
|
||||
$conn->statement("DELETE FROM system_settings WHERE key = 'low_balance_threshold_leads'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Откат — пустая заглушка. Прод-restore из pg_dump backup.
|
||||
* Этот метод существует только чтобы migrate:rollback не падал.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// НЕ восстанавливаем структуру — пустая заглушка.
|
||||
// Прод-restore — из pg_dump backup (см. runbook docs/deploy/test-server-runbook.md).
|
||||
}
|
||||
};
|
||||
@@ -31,7 +31,6 @@ class DemoSeeder extends Seeder
|
||||
'contact_email' => 'admin@demo.local',
|
||||
'status' => 'active',
|
||||
'balance_rub' => '1000.00',
|
||||
'balance_leads' => 100,
|
||||
'is_trial' => false,
|
||||
]);
|
||||
|
||||
|
||||
Generated
+104
-29
@@ -14,12 +14,15 @@
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"axios": "^1.16.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"histoire": "^1.0.0-beta.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.12.2",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
@@ -4084,22 +4087,40 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ajv": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
|
||||
@@ -4134,14 +4155,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
@@ -5117,6 +5135,23 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/ajv": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
|
||||
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
@@ -5130,6 +5165,13 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "11.2.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
|
||||
@@ -5302,6 +5344,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||
@@ -5775,6 +5834,30 @@
|
||||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/gray-matter/node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -6380,14 +6463,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.14.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
@@ -6452,9 +6534,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -6995,13 +7077,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
|
||||
@@ -23,12 +23,15 @@
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
"@vue/test-utils": "^2.4.10",
|
||||
"ajv": "^8.20.0",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"axios": "^1.16.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"histoire": "^1.0.0-beta.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsdom": "^29.1.1",
|
||||
"knip": "^6.12.2",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
|
||||
+564
-372
File diff suppressed because it is too large
Load Diff
@@ -371,6 +371,20 @@ export async function refundTenant(
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTenantBalance(
|
||||
id: number,
|
||||
payload: { balance_rub: string; reason?: string },
|
||||
): Promise<{ id: number; balance_rub: string; delta: string; transaction_id: number }> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.patch<{
|
||||
id: number;
|
||||
balance_rub: string;
|
||||
delta: string;
|
||||
transaction_id: number;
|
||||
}>(`/api/admin/tenants/${id}/balance`, payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function changeTenantTariff(
|
||||
id: number,
|
||||
tariffId: number,
|
||||
@@ -494,3 +508,68 @@ export async function updateAdminSupplier(
|
||||
const { data } = await apiClient.patch<{ data: AdminSupplier }>(`/api/admin/suppliers/${id}`, payload);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 152-ФЗ: обращения субъектов ПДн
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PdSubjectRequest {
|
||||
id: number;
|
||||
received_at: string;
|
||||
subject_email: string | null;
|
||||
subject_phone: string | null;
|
||||
subject_full_name: string | null;
|
||||
request_type: 'access' | 'rectification' | 'deletion' | 'objection';
|
||||
description: string | null;
|
||||
status: 'received' | 'in_progress' | 'completed' | 'rejected';
|
||||
tenant_id: number | null;
|
||||
assigned_admin_id: number | null;
|
||||
response_text: string | null;
|
||||
deadline_at: string;
|
||||
completed_at: string | null;
|
||||
processing_restricted: boolean;
|
||||
}
|
||||
|
||||
export interface ListPdRequestsResponse {
|
||||
data: PdSubjectRequest[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface CreatePdRequestPayload {
|
||||
subject_email?: string;
|
||||
subject_phone?: string;
|
||||
subject_full_name?: string;
|
||||
request_type: 'access' | 'rectification' | 'deletion' | 'objection';
|
||||
description?: string;
|
||||
tenant_id?: number | null;
|
||||
}
|
||||
|
||||
export interface EraseSubjectResult {
|
||||
message: string;
|
||||
counts: { users: number; leads: number; deals: number; webhook_log: number };
|
||||
}
|
||||
|
||||
export async function listPdSubjectRequests(
|
||||
params: { status?: string; request_type?: string; limit?: number; offset?: number } = {},
|
||||
): Promise<ListPdRequestsResponse> {
|
||||
const { data } = await apiClient.get<ListPdRequestsResponse>('/api/admin/pd-subject-requests', { params });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createPdSubjectRequest(payload: CreatePdRequestPayload): Promise<PdSubjectRequest> {
|
||||
await ensureCsrfCookie();
|
||||
const { data } = await apiClient.post<{ data: PdSubjectRequest }>('/api/admin/pd-subject-requests', payload);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function executePdErasure(id: number, adminUserId?: number): Promise<EraseSubjectResult> {
|
||||
await ensureCsrfCookie();
|
||||
const payload = adminUserId !== undefined ? { admin_user_id: adminUserId } : {};
|
||||
const { data } = await apiClient.post<EraseSubjectResult>(
|
||||
`/api/admin/pd-subject-requests/${id}/erase`,
|
||||
payload,
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -7,20 +7,29 @@ import { apiClient, ensureCsrfCookie } from './client';
|
||||
* (E3), POST topup (E1 — добавляется в Task 5). GET'ы не требуют CSRF-cookie.
|
||||
*/
|
||||
|
||||
/** Тариф в составе ответа GET /api/billing/wallet. */
|
||||
/** Тариф в составе ответа GET /api/billing/wallet (Billing v2 Spec A). */
|
||||
export interface WalletTariff {
|
||||
code: string;
|
||||
name: string;
|
||||
price_monthly: string | null;
|
||||
billing_model: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
/** Ответ GET /api/billing/wallet — кошелёк тенанта. */
|
||||
/** Один уровень tier-сетки в tiers_preview. */
|
||||
export interface WalletTierPreview {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
price_rub: string;
|
||||
}
|
||||
|
||||
/** Ответ GET /api/billing/wallet — кошелёк тенанта (Billing v2 Spec A). */
|
||||
export interface Wallet {
|
||||
balance_rub: string;
|
||||
balance_leads: number;
|
||||
affordable_leads: number;
|
||||
current_tier: { no: number; price_rub: string; leads_left_in_tier: number } | null;
|
||||
next_tier: { no: number; price_rub: string; leads_in_tier: number } | null;
|
||||
delivered_in_month: number;
|
||||
runway_days: number | null;
|
||||
tiers_preview: WalletTierPreview[];
|
||||
tariff: WalletTariff | null;
|
||||
}
|
||||
|
||||
@@ -30,15 +39,24 @@ export async function getWallet(): Promise<Wallet> {
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Строка истории транзакций (GET /api/billing/transactions). */
|
||||
/** Строка истории транзакций (GET /api/billing/transactions, Billing v2 Spec A). */
|
||||
export interface BillingTransaction {
|
||||
id: number;
|
||||
code: string;
|
||||
type: string;
|
||||
type:
|
||||
| 'topup'
|
||||
| 'lead_charge'
|
||||
| 'migration'
|
||||
| 'trial_bonus'
|
||||
| 'manual_adjustment'
|
||||
| 'historical_import'
|
||||
| 'chargeback_writedown'
|
||||
| 'chargeback_repayment';
|
||||
description: string | null;
|
||||
amount_rub: string;
|
||||
amount_leads: number;
|
||||
balance_rub_after: string | null;
|
||||
amount_leads: number | null;
|
||||
balance_rub_after: string;
|
||||
display_amount_rub: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Диалог установки точного ₽-баланса тенанта (SaaS-admin).
|
||||
* Используется из карточки тенанта (TenantDetailHeader) и из строки таблицы
|
||||
* списка (TenantsTable). Семантика «установить точную сумму» — сервер сам
|
||||
* считает знаковую дельту и пишет manual_adjustment + audit.
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { updateTenantBalance } from '../../api/admin';
|
||||
import { extractErrorMessage } from '../../api/client';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
tenantId: number;
|
||||
tenantName: string;
|
||||
currentBalanceRub: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
saved: [payload: { balance_rub: string; delta: string; transaction_id: number }];
|
||||
}>();
|
||||
|
||||
const newBalance = ref('');
|
||||
const reason = ref('');
|
||||
const submitting = ref(false);
|
||||
const errorMsg = ref<string | null>(null);
|
||||
|
||||
const targetNormalized = computed(() => {
|
||||
const raw = newBalance.value.trim().replace(',', '.');
|
||||
if (!/^-?\d+(\.\d{1,2})?$/.test(raw)) return '';
|
||||
return Number(raw).toFixed(2);
|
||||
});
|
||||
|
||||
const delta = computed(() => {
|
||||
if (targetNormalized.value === '') return '';
|
||||
return (Number(targetNormalized.value) - props.currentBalanceRub).toFixed(2);
|
||||
});
|
||||
|
||||
const canSave = computed(
|
||||
() => !submitting.value && targetNormalized.value !== '' && delta.value !== '' && Number(delta.value) !== 0,
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) {
|
||||
newBalance.value = '';
|
||||
reason.value = '';
|
||||
errorMsg.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!canSave.value) return;
|
||||
submitting.value = true;
|
||||
errorMsg.value = null;
|
||||
try {
|
||||
const payload: { balance_rub: string; reason?: string } = { balance_rub: targetNormalized.value };
|
||||
if (reason.value.trim() !== '') payload.reason = reason.value.trim();
|
||||
const result = await updateTenantBalance(props.tenantId, payload);
|
||||
emit('saved', { balance_rub: result.balance_rub, delta: result.delta, transaction_id: result.transaction_id });
|
||||
emit('update:modelValue', false);
|
||||
} catch (e) {
|
||||
errorMsg.value = extractErrorMessage(e, 'Не удалось изменить баланс.');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="460"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">Изменить баланс</v-card-title>
|
||||
<v-card-subtitle>{{ tenantName }}</v-card-subtitle>
|
||||
<v-card-text>
|
||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||
Текущий баланс: <strong class="num">{{ currentBalanceRub.toFixed(2) }} ₽</strong>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="newBalance"
|
||||
label="Новый баланс, ₽"
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
density="comfortable"
|
||||
data-testid="balance-input"
|
||||
:hint="targetNormalized === '' && newBalance !== '' ? 'Формат: 1234.56' : ''"
|
||||
persistent-hint
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="reason"
|
||||
label="Причина (необязательно)"
|
||||
type="text"
|
||||
density="comfortable"
|
||||
maxlength="500"
|
||||
class="mt-2"
|
||||
data-testid="reason-input"
|
||||
/>
|
||||
|
||||
<div v-if="delta !== ''" class="preview mt-3 text-body-2">
|
||||
было <span class="num">{{ currentBalanceRub.toFixed(2) }} ₽</span>
|
||||
→ станет <span class="num">{{ targetNormalized }} ₽</span>
|
||||
(<span class="num" :class="Number(delta) < 0 ? 'text-error' : 'text-success'">
|
||||
{{ Number(delta) > 0 ? '+' : '' }}{{ delta }} ₽
|
||||
</span>)
|
||||
</div>
|
||||
|
||||
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3">
|
||||
{{ errorMsg }}
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
:loading="submitting"
|
||||
:disabled="!canSave"
|
||||
data-testid="balance-save"
|
||||
@click="submit"
|
||||
>
|
||||
Сохранить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,7 @@ defineProps<{
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
impersonate: [];
|
||||
editBalance: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -70,6 +71,17 @@ const emit = defineEmits<{
|
||||
{{ formatRub(tenant.balanceRub) }}
|
||||
</div>
|
||||
<div class="kpi-sub text-caption text-medium-emphasis">runway ~{{ tenant.runwayDays }} дн</div>
|
||||
<v-btn
|
||||
variant="text"
|
||||
size="small"
|
||||
density="comfortable"
|
||||
prepend-icon="mdi-pencil"
|
||||
class="mt-1 px-0"
|
||||
data-testid="edit-balance-btn"
|
||||
@click="emit('editBalance')"
|
||||
>
|
||||
Изменить
|
||||
</v-btn>
|
||||
</v-card>
|
||||
<v-card variant="outlined" class="kpi-card pa-4" data-testid="kpi-mrr">
|
||||
<div class="kpi-label text-caption text-medium-emphasis">Тариф / MRR</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ defineProps<{
|
||||
const emit = defineEmits<{
|
||||
rowClick: [tenant: AdminTenant];
|
||||
impersonate: [tenant: AdminTenant];
|
||||
editBalance: [tenant: AdminTenant];
|
||||
}>();
|
||||
|
||||
function formatRub(v: number): string {
|
||||
@@ -40,7 +41,7 @@ function statusColor(s: TenantStatus): string {
|
||||
{ title: 'Желаем×факт сегодня', key: 'today', align: 'end', sortable: false },
|
||||
{ title: 'MRR', key: 'mrrRub', align: 'end', sortable: false },
|
||||
{ title: 'Активность', key: 'activitySince', sortable: false },
|
||||
{ title: 'Действия', key: 'actions', align: 'end', sortable: false, width: 56 },
|
||||
{ title: 'Действия', key: 'actions', align: 'end', sortable: false, width: 96 },
|
||||
]"
|
||||
items-per-page="-1"
|
||||
hide-default-footer
|
||||
@@ -78,6 +79,20 @@ function statusColor(s: TenantStatus): string {
|
||||
<span class="num text-medium-emphasis">{{ item.activitySince }}</span>
|
||||
</template>
|
||||
<template #[`item.actions`]="{ item }: { item: AdminTenant }">
|
||||
<v-tooltip text="Изменить баланс" location="top" aria-label="Изменить баланс">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<v-btn
|
||||
v-bind="tipProps"
|
||||
icon="mdi-cash-edit"
|
||||
variant="text"
|
||||
size="small"
|
||||
density="comfortable"
|
||||
:aria-label="`Изменить баланс для ${item.name}`"
|
||||
:data-testid="`edit-balance-btn-${item.id}`"
|
||||
@click.stop="emit('editBalance', item)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-tooltip
|
||||
text="Войти как клиент (impersonation)"
|
||||
location="top"
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* BalanceCard — 3 wallet-cards в одной строке: Кошелёк ₽ (dark) +
|
||||
* Баланс лидов + Тариф. Данные — из GET /api/billing/wallet (E3).
|
||||
* ≈ Лиды (affordable_leads) + Тариф. Данные — из GET /api/billing/wallet (E3).
|
||||
* Billing v2 Spec A: affordable_leads вместо leadsBalance; currentTierPriceRub вместо tariffPrice.
|
||||
* tariff* допускают null (тенант без назначенного тарифа — trial).
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
walletRub: number;
|
||||
leadsBalance: number;
|
||||
affordableLeads: number;
|
||||
currentTierPriceRub: string;
|
||||
tariffName: string | null;
|
||||
tariffPrice: string | null;
|
||||
tariffFeatures: string[];
|
||||
}>();
|
||||
|
||||
defineEmits<{ topup: [] }>();
|
||||
|
||||
const walletText = computed(() => new Intl.NumberFormat('ru-RU').format(props.walletRub));
|
||||
|
||||
const tariffPriceText = computed(() => {
|
||||
if (props.tariffPrice === null) return 'по запросу';
|
||||
return new Intl.NumberFormat('ru-RU').format(Number(props.tariffPrice)) + ' ₽/мес';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -36,7 +32,7 @@ const tariffPriceText = computed(() => {
|
||||
<span class="num">{{ walletText }}</span>
|
||||
<span class="ru"> ₽</span>
|
||||
</div>
|
||||
<div class="wallet-foot mt-3">мин. пополнение <strong>100 ₽</strong> · округление вниз ₽→лиды</div>
|
||||
<div class="wallet-foot mt-3">мин. пополнение <strong>100 ₽</strong></div>
|
||||
<div class="wallet-actions mt-3">
|
||||
<v-btn
|
||||
color="primary"
|
||||
@@ -62,12 +58,19 @@ const tariffPriceText = computed(() => {
|
||||
<v-col cols="12" md="4">
|
||||
<v-card variant="outlined" class="wallet-card pa-4">
|
||||
<div class="wallet-h">
|
||||
<span class="wallet-label">Баланс лидов (ГЦК)</span>
|
||||
<span class="wallet-label">≈ Лиды</span>
|
||||
</div>
|
||||
<div class="wallet-amount mt-2">
|
||||
<span class="num">{{ leadsBalance }}</span>
|
||||
<span class="num">≈ {{ affordableLeads }}</span>
|
||||
<span class="ru-text"> лидов</span>
|
||||
</div>
|
||||
<div class="wallet-foot mt-2">сейчас по {{ currentTierPriceRub }} ₽/лид
|
||||
<v-tooltip text="Точный расчёт по текущим ценам. Меняется при переходе ступеней.">
|
||||
<template #activator="{ props: tipProps }">
|
||||
<v-icon v-bind="tipProps" size="14" class="ml-1">mdi-information-outline</v-icon>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
@@ -75,10 +78,7 @@ const tariffPriceText = computed(() => {
|
||||
<v-card variant="outlined" class="wallet-card pa-4 d-flex flex-column">
|
||||
<span class="wallet-label">Тариф</span>
|
||||
<template v-if="tariffName">
|
||||
<div class="tariff-name mt-1">
|
||||
{{ tariffName }}
|
||||
<span class="tariff-price">· {{ tariffPriceText }}</span>
|
||||
</div>
|
||||
<div class="tariff-name mt-1">{{ tariffName }}</div>
|
||||
<ul v-if="tariffFeatures.length" class="tariff-feats mt-3">
|
||||
<li v-for="f in tariffFeatures" :key="f">
|
||||
<v-icon size="14" color="success" class="mr-1">mdi-check</v-icon>{{ f }}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import TierPricesPanel from './TierPricesPanel.vue';
|
||||
|
||||
const tiers = [
|
||||
{ tier_no: 1, leads_in_tier: 50, price_rub: '120.00' },
|
||||
{ tier_no: 2, leads_in_tier: 100, price_rub: '100.00' },
|
||||
{ tier_no: 3, leads_in_tier: 200, price_rub: '80.00' },
|
||||
{ tier_no: 4, leads_in_tier: 300, price_rub: '70.00' },
|
||||
{ tier_no: 5, leads_in_tier: 400, price_rub: '65.00' },
|
||||
{ tier_no: 6, leads_in_tier: 500, price_rub: '62.00' },
|
||||
{ tier_no: 7, leads_in_tier: null, price_rub: '60.00' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story title="Billing/TierPricesPanel">
|
||||
<Variant title="Текущая ступень: 1"><TierPricesPanel :tiers="tiers" :current-tier-no="1" /></Variant>
|
||||
<Variant title="Текущая ступень: 3"><TierPricesPanel :tiers="tiers" :current-tier-no="3" /></Variant>
|
||||
<Variant title="Текущая ступень: 7 (всё свыше)"><TierPricesPanel :tiers="tiers" :current-tier-no="7" /></Variant>
|
||||
<Variant title="Без current_tier"><TierPricesPanel :tiers="tiers" :current-tier-no="null" /></Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* TierPricesPanel — свёрнутый по умолчанию блок с ценами 7 ступеней.
|
||||
* Подсвечивает текущую ступень чипом «вы здесь». Источник данных —
|
||||
* GET /api/billing/wallet → tiers_preview + current_tier.no.
|
||||
*
|
||||
* Billing v2 Spec A §3.4.8.
|
||||
*/
|
||||
interface TierPreview {
|
||||
tier_no: number;
|
||||
leads_in_tier: number | null;
|
||||
price_rub: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
tiers: TierPreview[];
|
||||
currentTierNo: number | null;
|
||||
}>();
|
||||
|
||||
function rangeText(idx: number): string {
|
||||
let start = 1;
|
||||
for (let i = 0; i < idx; i++) {
|
||||
const cap = props.tiers[i].leads_in_tier;
|
||||
if (cap !== null) start += cap;
|
||||
}
|
||||
const cap = props.tiers[idx].leads_in_tier;
|
||||
if (cap === null) return `${start}+`;
|
||||
return `${start}–${start + cap - 1}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-expansion-panels class="mt-4 tier-prices">
|
||||
<v-expansion-panel title="Цены за лид (7 ступеней)" eager>
|
||||
<template #text>
|
||||
<ul class="tier-list">
|
||||
<li
|
||||
v-for="(t, i) in tiers"
|
||||
:key="t.tier_no"
|
||||
data-test="tier-row"
|
||||
:data-test-row="`tier-row-${t.tier_no}`"
|
||||
class="tier-row"
|
||||
:class="{ 'tier-row--current': t.tier_no === currentTierNo }"
|
||||
>
|
||||
<span class="tier-no num">№{{ t.tier_no }}</span>
|
||||
<span class="tier-range">{{ rangeText(i) }} лидов</span>
|
||||
<span class="tier-price num">{{ t.price_rub }} ₽</span>
|
||||
<v-chip
|
||||
v-if="t.tier_no === currentTierNo"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
>вы здесь</v-chip>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tier-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.tier-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr auto auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.tier-row--current {
|
||||
background: rgba(15, 110, 86, 0.07);
|
||||
border-left: 3px solid #0f6e56;
|
||||
}
|
||||
.num {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
.tier-price {
|
||||
color: #0f6e56;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,6 @@ const TABS: Tab[] = [
|
||||
{ id: 'all', label: 'Все', type: null },
|
||||
{ id: 'topup', label: 'Пополнения', type: 'topup' },
|
||||
{ id: 'lead_charge', label: 'Списания', type: 'lead_charge' },
|
||||
{ id: 'refund', label: 'Возвраты', type: 'refund' },
|
||||
];
|
||||
|
||||
const activeTab = ref<string>('all');
|
||||
@@ -38,6 +37,7 @@ const headers = [
|
||||
function formatWhen(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
timeZone: 'Europe/Moscow',
|
||||
year: '2-digit',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
@@ -45,21 +45,14 @@ function formatWhen(iso: string): string {
|
||||
});
|
||||
}
|
||||
|
||||
/** Числовое значение движения: рубли приоритетно, иначе лиды. */
|
||||
/** Числовое значение движения из display_amount_rub. */
|
||||
function txAmountValue(tx: BillingTransaction): number {
|
||||
const rub = Number(tx.amount_rub);
|
||||
return rub !== 0 ? rub : tx.amount_leads;
|
||||
return Number(tx.display_amount_rub);
|
||||
}
|
||||
|
||||
/** Текст суммы: «+ 5 000 ₽» / «− 1 лид.» / «0 ₽». */
|
||||
/** Текст суммы из display_amount_rub. */
|
||||
function txAmountText(tx: BillingTransaction): string {
|
||||
const rub = Number(tx.amount_rub);
|
||||
if (rub !== 0) return formatCost(rub);
|
||||
if (tx.amount_leads !== 0) {
|
||||
const sign = tx.amount_leads > 0 ? '+ ' : '− ';
|
||||
return sign + Math.abs(tx.amount_leads) + ' лид.';
|
||||
}
|
||||
return '0 ₽';
|
||||
return formatCost(Number(tx.display_amount_rub));
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
|
||||
@@ -206,6 +206,7 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
item-value="code"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
label="Субъекты РФ"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
data-testid="region-add-select"
|
||||
@@ -43,6 +44,7 @@
|
||||
label="Субъекты РФ"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
data-testid="region-remove-select"
|
||||
|
||||
@@ -34,6 +34,7 @@ const navItems: NavItem[] = [
|
||||
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
|
||||
{ title: 'Интеграция с поставщиком', icon: 'mdi-swap-horizontal', to: '/admin/supplier-integration' },
|
||||
{ title: 'Проекты у поставщика', icon: 'mdi-format-list-checks', to: '/admin/supplier-projects' },
|
||||
{ title: 'Обращения ПДн (152-ФЗ)', icon: 'mdi-shield-account-outline', to: '/admin/pd-subject-requests' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -295,6 +295,18 @@ const routes: RouteRecordRaw[] = [
|
||||
devLabel: 'Admin Supplier Projects',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/pd-subject-requests',
|
||||
name: 'admin-pd-subject-requests',
|
||||
component: () => import('../views/admin/AdminPdSubjectRequestsView.vue'),
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
title: 'Обращения ПДн',
|
||||
requiresAuth: true,
|
||||
devIndex: 32,
|
||||
devLabel: 'Admin PD Requests',
|
||||
},
|
||||
},
|
||||
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
|
||||
{
|
||||
path: '/403',
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import BalanceCard from '../components/billing/BalanceCard.vue';
|
||||
import TierPricesPanel from '../components/billing/TierPricesPanel.vue';
|
||||
import TransactionsTable from '../components/billing/TransactionsTable.vue';
|
||||
import InvoicesTable from '../components/billing/InvoicesTable.vue';
|
||||
import TopupDialog from '../components/billing/TopupDialog.vue';
|
||||
@@ -29,10 +30,12 @@ const topupSnackbar = ref(false);
|
||||
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
|
||||
|
||||
const walletRub = computed(() => Number(wallet.value?.balance_rub ?? 0));
|
||||
const leadsBalance = computed(() => wallet.value?.balance_leads ?? 0);
|
||||
const affordableLeads = computed(() => wallet.value?.affordable_leads ?? 0);
|
||||
const currentTierPriceRub = computed(() => wallet.value?.current_tier?.price_rub ?? '0.00');
|
||||
const tiersPreview = computed(() => wallet.value?.tiers_preview ?? []);
|
||||
const currentTierNo = computed(() => wallet.value?.current_tier?.no ?? null);
|
||||
const runwayDays = computed(() => wallet.value?.runway_days ?? null);
|
||||
const tariffName = computed(() => wallet.value?.tariff?.name ?? null);
|
||||
const tariffPrice = computed(() => wallet.value?.tariff?.price_monthly ?? null);
|
||||
const tariffFeatures = computed<string[]>(() => (wallet.value?.tariff?.features ?? []).map(featureLabel));
|
||||
|
||||
async function loadWallet(): Promise<void> {
|
||||
@@ -73,10 +76,6 @@ defineExpose({ loadWallet, wallet, topupOpen });
|
||||
<span
|
||||
><span class="num text-primary">{{ formatPlain(walletRub) }}</span> кошелёк</span
|
||||
>
|
||||
<span class="sep">·</span>
|
||||
<span
|
||||
><span class="num">{{ leadsBalance }}</span> лидов запас</span
|
||||
>
|
||||
<template v-if="runwayDays !== null">
|
||||
<span class="sep">·</span>
|
||||
<span
|
||||
@@ -111,13 +110,15 @@ defineExpose({ loadWallet, wallet, topupOpen });
|
||||
<template v-else-if="wallet">
|
||||
<BalanceCard
|
||||
:wallet-rub="walletRub"
|
||||
:leads-balance="leadsBalance"
|
||||
:affordable-leads="affordableLeads"
|
||||
:current-tier-price-rub="currentTierPriceRub"
|
||||
:tariff-name="tariffName"
|
||||
:tariff-price="tariffPrice"
|
||||
:tariff-features="tariffFeatures"
|
||||
@topup="topupOpen = true"
|
||||
/>
|
||||
|
||||
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
|
||||
|
||||
<TransactionsTable ref="txTableRef" />
|
||||
|
||||
<InvoicesTable />
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Adminка SaaS → Обращения субъектов ПДн (152-ФЗ).
|
||||
*
|
||||
* Список обращений на удаление/доступ/исправление/возражение.
|
||||
* Для request_type='deletion' — кнопка «Анонимизировать» (§ 1.5, дыра #4).
|
||||
*
|
||||
* API: GET/POST /api/admin/pd-subject-requests, POST /{id}/erase
|
||||
*/
|
||||
import { onMounted, ref, reactive, computed } from 'vue';
|
||||
import * as adminApi from '../../api/admin';
|
||||
import type { PdSubjectRequest, CreatePdRequestPayload } from '../../api/admin';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
const rows = ref<PdSubjectRequest[]>([]);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const fetchError = ref(false);
|
||||
|
||||
const filterStatus = ref('');
|
||||
const filterType = ref('');
|
||||
|
||||
// Dialog: create
|
||||
const createDialog = ref(false);
|
||||
const createLoading = ref(false);
|
||||
const createError = ref('');
|
||||
const createForm = reactive<CreatePdRequestPayload>({
|
||||
subject_email: '',
|
||||
subject_phone: '',
|
||||
subject_full_name: '',
|
||||
request_type: 'deletion',
|
||||
description: '',
|
||||
tenant_id: null,
|
||||
});
|
||||
|
||||
// Dialog: erase confirm
|
||||
const eraseDialog = ref(false);
|
||||
const eraseLoading = ref(false);
|
||||
const eraseTarget = ref<PdSubjectRequest | null>(null);
|
||||
const eraseResult = ref<{ users: number; leads: number; deals: number; webhook_log: number } | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load data
|
||||
// ---------------------------------------------------------------------------
|
||||
async function loadRows(): Promise<void> {
|
||||
loading.value = true;
|
||||
fetchError.value = false;
|
||||
try {
|
||||
const res = await adminApi.listPdSubjectRequests({
|
||||
status: filterStatus.value || undefined,
|
||||
request_type: filterType.value || undefined,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
});
|
||||
rows.value = res.data;
|
||||
total.value = res.total;
|
||||
} catch {
|
||||
fetchError.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRows);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create request
|
||||
// ---------------------------------------------------------------------------
|
||||
async function submitCreate(): Promise<void> {
|
||||
createError.value = '';
|
||||
if (!createForm.subject_email && !createForm.subject_phone) {
|
||||
createError.value = 'Укажите email или телефон субъекта.';
|
||||
return;
|
||||
}
|
||||
createLoading.value = true;
|
||||
try {
|
||||
await adminApi.createPdSubjectRequest({
|
||||
subject_email: createForm.subject_email || undefined,
|
||||
subject_phone: createForm.subject_phone || undefined,
|
||||
subject_full_name: createForm.subject_full_name || undefined,
|
||||
request_type: createForm.request_type,
|
||||
description: createForm.description || undefined,
|
||||
tenant_id: createForm.tenant_id ?? undefined,
|
||||
});
|
||||
createDialog.value = false;
|
||||
resetCreateForm();
|
||||
await loadRows();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
createError.value = err?.response?.data?.message ?? 'Ошибка при создании обращения.';
|
||||
} finally {
|
||||
createLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetCreateForm(): void {
|
||||
createForm.subject_email = '';
|
||||
createForm.subject_phone = '';
|
||||
createForm.subject_full_name = '';
|
||||
createForm.request_type = 'deletion';
|
||||
createForm.description = '';
|
||||
createForm.tenant_id = null;
|
||||
createError.value = '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Erase
|
||||
// ---------------------------------------------------------------------------
|
||||
function openErase(row: PdSubjectRequest): void {
|
||||
eraseTarget.value = row;
|
||||
eraseResult.value = null;
|
||||
eraseDialog.value = true;
|
||||
}
|
||||
|
||||
async function confirmErase(): Promise<void> {
|
||||
if (!eraseTarget.value) return;
|
||||
eraseLoading.value = true;
|
||||
try {
|
||||
const res = await adminApi.executePdErasure(eraseTarget.value.id);
|
||||
eraseResult.value = res.counts;
|
||||
// Update row status in list
|
||||
const idx = rows.value.findIndex((r) => r.id === eraseTarget.value?.id);
|
||||
if (idx !== -1) {
|
||||
rows.value[idx] = { ...rows.value[idx], status: 'completed' };
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
alert(err?.response?.data?.message ?? 'Ошибка анонимизации.');
|
||||
eraseDialog.value = false;
|
||||
} finally {
|
||||
eraseLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
const statusLabels: Record<string, { label: string; color: string }> = {
|
||||
received: { label: 'Получено', color: 'info' },
|
||||
in_progress: { label: 'В работе', color: 'warning' },
|
||||
completed: { label: 'Выполнено', color: 'success' },
|
||||
rejected: { label: 'Отклонено', color: 'error' },
|
||||
};
|
||||
function statusInfo(s: string) {
|
||||
return statusLabels[s] ?? { label: s, color: 'default' };
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
access: 'Доступ',
|
||||
rectification: 'Исправление',
|
||||
deletion: 'Удаление',
|
||||
objection: 'Возражение',
|
||||
};
|
||||
function typeLabel(t: string): string {
|
||||
return typeLabels[t] ?? t;
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
const headers = [
|
||||
{ title: 'ID', key: 'id', width: '60px' },
|
||||
{ title: 'Получено', key: 'received_at', width: '140px' },
|
||||
{ title: 'Email / тел.', key: 'contact', sortable: false },
|
||||
{ title: 'Тип', key: 'request_type', width: '110px' },
|
||||
{ title: 'Статус', key: 'status', width: '120px' },
|
||||
{ title: 'Дедлайн', key: 'deadline_at', width: '140px' },
|
||||
{ title: 'Действия', key: 'actions', sortable: false, width: '140px', align: 'end' as const },
|
||||
];
|
||||
|
||||
const filteredRows = computed(() => rows.value);
|
||||
|
||||
defineExpose({ rows, loading, fetchError, loadRows });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="admin-pd pa-6">
|
||||
<!-- Page head -->
|
||||
<header class="page-head mb-4 d-flex justify-space-between align-start flex-wrap ga-3">
|
||||
<div>
|
||||
<h1 class="text-h4 page-title">Обращения субъектов ПДн</h1>
|
||||
<p class="text-body-2 text-medium-emphasis ma-0">
|
||||
Обращения на доступ, исправление, удаление и возражение (152-ФЗ).
|
||||
Срок ответа — 30 дней.
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex ga-2">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-refresh"
|
||||
:loading="loading"
|
||||
data-testid="reload-btn"
|
||||
@click="loadRows"
|
||||
>
|
||||
Обновить
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-plus"
|
||||
data-testid="create-btn"
|
||||
@click="createDialog = true"
|
||||
>
|
||||
Новый запрос
|
||||
</v-btn>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<v-alert
|
||||
v-if="fetchError"
|
||||
type="warning"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
closable
|
||||
class="mb-4"
|
||||
data-testid="fetch-error-alert"
|
||||
>
|
||||
Не удалось загрузить обращения. Попробуйте обновить.
|
||||
</v-alert>
|
||||
|
||||
<!-- Filters -->
|
||||
<v-card variant="outlined" class="pa-3 mb-4">
|
||||
<v-row dense>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-select
|
||||
v-model="filterStatus"
|
||||
label="Статус"
|
||||
:items="[
|
||||
{ title: 'Все статусы', value: '' },
|
||||
{ title: 'Получено', value: 'received' },
|
||||
{ title: 'В работе', value: 'in_progress' },
|
||||
{ title: 'Выполнено', value: 'completed' },
|
||||
{ title: 'Отклонено', value: 'rejected' },
|
||||
]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@update:model-value="loadRows"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-select
|
||||
v-model="filterType"
|
||||
label="Тип обращения"
|
||||
:items="[
|
||||
{ title: 'Все типы', value: '' },
|
||||
{ title: 'Доступ', value: 'access' },
|
||||
{ title: 'Исправление', value: 'rectification' },
|
||||
{ title: 'Удаление', value: 'deletion' },
|
||||
{ title: 'Возражение', value: 'objection' },
|
||||
]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
@update:model-value="loadRows"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4" class="d-flex align-center">
|
||||
<span class="text-body-2 text-medium-emphasis">Всего: {{ total }}</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
|
||||
<!-- Table -->
|
||||
<v-card variant="outlined">
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredRows"
|
||||
:loading="loading"
|
||||
item-value="id"
|
||||
density="compact"
|
||||
no-data-text="Обращений нет"
|
||||
data-testid="pd-requests-table"
|
||||
>
|
||||
<template v-slot:[`item.received_at`]="{ item }">
|
||||
<span class="text-caption font-mono">{{ formatDate(item.received_at) }}</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.contact`]="{ item }">
|
||||
<div>
|
||||
<span v-if="item.subject_email" class="d-block text-body-2">{{ item.subject_email }}</span>
|
||||
<span v-if="item.subject_phone" class="d-block text-caption text-medium-emphasis">
|
||||
{{ item.subject_phone }}
|
||||
</span>
|
||||
<span v-if="item.subject_full_name" class="d-block text-caption text-medium-emphasis">
|
||||
{{ item.subject_full_name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.request_type`]="{ item }">
|
||||
<v-chip
|
||||
:color="item.request_type === 'deletion' ? 'error' : 'default'"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ typeLabel(item.request_type) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.status`]="{ item }">
|
||||
<v-chip
|
||||
:color="statusInfo(item.status).color"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
>
|
||||
{{ statusInfo(item.status).label }}
|
||||
</v-chip>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.deadline_at`]="{ item }">
|
||||
<span
|
||||
class="text-caption"
|
||||
:class="item.status !== 'completed' && new Date(item.deadline_at) < new Date() ? 'text-error' : ''"
|
||||
>
|
||||
{{ formatDate(item.deadline_at) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-slot:[`item.actions`]="{ item }">
|
||||
<v-btn
|
||||
v-if="item.request_type === 'deletion' && item.status !== 'completed'"
|
||||
color="error"
|
||||
size="x-small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-delete-forever"
|
||||
:data-testid="`erase-btn-${item.id}`"
|
||||
@click="openErase(item)"
|
||||
>
|
||||
Анонимизировать
|
||||
</v-btn>
|
||||
<v-chip
|
||||
v-else-if="item.status === 'completed'"
|
||||
color="success"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
>
|
||||
Выполнено
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Dialog: create request -->
|
||||
<v-dialog v-model="createDialog" max-width="520" data-testid="create-dialog">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 pa-4 pb-2">Новое обращение субъекта ПДн</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<v-alert
|
||||
v-if="createError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
>
|
||||
{{ createError }}
|
||||
</v-alert>
|
||||
|
||||
<v-select
|
||||
v-model="createForm.request_type"
|
||||
label="Тип обращения *"
|
||||
:items="[
|
||||
{ title: 'Доступ к данным', value: 'access' },
|
||||
{ title: 'Исправление данных', value: 'rectification' },
|
||||
{ title: 'Удаление данных', value: 'deletion' },
|
||||
{ title: 'Возражение', value: 'objection' },
|
||||
]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-3"
|
||||
data-testid="form-request-type"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="createForm.subject_email"
|
||||
label="Email субъекта"
|
||||
type="email"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
data-testid="form-email"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="createForm.subject_phone"
|
||||
label="Телефон субъекта"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
data-testid="form-phone"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="createForm.subject_full_name"
|
||||
label="ФИО субъекта"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model.number="createForm.tenant_id"
|
||||
label="ID тенанта (необязательно)"
|
||||
type="number"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="mb-2"
|
||||
/>
|
||||
<v-textarea
|
||||
v-model="createForm.description"
|
||||
label="Описание"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
rows="3"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0 justify-end">
|
||||
<v-btn variant="text" @click="createDialog = false; resetCreateForm()">Отмена</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="createLoading"
|
||||
data-testid="submit-create-btn"
|
||||
@click="submitCreate"
|
||||
>
|
||||
Создать
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Dialog: erase confirm -->
|
||||
<v-dialog v-model="eraseDialog" max-width="480" data-testid="erase-dialog">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6 pa-4 pb-2 text-error">
|
||||
Анонимизировать данные субъекта
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-4 pt-0">
|
||||
<template v-if="!eraseResult">
|
||||
<v-alert type="warning" variant="tonal" density="compact" class="mb-3">
|
||||
Операция необратима. Данные будут заменены плейсхолдерами.
|
||||
</v-alert>
|
||||
<p class="text-body-2 mb-1">
|
||||
<strong>Email:</strong> {{ eraseTarget?.subject_email ?? '—' }}
|
||||
</p>
|
||||
<p class="text-body-2 mb-1">
|
||||
<strong>Телефон:</strong> {{ eraseTarget?.subject_phone ?? '—' }}
|
||||
</p>
|
||||
<p class="text-body-2">
|
||||
<strong>Тенант:</strong> {{ eraseTarget?.tenant_id ?? 'все' }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-alert type="success" variant="tonal" density="compact" class="mb-3">
|
||||
Анонимизация выполнена.
|
||||
</v-alert>
|
||||
<p class="text-body-2 mb-1">Пользователей: <strong>{{ eraseResult.users }}</strong></p>
|
||||
<p class="text-body-2 mb-1">Лидов поставщика: <strong>{{ eraseResult.leads }}</strong></p>
|
||||
<p class="text-body-2 mb-1">Сделок: <strong>{{ eraseResult.deals }}</strong></p>
|
||||
<p class="text-body-2">Webhook-логов: <strong>{{ eraseResult.webhook_log }}</strong></p>
|
||||
</template>
|
||||
</v-card-text>
|
||||
<v-card-actions class="pa-4 pt-0 justify-end">
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="eraseDialog = false"
|
||||
>
|
||||
{{ eraseResult ? 'Закрыть' : 'Отмена' }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!eraseResult"
|
||||
color="error"
|
||||
:loading="eraseLoading"
|
||||
data-testid="confirm-erase-btn"
|
||||
@click="confirmErase"
|
||||
>
|
||||
Подтвердить удаление
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-pd {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.page-title {
|
||||
font-variation-settings: 'opsz' 28;
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,7 @@ import type { AdminTenantDetail } from '../../composables/mockTenantDetail';
|
||||
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
|
||||
import TenantDetailHeader from '../../components/admin/tenant-detail/TenantDetailHeader.vue';
|
||||
import TenantDetailTabs from '../../components/admin/tenant-detail/TenantDetailTabs.vue';
|
||||
import TenantBalanceDialog from '../../components/admin/TenantBalanceDialog.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -64,6 +65,7 @@ watch(code, () => {
|
||||
|
||||
const ADMIN_USER_ID = 1;
|
||||
const impersonationOpen = ref(false);
|
||||
const balanceDialogOpen = ref(false);
|
||||
|
||||
const activeTab = ref<'finance' | 'users' | 'projects' | 'activity'>('finance');
|
||||
|
||||
@@ -71,14 +73,30 @@ function goBack() {
|
||||
router.push({ name: 'admin-tenants' });
|
||||
}
|
||||
|
||||
defineExpose({ tenant, activeTab, impersonationOpen, loadTenant });
|
||||
async function onBalanceSaved(): Promise<void> {
|
||||
await loadTenant();
|
||||
}
|
||||
|
||||
defineExpose({ tenant, activeTab, impersonationOpen, balanceDialogOpen, loadTenant });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container v-if="tenant" fluid class="tenant-detail pa-6">
|
||||
<TenantDetailHeader :tenant="tenant" @back="goBack" @impersonate="impersonationOpen = true" />
|
||||
<TenantDetailHeader
|
||||
:tenant="tenant"
|
||||
@back="goBack"
|
||||
@impersonate="impersonationOpen = true"
|
||||
@edit-balance="balanceDialogOpen = true"
|
||||
/>
|
||||
<TenantDetailTabs :tenant="tenant" :active-tab="activeTab" @update:active-tab="activeTab = $event" />
|
||||
<ImpersonationDialog v-model="impersonationOpen" :tenant="tenant" :requested-by="ADMIN_USER_ID" />
|
||||
<TenantBalanceDialog
|
||||
v-model="balanceDialogOpen"
|
||||
:tenant-id="tenant.id"
|
||||
:tenant-name="tenant.name"
|
||||
:current-balance-rub="tenant.balanceRub"
|
||||
@saved="onBalanceSaved"
|
||||
/>
|
||||
</v-container>
|
||||
|
||||
<v-container v-else-if="loading" fluid class="pa-6" data-testid="tenant-loading">
|
||||
|
||||
@@ -23,6 +23,7 @@ import * as adminApi from '../../api/admin';
|
||||
// `findComponent({ name: 'ImpersonationDialog' })` + `stubs`, defineAsyncComponent
|
||||
// ломает identity wrapper'а в test-utils.
|
||||
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
|
||||
import TenantBalanceDialog from '../../components/admin/TenantBalanceDialog.vue';
|
||||
import TenantsStatsHeader from '../../components/admin/tenants/TenantsStatsHeader.vue';
|
||||
import TenantsFilters from '../../components/admin/tenants/TenantsFilters.vue';
|
||||
import TenantsTable from '../../components/admin/tenants/TenantsTable.vue';
|
||||
@@ -66,6 +67,9 @@ const filterTariffs = ref<string[]>([]);
|
||||
const impersonationOpen = ref(false);
|
||||
const impersonationTenant = ref<AdminTenant | null>(null);
|
||||
|
||||
const balanceDialogOpen = ref(false);
|
||||
const balanceTarget = ref<AdminTenant | null>(null);
|
||||
|
||||
const availableTariffs = computed(() => Array.from(new Set(tenantsState.map((t) => t.tariff))).sort());
|
||||
|
||||
function clearFilters() {
|
||||
@@ -80,12 +84,23 @@ function openImpersonation(tenant: AdminTenant) {
|
||||
impersonationOpen.value = true;
|
||||
}
|
||||
|
||||
function openBalanceDialog(tenant: AdminTenant) {
|
||||
balanceTarget.value = tenant;
|
||||
balanceDialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function onBalanceSaved(): Promise<void> {
|
||||
await loadTenants();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
filterStatuses,
|
||||
filterTariffs,
|
||||
clearFilters,
|
||||
impersonationOpen,
|
||||
impersonationTenant,
|
||||
balanceDialogOpen,
|
||||
balanceTarget,
|
||||
tenantsState,
|
||||
stats,
|
||||
loading,
|
||||
@@ -137,9 +152,23 @@ const filteredTenants = computed<AdminTenant[]>(() => {
|
||||
@clear="clearFilters"
|
||||
/>
|
||||
|
||||
<TenantsTable :tenants="filteredTenants" @row-click="openTenantDetail" @impersonate="openImpersonation" />
|
||||
<TenantsTable
|
||||
:tenants="filteredTenants"
|
||||
@row-click="openTenantDetail"
|
||||
@impersonate="openImpersonation"
|
||||
@edit-balance="openBalanceDialog"
|
||||
/>
|
||||
|
||||
<ImpersonationDialog v-model="impersonationOpen" :tenant="impersonationTenant" :requested-by="ADMIN_USER_ID" />
|
||||
|
||||
<TenantBalanceDialog
|
||||
v-if="balanceTarget"
|
||||
v-model="balanceDialogOpen"
|
||||
:tenant-id="balanceTarget.id"
|
||||
:tenant-name="balanceTarget.name"
|
||||
:current-balance-rub="balanceTarget.balanceRub"
|
||||
@saved="onBalanceSaved"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -12,18 +12,6 @@
|
||||
style="max-width: 220px"
|
||||
@update:model-value="refresh"
|
||||
/>
|
||||
<v-select
|
||||
v-model="source"
|
||||
:items="sources"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
label="Источник"
|
||||
density="compact"
|
||||
hide-details
|
||||
clearable
|
||||
style="max-width: 200px"
|
||||
@update:model-value="refresh"
|
||||
/>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" prepend-icon="mdi-download" :loading="exporting" @click="exportCsv">
|
||||
Скачать CSV
|
||||
@@ -45,12 +33,14 @@
|
||||
<template #[`item.deal_id`]="{ item }">
|
||||
<RouterLink :to="`/deals/${item.deal_id}`">#{{ item.deal_id }}</RouterLink>
|
||||
</template>
|
||||
<template #[`item.charge_source`]="{ item }">
|
||||
<v-chip size="small" :color="item.charge_source === 'prepaid' ? 'info' : 'success'">
|
||||
{{ item.charge_source === 'prepaid' ? 'prepaid' : '₽' }}
|
||||
</v-chip>
|
||||
<template #[`item.price_rub`]="{ item }">
|
||||
<span v-if="item.price_per_lead_kopecks === 0" class="text-medium-emphasis">
|
||||
0 ₽
|
||||
<v-tooltip activator="parent" text="До перехода на новую модель эти лиды списывались из бесплатного остатка." />
|
||||
<span class="text-caption ml-1">(из бесплатного)</span>
|
||||
</span>
|
||||
<span v-else>{{ (item.price_per_lead_kopecks / 100).toFixed(2) }} ₽</span>
|
||||
</template>
|
||||
<template #[`item.price_rub`]="{ item }"> {{ (item.price_per_lead_kopecks / 100).toFixed(2) }} ₽ </template>
|
||||
</v-data-table-server>
|
||||
</div>
|
||||
</template>
|
||||
@@ -59,15 +49,17 @@
|
||||
/**
|
||||
* ChargesTab — read-only ledger списаний за лиды (Plan 4 Task 11).
|
||||
*
|
||||
* Backend: GET /api/billing/charges?page=N&period=...&charge_source=...
|
||||
* Backend: GET /api/billing/charges?page=N&period=...
|
||||
* POST /api/billing/charges/export → CSV blob.
|
||||
*
|
||||
* Pagination server-side через v-data-table-server (20/page).
|
||||
* Фильтры: period (current_month / last_month / 90d) + charge_source (prepaid / rub).
|
||||
* Фильтры: period (current_month / last_month / 90d).
|
||||
* Исторические prepaid-строки (price_per_lead_kopecks=0) помечены
|
||||
* тултипом «До перехода на новую модель...».
|
||||
* RLS: tenant-isolation на backend, frontend просто читает то что вернули.
|
||||
*
|
||||
* defineExpose ниже — для Vitest unit-тестов (`refresh`/`exportCsv`/`period`/
|
||||
* `source`/`total`/`load`).
|
||||
* `total`/`load`).
|
||||
*/
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
@@ -86,7 +78,6 @@ const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const exporting = ref(false);
|
||||
const period = ref<string>('current_month');
|
||||
const source = ref<string | null>(null);
|
||||
const page = ref(1);
|
||||
|
||||
const periods = [
|
||||
@@ -94,17 +85,12 @@ const periods = [
|
||||
{ title: 'Прошлый месяц', value: 'last_month' },
|
||||
{ title: 'Последние 90 дней', value: '90d' },
|
||||
];
|
||||
const sources = [
|
||||
{ title: 'prepaid', value: 'prepaid' },
|
||||
{ title: '₽', value: 'rub' },
|
||||
];
|
||||
|
||||
const headers = [
|
||||
{ title: 'Дата', key: 'charged_at', sortable: false },
|
||||
{ title: 'Сделка', key: 'deal_id', sortable: false, width: 100 },
|
||||
{ title: 'Tier', key: 'tier_no', sortable: false, width: 80 },
|
||||
{ title: 'Источник', key: 'charge_source', sortable: false, width: 120 },
|
||||
{ title: 'Цена', key: 'price_rub', sortable: false, width: 120 },
|
||||
{ title: 'Цена', key: 'price_rub', sortable: false, width: 160 },
|
||||
];
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
@@ -125,7 +111,6 @@ async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params: Record<string, string | number> = { page: page.value, period: period.value };
|
||||
if (source.value) params.charge_source = source.value;
|
||||
const { data } = await axios.get('/api/billing/charges', { params });
|
||||
rows.value = data.data;
|
||||
total.value = data.meta.total;
|
||||
@@ -138,7 +123,6 @@ async function exportCsv(): Promise<void> {
|
||||
exporting.value = true;
|
||||
try {
|
||||
const params: Record<string, string> = { period: period.value };
|
||||
if (source.value) params.charge_source = source.value;
|
||||
const response = await axios.post('/api/billing/charges/export', params, { responseType: 'blob' });
|
||||
// jsdom не реализует URL.createObjectURL — gracefully no-op (тесты мокают).
|
||||
if (typeof URL.createObjectURL !== 'function') return;
|
||||
@@ -157,7 +141,7 @@ async function exportCsv(): Promise<void> {
|
||||
|
||||
onMounted(load);
|
||||
|
||||
defineExpose({ refresh, exportCsv, load, period, source, total });
|
||||
defineExpose({ refresh, exportCsv, load, period, total });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -94,6 +94,7 @@
|
||||
:disabled="vsyaRfConfirmed"
|
||||
multiple
|
||||
chips
|
||||
closable-chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
class="ld-input-quiet"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
Аудит hash-chain: РАЗРЫВ ЦЕПОЧКИ
|
||||
===================================
|
||||
Таблица: {{ $tableName }}
|
||||
Партиция: {{ $partitionName }}
|
||||
Первый сломанный id: {{ $firstBrokenId }}
|
||||
Несовпадений: {{ $mismatchCount }}
|
||||
Время: {{ $now }} (МСК)
|
||||
|
||||
ВНИМАНИЕ: разрыв hash-chain означает, что строки в партиции {{ $partitionName }}
|
||||
(таблица {{ $tableName }}) могли быть изменены или удалены в обход триггеров
|
||||
(прямой SQL под суперюзером).
|
||||
|
||||
Необходимо срочно:
|
||||
1. Проверить pg_audit log на предмет DDL/UPDATE/DELETE без триггеров.
|
||||
2. Проверить incidents_log для деталей.
|
||||
3. Восстановить данные из backup при необходимости.
|
||||
|
||||
Это автоматическое сообщение от команды audit:verify-chains (Лидерра).
|
||||
@@ -0,0 +1,10 @@
|
||||
Автоматический инцидент — Лидерра
|
||||
===================================
|
||||
Severity: {{ strtoupper($severity) }}
|
||||
Время: {{ $now }} (МСК)
|
||||
|
||||
Описание:
|
||||
{{ $summary }}
|
||||
|
||||
Это автоматическое сообщение от команды incidents:watch-failures (Лидерра).
|
||||
Проверьте incidents_log в панели администратора для деталей.
|
||||
@@ -1,26 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Низкий баланс</title>
|
||||
</head>
|
||||
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
|
||||
<h1 style="color: #0F6E56; font-size: 20px;">Лидерра. Низкий баланс</h1>
|
||||
|
||||
<p>Здравствуйте, {{ $recipient->first_name ?? $recipient->email }}.</p>
|
||||
|
||||
<p>На балансе аккаунта <strong>{{ $tenant->organization_name ?? $tenant->subdomain }}</strong>
|
||||
осталось <strong>{{ $tenant->balance_leads }} лидов</strong>
|
||||
(порог уведомления — {{ $thresholdLeads }} лидов).</p>
|
||||
|
||||
<p style="background: #FEF3F2; padding: 12px; border-left: 3px solid #B94837;">
|
||||
Если баланс закончится — все входящие лиды будут отклоняться.
|
||||
</p>
|
||||
|
||||
<p style="margin-top: 24px;">Откройте Биллинг в CRM, чтобы пополнить баланс.</p>
|
||||
|
||||
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
|
||||
Это автоматическое уведомление о событии «Низкий баланс». Чтобы изменить настройки уведомлений — перейдите в Настройки → Уведомления.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
Scheduler Heartbeat: ПРОПАВШАЯ ЗАДАЧА
|
||||
======================================
|
||||
Команда: {{ $commandName }}
|
||||
Причина: {{ $reason }}
|
||||
Consecutive ошибок: {{ $consecutiveFailures }}
|
||||
Время: {{ $now }} (МСК)
|
||||
|
||||
@if($lastError)
|
||||
Последняя ошибка:
|
||||
{{ $lastError }}
|
||||
|
||||
@endif
|
||||
ВНИМАНИЕ: cron-задача {{ $commandName }} не даёт пульса.
|
||||
Это может означать, что планировщик Laravel не работает, команда зависла,
|
||||
или процесс завершается с ошибкой.
|
||||
|
||||
Необходимо:
|
||||
1. Проверить scheduler_heartbeats через админку или вручную.
|
||||
2. Проверить логи: storage/logs/laravel.log на сервере.
|
||||
3. Убедиться, что crontab/supervisor работает на боевом сервере.
|
||||
|
||||
Это автоматическое сообщение от команды scheduler:check-heartbeats (Лидерра).
|
||||
@@ -1,27 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Баланс закончился</title>
|
||||
</head>
|
||||
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
|
||||
<h1 style="color: #B94837; font-size: 20px;">Лидерра. Баланс закончился</h1>
|
||||
|
||||
<p>Здравствуйте, {{ $recipient->first_name ?? $recipient->email }}.</p>
|
||||
|
||||
<p>Баланс аккаунта <strong>{{ $tenant->organization_name ?? $tenant->subdomain }}</strong>
|
||||
обнулился — <strong>входящие лиды отклоняются</strong>.</p>
|
||||
|
||||
<p style="background: #FEF3F2; padding: 12px; border-left: 3px solid #B94837;">
|
||||
<strong>Что делать:</strong> пополните баланс в разделе Биллинг.
|
||||
После пополнения новые лиды снова начнут приниматься.
|
||||
</p>
|
||||
|
||||
<p>Уже отклонённые лиды не возвращаются — посмотрите их в журнале
|
||||
«Отклонённые лиды» в разделе Отчёты.</p>
|
||||
|
||||
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
|
||||
Это автоматическое уведомление о событии «Нулевой баланс». Чтобы изменить настройки уведомлений — перейдите в Настройки → Уведомления.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
+70
-10
@@ -4,6 +4,7 @@ use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
|
||||
use App\Jobs\Supplier\CsvReconcileJob;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Services\SchedulerHeartbeatTracker;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
@@ -12,6 +13,12 @@ Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
// Hole #6: heartbeat-трекинг всех cron-задач.
|
||||
// SchedulerHeartbeatTracker::recordRun() оборачивает каждую задачу через
|
||||
// before/after/onFailure хуки Laravel Scheduler — минимально инвазивный подход.
|
||||
/** @var SchedulerHeartbeatTracker $hb */
|
||||
$hb = app(SchedulerHeartbeatTracker::class);
|
||||
|
||||
// Spec §6.1: ежедневный сброс projects.delivered_today=0 в 00:00 МСК.
|
||||
// delivered_in_month НЕ трогаем — это месячный счётчик, отдельный cron Plan 4.
|
||||
//
|
||||
@@ -21,18 +28,41 @@ Artisan::command('inspire', function () {
|
||||
// schema.sql нет (Laravel-default-миграции удалены, см. project_state.md фаза 1).
|
||||
Schedule::command('projects:reset-delivered-today')
|
||||
->dailyAt('00:00')
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->before(fn () => $startTimes['projects:reset-delivered-today'] = microtime(true))
|
||||
->onSuccess(function () use ($hb, &$startTimes): void {
|
||||
$name = 'projects:reset-delivered-today';
|
||||
$ms = isset($startTimes[$name]) ? (int) ((microtime(true) - $startTimes[$name]) * 1000) : null;
|
||||
$hb->recordRunResult($name, true, null, $ms);
|
||||
})
|
||||
->onFailure(function () use ($hb): void {
|
||||
$hb->recordRunResult('projects:reset-delivered-today', false, 'Command failed', null);
|
||||
});
|
||||
|
||||
// Plan 4: monthly reset 1-го числа в 00:00 МСК для tier-lookup в LedgerService.
|
||||
Schedule::command('projects:reset-monthly')
|
||||
->monthlyOn(1, '00:00')
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('projects:reset-monthly', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('projects:reset-monthly', false, 'Command failed', null));
|
||||
|
||||
// Audit #2 Phase 14 P2: partition maintenance — создаёт разделы на 3 месяца вперёд.
|
||||
// Без этой записи partitions:create-months не запускается автоматически.
|
||||
Schedule::command('partitions:create-months')
|
||||
->daily()
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('partitions:create-months', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('partitions:create-months', false, 'Command failed', null));
|
||||
|
||||
// Hole #2 (23.05.2026): удаление устаревших месячных партиций согласно retention
|
||||
// (system_settings: partition_retention_months_<table>).
|
||||
// Запускается еженедельно в воскресенье в 03:00 МСК — вне пиковых часов,
|
||||
// но раз в неделю достаточно (данные удаляются целыми месяцами).
|
||||
Schedule::command('partitions:drop-expired')
|
||||
->weeklyOn(0, '03:00')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('partitions:drop-expired', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('partitions:drop-expired', false, 'Command failed', null));
|
||||
|
||||
// Plan 3 Task 8: 5 Schedule entries для supplier-flow.
|
||||
//
|
||||
@@ -41,28 +71,58 @@ Schedule::command('partitions:create-months')
|
||||
// делает diff'ы (skip-no-diff), CleanupJob — UPDATE WHERE conditions, RefreshSession
|
||||
// — Cache::lock guard внутри handle, RetryFailedSupplierJobs — WHERE retried_at
|
||||
// фильтр. На multi-server prod может потребовать cache_locks таблицу.
|
||||
Schedule::job(new RefreshSupplierSessionJob)->hourly();
|
||||
Schedule::job(new RefreshSupplierSessionJob)->hourly()
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@hourly', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@hourly', false, 'Job failed', null));
|
||||
// Spec docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.7:
|
||||
// крон переехал с 20:30 на 18:00 МСК — даёт ~3 часа окно восстановления
|
||||
// (эскалация на медленный ярус 2 / ручной ярус 3) в рабочее время до
|
||||
// портального дедлайна 21:00. Session refresh — на 15 мин раньше sync (17:45).
|
||||
Schedule::job(new RefreshSupplierSessionJob)
|
||||
->dailyAt('17:45')
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', false, 'Job failed', null));
|
||||
Schedule::job(new SyncSupplierProjectsJob)
|
||||
->dailyAt('18:00')
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', false, 'Job failed', null));
|
||||
Schedule::job(new CleanupInactiveSupplierProjectsJob)
|
||||
->dailyAt('02:00')
|
||||
->timezone('Europe/Moscow');
|
||||
Schedule::command('supplier:retry-failed')->hourly();
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob', false, 'Job failed', null));
|
||||
Schedule::command('supplier:retry-failed')->hourly()
|
||||
->onSuccess(fn () => $hb->recordRunResult('supplier:retry-failed', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('supplier:retry-failed', false, 'Command failed', null));
|
||||
|
||||
// Резервный CSV-канал (Путь 2): сверка каждые 30 минут.
|
||||
// Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.5
|
||||
Schedule::job(new CsvReconcileJob)->everyThirtyMinutes();
|
||||
Schedule::job(new CsvReconcileJob)->everyThirtyMinutes()
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\CsvReconcileJob', false, 'Job failed', null));
|
||||
|
||||
// Audit #2 Phase 14 P2: авто-детекция штормов упавших webhook-джобов.
|
||||
// Сканирует за последние 10 мин, порог 200, дедуп 60 мин.
|
||||
Schedule::command('incidents:watch-failures')
|
||||
->everyTenMinutes()
|
||||
->timezone('Europe/Moscow');
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('incidents:watch-failures', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('incidents:watch-failures', false, 'Command failed', null));
|
||||
|
||||
// Hole #1: ежедневная проверка SHA-256 hash-chain в 6 audit-таблицах.
|
||||
// Разрыв → incidents_log (severity high) + email kdv1@bk.ru.
|
||||
// Ref: docs/superpowers/plans/2026-05-23-hole-1-hash-chain-validator.md
|
||||
Schedule::command('audit:verify-chains')
|
||||
->dailyAt('04:00')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('audit:verify-chains', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('audit:verify-chains', false, 'Command failed', null));
|
||||
|
||||
// Hole #6: проверка пульса планировщика — hourly МСК.
|
||||
Schedule::command('scheduler:check-heartbeats')
|
||||
->hourly()
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('scheduler:check-heartbeats', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('scheduler:check-heartbeats', false, 'Command failed', null));
|
||||
|
||||
+12
-6
@@ -98,6 +98,8 @@ Route::middleware('saas-admin')->group(function () {
|
||||
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
|
||||
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
|
||||
->where('subdomain', '[a-z0-9_-]+');
|
||||
Route::patch('/api/admin/tenants/{id}/balance', 'App\Http\Controllers\Api\AdminTenantsController@updateBalance')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
|
||||
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
|
||||
@@ -162,6 +164,16 @@ Route::middleware('saas-admin')->group(function () {
|
||||
// Plan 4 Task 2: экран «Проекты у поставщика» — список + bulk-delete.
|
||||
Route::get('/api/admin/supplier-integration/projects', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsIndex');
|
||||
Route::post('/api/admin/supplier-integration/projects/delete', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@projectsDestroy');
|
||||
|
||||
// 152-ФЗ: обращения субъектов ПДн + анонимизация (дыра #4).
|
||||
Route::prefix('/api/admin/pd-subject-requests')->group(function () {
|
||||
Route::get('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@index');
|
||||
Route::post('/', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@store');
|
||||
Route::get('/{id}', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@show')
|
||||
->where('id', '[0-9]+');
|
||||
Route::post('/{id}/erase', 'App\Http\Controllers\Api\AdminPdSubjectRequestsController@executeErasure')
|
||||
->where('id', '[0-9]+');
|
||||
});
|
||||
});
|
||||
|
||||
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
|
||||
@@ -258,12 +270,6 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(fu
|
||||
Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+');
|
||||
});
|
||||
|
||||
// Receive endpoint для входящих webhook'ов (narrative §5.5).
|
||||
// Auth — по `tenants.webhook_token` в URL (без middleware, проверка внутри controller).
|
||||
// На prod: + HMAC-валидация X-Webhook-Signature + per-token rate-limit.
|
||||
Route::post('/api/webhook/{token}', 'App\Http\Controllers\Api\WebhookReceiveController@receive')
|
||||
->where('token', '[A-Za-z0-9\-_]+');
|
||||
|
||||
// Supplier-integration webhook (Plan 2/5, spec §5.1).
|
||||
// Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru.
|
||||
// Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist
|
||||
|
||||
@@ -77,7 +77,6 @@ foreach ($accounts as $a) {
|
||||
[
|
||||
'organization_name' => $a['org_name'],
|
||||
'contact_email' => $user->email,
|
||||
'webhook_token' => Str::random(64),
|
||||
'timezone' => 'Europe/Moscow',
|
||||
'locale' => 'ru',
|
||||
'is_trial' => true,
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Pd\PdErasureService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Создать stub saas_admin_users и вернуть его id. */
|
||||
function pdStubAdminUser(string $email = 'pd-test-stub@system.local'): int
|
||||
{
|
||||
$existing = DB::table('saas_admin_users')->where('email', $email)->value('id');
|
||||
if ($existing !== null) {
|
||||
return (int) $existing;
|
||||
}
|
||||
|
||||
return (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => $email,
|
||||
'full_name' => 'PD Test Stub',
|
||||
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||||
'role' => 'super_admin',
|
||||
'is_active' => false,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Создать тенант и вернуть его. */
|
||||
function pdCreateTenant(): Tenant
|
||||
{
|
||||
return Tenant::factory()->create([
|
||||
'subdomain' => 'pd-test-'.uniqid(),
|
||||
'organization_name' => 'PD Test Org',
|
||||
'contact_email' => 'pd-tenant@test.local',
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
/** Вставить запись pd_subject_requests напрямую и вернуть id. */
|
||||
function pdInsertRequest(array $attrs = []): int
|
||||
{
|
||||
$defaults = [
|
||||
'received_at' => now(),
|
||||
'subject_email' => 'subject@example.com',
|
||||
'subject_phone' => null,
|
||||
'subject_full_name' => 'Test Subject',
|
||||
'request_type' => 'deletion',
|
||||
'description' => 'Test description',
|
||||
'status' => 'received',
|
||||
'tenant_id' => null,
|
||||
'processing_restricted' => false,
|
||||
// deadline_at заполняется триггером, но NOT NULL — вставим вручную
|
||||
'deadline_at' => now()->addDays(30),
|
||||
];
|
||||
|
||||
return (int) DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->insertGetId(array_merge($defaults, $attrs));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('index returns paginated list of pd_subject_requests', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
// Вставим 2 записи
|
||||
pdInsertRequest(['request_type' => 'deletion']);
|
||||
pdInsertRequest(['request_type' => 'access', 'status' => 'completed']);
|
||||
|
||||
$response = $this->getJson('/api/admin/pd-subject-requests');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('total'))->toBeGreaterThanOrEqual(2);
|
||||
expect($response->json('data'))->toBeArray();
|
||||
$first = $response->json('data.0');
|
||||
expect($first)->toHaveKeys(['id', 'received_at', 'request_type', 'status', 'deadline_at']);
|
||||
});
|
||||
|
||||
it('index filters by status', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
pdInsertRequest(['status' => 'received']);
|
||||
pdInsertRequest(['status' => 'completed', 'request_type' => 'access']);
|
||||
|
||||
$response = $this->getJson('/api/admin/pd-subject-requests?status=received');
|
||||
|
||||
$response->assertOk();
|
||||
foreach ($response->json('data') as $row) {
|
||||
expect($row['status'])->toBe('received');
|
||||
}
|
||||
});
|
||||
|
||||
it('store creates pd_subject_request with deadline_at ~+30 days from received_at', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$response = $this->postJson('/api/admin/pd-subject-requests', [
|
||||
'subject_email' => 'newsubject@example.com',
|
||||
'request_type' => 'deletion',
|
||||
'description' => 'Please delete my data.',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$data = $response->json('data');
|
||||
expect($data['subject_email'])->toBe('newsubject@example.com');
|
||||
expect($data['request_type'])->toBe('deletion');
|
||||
expect($data['status'])->toBe('received');
|
||||
|
||||
// deadline_at должен быть ~30 дней вперёд (с погрешностью ±2 дня на тест-лаги)
|
||||
$deadline = CarbonImmutable::parse($data['deadline_at']);
|
||||
$received = CarbonImmutable::parse($data['received_at']);
|
||||
// diffInDays: абсолютное значение (порядок параметров не важен с abs)
|
||||
$diff = abs($deadline->diffInDays($received));
|
||||
expect($diff)->toBeGreaterThanOrEqual(29)->toBeLessThanOrEqual(31);
|
||||
});
|
||||
|
||||
it('store validates: at least email or phone required', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$this->postJson('/api/admin/pd-subject-requests', [
|
||||
'request_type' => 'deletion',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('store validates: request_type must be valid', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$this->postJson('/api/admin/pd-subject-requests', [
|
||||
'subject_email' => 'x@y.com',
|
||||
'request_type' => 'invalid_type',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('executeErasure anonymises user email first_name phone and writes pd_processing_log', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
// Используем pgsql_supplier для всех вставок, чтобы FK-проверки работали
|
||||
// в рамках одного соединения (DatabaseTransactions оборачивает default pgsql,
|
||||
// но pgsql_supplier видит только committed данные default-соединения).
|
||||
$stubEmail = 'pd-user-stub-'.uniqid().'@system.local';
|
||||
$adminId = (int) DB::connection('pgsql_supplier')->table('saas_admin_users')->insertGetId([
|
||||
'email' => $stubEmail,
|
||||
'full_name' => 'User Test Stub',
|
||||
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||||
'role' => 'super_admin',
|
||||
'is_active' => false,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
|
||||
// Создаём тенант через pgsql_supplier (тот же физ. сервер/БД)
|
||||
$tenantId = (int) DB::connection('pgsql_supplier')->table('tenants')->insertGetId([
|
||||
'subdomain' => 'pd-user-test-'.uniqid(),
|
||||
'organization_name' => 'PD User Test',
|
||||
'contact_email' => 'pd-u@test.local',
|
||||
'status' => 'active',
|
||||
'balance_rub' => '0.00',
|
||||
'balance_leads' => 0,
|
||||
'is_trial' => false,
|
||||
'chargeback_unrecovered_rub' => '0.00',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// Создаём user с email/phone субъекта
|
||||
$victimEmail = 'victim-'.uniqid().'@example.com';
|
||||
$victimPhone = '+79991234567';
|
||||
|
||||
$userId = (int) DB::connection('pgsql_supplier')->table('users')->insertGetId([
|
||||
'tenant_id' => $tenantId,
|
||||
'email' => $victimEmail,
|
||||
'password_hash' => '$2y$04$test',
|
||||
'first_name' => 'Иван',
|
||||
'last_name' => 'Иванов',
|
||||
'phone' => $victimPhone,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$requestId = pdInsertRequest([
|
||||
'subject_email' => $victimEmail,
|
||||
'subject_phone' => $victimPhone,
|
||||
'tenant_id' => $tenantId,
|
||||
'request_type' => 'deletion',
|
||||
'status' => 'received',
|
||||
]);
|
||||
|
||||
$response = $this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
|
||||
'admin_user_id' => $adminId,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('counts.users'))->toBe(1);
|
||||
|
||||
// Проверяем анонимизацию user
|
||||
$user = DB::connection('pgsql_supplier')->table('users')->where('id', $userId)->first();
|
||||
expect($user->email)->toContain('erased-');
|
||||
expect($user->first_name)->toBe('Удалено');
|
||||
expect($user->phone)->toContain('+7000');
|
||||
|
||||
// pd_processing_log должен содержать запись
|
||||
$log = DB::connection('pgsql_supplier')
|
||||
->table('pd_processing_log')
|
||||
->where('subject_id', $userId)
|
||||
->where('subject_type', 'user')
|
||||
->where('action', 'deleted')
|
||||
->where('actor_admin_user_id', $adminId)
|
||||
->first();
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->purpose)->toBe('152-FZ erasure');
|
||||
});
|
||||
|
||||
it('executeErasure anonymises supplier_lead phone and raw_payload', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
// Создаём stub admin через pgsql_supplier, чтобы FK pd_processing_log работал
|
||||
// независимо от DatabaseTransactions-транзакции default-соединения.
|
||||
$stubEmail = 'pd-lead-stub-'.uniqid().'@system.local';
|
||||
$adminId = (int) DB::connection('pgsql_supplier')->table('saas_admin_users')->insertGetId([
|
||||
'email' => $stubEmail,
|
||||
'full_name' => 'Lead Test Stub',
|
||||
'password_hash' => '$2y$04$system-stub-not-loginable',
|
||||
'role' => 'super_admin',
|
||||
'is_active' => false,
|
||||
'sso_provider' => 'local',
|
||||
'is_break_glass' => false,
|
||||
]);
|
||||
|
||||
$victimPhone = '+79887654321';
|
||||
|
||||
$leadId = (int) DB::connection('pgsql_supplier')->table('supplier_leads')->insertGetId([
|
||||
'platform' => 'B1',
|
||||
'raw_payload' => json_encode(['phone' => $victimPhone, 'name' => 'Жертва']),
|
||||
'phone' => $victimPhone,
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
|
||||
$requestId = pdInsertRequest([
|
||||
'subject_phone' => $victimPhone,
|
||||
'request_type' => 'deletion',
|
||||
'status' => 'received',
|
||||
]);
|
||||
|
||||
$response = $this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
|
||||
'admin_user_id' => $adminId,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('counts.leads'))->toBeGreaterThanOrEqual(1);
|
||||
|
||||
$lead = DB::connection('pgsql_supplier')->table('supplier_leads')->where('id', $leadId)->first();
|
||||
expect($lead->phone)->toBe('+7000XXXXXXX');
|
||||
$payload = json_decode($lead->raw_payload, true);
|
||||
expect($payload['erased'])->toBe(true);
|
||||
});
|
||||
|
||||
it('executeErasure marks pd_subject_request as completed', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$adminId = pdStubAdminUser();
|
||||
$requestId = pdInsertRequest([
|
||||
'subject_email' => 'mark-completed-'.uniqid().'@example.com',
|
||||
'request_type' => 'deletion',
|
||||
'status' => 'received',
|
||||
]);
|
||||
|
||||
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase", [
|
||||
'admin_user_id' => $adminId,
|
||||
])->assertOk();
|
||||
|
||||
$row = DB::connection('pgsql_supplier')
|
||||
->table('pd_subject_requests')
|
||||
->where('id', $requestId)
|
||||
->first();
|
||||
|
||||
expect($row->status)->toBe('completed');
|
||||
expect($row->completed_at)->not->toBeNull();
|
||||
expect($row->response_text)->toContain('users=');
|
||||
});
|
||||
|
||||
it('executeErasure rejects non-deletion request_type with 422', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$requestId = pdInsertRequest([
|
||||
'subject_email' => 'access-request@example.com',
|
||||
'request_type' => 'access',
|
||||
'status' => 'received',
|
||||
]);
|
||||
|
||||
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase")
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
it('executeErasure rejects already completed request with 422', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
$requestId = pdInsertRequest([
|
||||
'subject_email' => 'already-done-'.uniqid().'@example.com',
|
||||
'request_type' => 'deletion',
|
||||
'status' => 'completed',
|
||||
]);
|
||||
|
||||
$this->postJson("/api/admin/pd-subject-requests/{$requestId}/erase")
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
it('saas-admin middleware allows request in testing env', function (): void {
|
||||
// EnsureSaasAdmin в testing-окружении пропускает всех без проверки.
|
||||
$response = $this->getJson('/api/admin/pd-subject-requests');
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
it('PdErasureService throws InvalidArgumentException when both email and phone are null', function (): void {
|
||||
$service = app(PdErasureService::class);
|
||||
|
||||
expect(fn () => $service->eraseSubject(null, null, null, 1, null))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
@@ -161,3 +161,31 @@ it('writes audit-trail row in saas_admin_audit_log on POST', function () {
|
||||
->where('action', 'pricing_tiers.create_scheduled')->first();
|
||||
expect($log)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('AdminPricingTiers::store сохраняет цену 10.10 ₽ как ровно 1010 kopecks (без float-drift)', function () {
|
||||
$tiers = [];
|
||||
for ($i = 1; $i <= 6; $i++) {
|
||||
$tiers[] = ['tier_no' => $i, 'leads_in_tier' => 50, 'price_rub' => '10.10'];
|
||||
}
|
||||
$tiers[] = ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '10.10'];
|
||||
|
||||
$resp = $this->postJson('/api/admin/pricing-tiers', ['tiers' => $tiers]);
|
||||
$resp->assertCreated();
|
||||
|
||||
$expectedDate = now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();
|
||||
foreach (PricingTier::query()->where('effective_from', $expectedDate)->orderBy('tier_no')->get() as $row) {
|
||||
expect((int) $row->price_per_lead_kopecks)->toBe(1010);
|
||||
}
|
||||
});
|
||||
|
||||
test('AdminPricingTiers::store отклоняет некорректный price_rub (например "10.123" — три знака после точки)', function () {
|
||||
$tiers = [];
|
||||
for ($i = 1; $i <= 6; $i++) {
|
||||
$tiers[] = ['tier_no' => $i, 'leads_in_tier' => 50, 'price_rub' => '10.10'];
|
||||
}
|
||||
$tiers[] = ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '10.123'];
|
||||
|
||||
$resp = $this->postJson('/api/admin/pricing-tiers', ['tiers' => $tiers]);
|
||||
$resp->assertStatus(422);
|
||||
$resp->assertJsonValidationErrors(['tiers.6.price_rub']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function makeBalanceTenant(string $balanceRub): Tenant
|
||||
{
|
||||
return Tenant::factory()->create(['balance_rub' => $balanceRub]);
|
||||
}
|
||||
|
||||
it('sets exact balance and records signed manual_adjustment delta', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '2500.00',
|
||||
'reason' => 'Коррекция тестового баланса',
|
||||
]);
|
||||
|
||||
$resp->assertOk()
|
||||
->assertJsonPath('balance_rub', '2500.00')
|
||||
->assertJsonPath('delta', '1500.00');
|
||||
|
||||
$tenant->refresh();
|
||||
expect((string) $tenant->balance_rub)->toBe('2500.00');
|
||||
|
||||
$tx = BalanceTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
|
||||
->latest('id')->first();
|
||||
expect($tx)->not->toBeNull();
|
||||
expect((string) $tx->amount_rub)->toBe('1500.00');
|
||||
expect((string) $tx->balance_rub_after)->toBe('2500.00');
|
||||
expect($tx->amount_leads)->toBeNull();
|
||||
expect($tx->description)->toBe('Коррекция тестового баланса');
|
||||
});
|
||||
|
||||
it('records negative delta when lowering balance', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '300.00',
|
||||
]);
|
||||
|
||||
$resp->assertOk()->assertJsonPath('delta', '-700.00');
|
||||
|
||||
$tx = BalanceTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
|
||||
->latest('id')->first();
|
||||
expect((string) $tx->amount_rub)->toBe('-700.00');
|
||||
expect($tx->description)->toBe('Ручная корректировка баланса (админ)');
|
||||
});
|
||||
|
||||
it('accepts negative target balance (debt correction)', function () {
|
||||
$tenant = makeBalanceTenant('0.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '-500.00',
|
||||
])->assertOk()->assertJsonPath('balance_rub', '-500.00');
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('-500.00');
|
||||
});
|
||||
|
||||
it('rejects no-op (target equals current) with 422', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '1000.00',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('rejects malformed balance_rub with 422', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '10.123',
|
||||
])->assertStatus(422);
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => 'abc',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('returns 404 for missing or soft-deleted tenant', function () {
|
||||
$this->patchJson('/api/admin/tenants/99999999/balance', [
|
||||
'balance_rub' => '100.00',
|
||||
])->assertStatus(404);
|
||||
|
||||
$tenant = makeBalanceTenant('100.00');
|
||||
$tenant->delete();
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '200.00',
|
||||
])->assertStatus(404);
|
||||
});
|
||||
@@ -14,7 +14,6 @@ function makeBillingTenant(array $overrides = []): int
|
||||
'subdomain' => 'bt-'.bin2hex(random_bytes(4)),
|
||||
'organization_name' => 'Billing Test Co',
|
||||
'contact_email' => 'bt-'.bin2hex(random_bytes(3)).'@test.local',
|
||||
'webhook_token' => bin2hex(random_bytes(16)),
|
||||
'status' => 'active',
|
||||
'balance_rub' => '5000.00',
|
||||
'is_trial' => false,
|
||||
|
||||
@@ -64,7 +64,6 @@ test('GET /api/admin/incidents/{id} разрешает имена affected_tenan
|
||||
'subdomain' => 'inc-'.bin2hex(random_bytes(4)),
|
||||
'organization_name' => 'Affected Org',
|
||||
'contact_email' => 'a@test.local',
|
||||
'webhook_token' => bin2hex(random_bytes(16)),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$id = makeShowIncident($this->adminId, ['affected_tenant_ids' => '{'.$tenantId.'}']);
|
||||
|
||||
@@ -11,8 +11,10 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Full operational journaling pipeline integration test.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user