Compare commits
294 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b2597ff4a | |||
| d2100a9bab | |||
| 418bd1fe70 | |||
| 0902de96c7 | |||
| 5b7d958ecb | |||
| 06dc4a2a91 | |||
| fdfaa956bd | |||
| 675b7f2237 | |||
| 753c3901b2 | |||
| 38ecbc682f | |||
| 7e79bf714a | |||
| 69aeac3756 | |||
| 84272c5ccd | |||
| 7a56442149 | |||
| 0b07debb7a | |||
| 57a7f55bf1 | |||
| 0ea3b5d70d | |||
| e630976ae1 | |||
| d51ba5f57d | |||
| e2e300f4f6 | |||
| 08e2a969e8 | |||
| 5682926626 | |||
| a846eed9dc | |||
| 7e5c297394 | |||
| ce02d1adad | |||
| 8b6b410119 | |||
| 51966328c5 | |||
| ea9430d8a7 | |||
| 659f2b0757 | |||
| f48f79d2f3 | |||
| 0da72778c3 | |||
| d568bf84eb | |||
| 752d80af7c | |||
| 5265b82ad1 | |||
| 3318498587 | |||
| cbfd9738de | |||
| 4d6f92c649 | |||
| c7079ac8e4 | |||
| bfa228197d | |||
| cc444e7f53 | |||
| 982cd00678 | |||
| 97982f85fe | |||
| 3d5fb86e7c | |||
| 6cb8be6919 | |||
| 59c3ef4112 | |||
| fe338e09f9 | |||
| c9f2be37fe | |||
| d7fe7ba458 | |||
| bb41315df4 | |||
| b6a0938ccd | |||
| a3e7573387 | |||
| 9188e1cefd | |||
| 76cb825331 | |||
| 6f70cca90e | |||
| 48eaffece8 | |||
| 919971d085 | |||
| 6bf0ebfd1d | |||
| 5cad78b73d | |||
| 3bb2bf92e2 | |||
| 82b95f4bcb | |||
| 9a56d92440 | |||
| 0e5f47c5e9 | |||
| cbfb504a54 | |||
| 8d037e1f04 | |||
| e8782c47b3 | |||
| 3dfb96ba47 | |||
| b92d9b3bfc | |||
| 58784b182d | |||
| 4010495d19 | |||
| 2bf25db72e | |||
| da4ab729df | |||
| 4f362a9e62 | |||
| 633435e990 | |||
| 050b349af5 | |||
| 25ac64f9b0 | |||
| dcd7163738 | |||
| 30334aaa8c | |||
| 6cff2c3854 | |||
| 318e3ca75d | |||
| 763469c072 | |||
| b437597286 | |||
| cf97898833 | |||
| 12f88f32c1 | |||
| 8355f7a045 | |||
| df5f0118e9 | |||
| 9480c44092 | |||
| 831ea553fa | |||
| 530f2cb6d2 | |||
| fb0309d357 | |||
| 55123bfe9f | |||
| d512b8e6be | |||
| 3c3bdc2d3d | |||
| 808461295a | |||
| 41deac7bc8 | |||
| 2fe4e1c4bc | |||
| 975570e555 | |||
| 2b052ab1a7 | |||
| c6f9dc2d76 | |||
| b917360e9b | |||
| e5f20adcad | |||
| 32f9133e87 | |||
| f6b52df613 | |||
| 26999ca597 | |||
| 4357a0e732 | |||
| 9d4a30c314 | |||
| 3eb6c7fecd | |||
| 0817c81e67 | |||
| b2cbc57533 | |||
| 7d31d0be39 | |||
| 2b7a71c5b6 | |||
| af441961d9 | |||
| 2ec8707a03 | |||
| 81f52fd1c6 | |||
| 455bc1439b | |||
| 000c196e51 | |||
| 49aa4ba725 | |||
| 10eed4e7e4 | |||
| af6c328933 | |||
| 2e2abe0e53 | |||
| d377d97737 | |||
| 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.
|
||||
@@ -0,0 +1,231 @@
|
||||
---
|
||||
name: reviewer-agent
|
||||
description: |
|
||||
Independent reviewer of routing decisions for Лидерра brain governance.
|
||||
Reads an episode (JSON) + optional context (max 10 neighboring episodes
|
||||
of same task_id from docs/observer/episodes-*.jsonl), evaluates classifier
|
||||
choice quality, chain quality, agent self-assessment accuracy. Returns
|
||||
structured JSON review.
|
||||
|
||||
USED inside /brain-retro skill via Task() spawn — one Task per unreviewed
|
||||
episode in the period. NEVER edits files. NEVER commits. NEVER touches
|
||||
nodes.yaml / episodes / нормативку.
|
||||
|
||||
Escalates to controller if episode is malformed or schema unknown.
|
||||
|
||||
Reviewer-agent is part of LLM-first router overhaul (see spec
|
||||
docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md
|
||||
§4.6 v2.1). Replaces direct Opus API call (v2.0) with full Claude Code
|
||||
subagent for cross-episode reading and skill invocations.
|
||||
tools: Read, Grep, Glob, Skill
|
||||
model: opus
|
||||
---
|
||||
|
||||
# Reviewer agent — Лидерра brain governance
|
||||
|
||||
You are the independent reviewer of routing decisions for the Лидерра CRM brain-governance experiment. Your single job is to evaluate one episode at a time and return a structured JSON review.
|
||||
|
||||
You DO NOT edit files. You DO NOT commit. You DO NOT modify the episode you are reviewing. You DO NOT make architectural decisions. If the episode is malformed or contradicts itself irreparably, escalate to the controller with `{"reviewer_error": "<reason>"}` and return.
|
||||
|
||||
## Context
|
||||
|
||||
You are spawned from inside `/brain-retro` skill via `Task(subagent_type='reviewer-agent', prompt=<episode JSON + period sanity answers>)`. Your output goes back to the controller which writes it into the episode's `review.*` fields.
|
||||
|
||||
Spec reference: `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md` §4.6.
|
||||
|
||||
## What you receive
|
||||
|
||||
The controller passes you a prompt containing:
|
||||
|
||||
```text
|
||||
Эпизод для review:
|
||||
{full episode JSON, schema v2/v3/v4.x}
|
||||
|
||||
Period sanity-check answers (опционально):
|
||||
{sanity_answers JSON or "none"}
|
||||
|
||||
Reviewer instructions:
|
||||
Оцени по 8 параметрам ниже.
|
||||
Return ONLY JSON, no prose.
|
||||
```
|
||||
|
||||
## What you can read additionally (context)
|
||||
|
||||
Use `Read`, `Grep`, `Glob` to fetch:
|
||||
|
||||
1. **Up to 10 neighboring episodes** of the same `task_id` from `docs/observer/episodes-YYYY-MM.jsonl`. Use Grep to find them by `task_id`. **HARD LIMIT: 10**. If more exist, take the 10 closest in time.
|
||||
2. **`docs/registry/nodes.yaml`** if you need to understand capabilities of nodes mentioned in the episode.
|
||||
3. **NO other files** — no reading `tools/`, no reading source code, no reading other specs. Stay focused.
|
||||
|
||||
## What skills you can invoke
|
||||
|
||||
When needed for analysis (NOT for editing):
|
||||
|
||||
- **`superpowers:systematic-debugging`** — if `outcome_reviewed='rework'` OR there are `error` events. Apply 3-hypothesis methodology to identify `error_root_cause`.
|
||||
- **`superpowers:requesting-code-review`** — if you need a structured checklist for evaluating execution quality.
|
||||
- **`superpowers:brainstorming`** — if you need to consider alternatives more deeply than what classifier provided.
|
||||
|
||||
Skills are tools for YOUR thinking. They don't change anything. After invocation, return back to evaluating the episode.
|
||||
|
||||
## What you evaluate (8 dimensions)
|
||||
|
||||
Return JSON with these exact keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"node_quality": "correct | wrong_node | overkill | underkill | disputable",
|
||||
"chain_quality": "correct | missing_step | extra_step | wrong_order | n/a",
|
||||
"gap_assessment": "acceptable | mistake_should_complete | mistake_should_not_start | n/a",
|
||||
"agent_self_assessment_accuracy": "accurate | over_confident | under_confident | no_self_assessment",
|
||||
"error_root_cause": "wrong_skill | wrong_tool | wrong_chain_order | external_failure | n/a",
|
||||
"alternative_better": "<node_id from alternatives_considered or null>",
|
||||
"outcome_reviewed": "success | soft_success | rework | blocked",
|
||||
"reasoning": "1-3 предложения объяснения. Конкретно, не общо."
|
||||
}
|
||||
```
|
||||
|
||||
### Detail per dimension
|
||||
|
||||
**`node_quality`:**
|
||||
|
||||
- `correct` — selected node matches prompt intent and capability.
|
||||
- `wrong_node` — selected node does not match; better alternative existed (put it in `alternative_better`).
|
||||
- `overkill` — node is more heavy than needed (e.g., systematic-debugging for typo fix).
|
||||
- `underkill` — node is too light (e.g., direct edit for security-sensitive area).
|
||||
- `disputable` — reasonable but not obviously best.
|
||||
|
||||
**`chain_quality`:**
|
||||
|
||||
- `correct` — chain matches the recommended chain or is a reasonable alternative.
|
||||
- `missing_step` — important step skipped (e.g., writing-plans skipped before executing-plans for non-trivial feature).
|
||||
- `extra_step` — unnecessary step added.
|
||||
- `wrong_order` — steps executed in wrong order.
|
||||
- `n/a` — single-node task, no chain.
|
||||
|
||||
**`gap_assessment`** (only if `chain_gaps[].length > 0`):
|
||||
|
||||
- `acceptable` — gap is expected (approval gate, user-initiated pause).
|
||||
- `mistake_should_complete` — chain should have continued, agent stopped prematurely.
|
||||
- `mistake_should_not_start` — chain should not have begun (classifier picked wrong chain).
|
||||
|
||||
**`agent_self_assessment_accuracy`:**
|
||||
|
||||
- Сравни `self_assessment.confidence_in_choice` с реальным `outcome_inferred`/`outcome_reviewed`.
|
||||
- `confidence ≥ 0.7 + outcome=rework` → `over_confident`.
|
||||
- `confidence ≤ 0.4 + outcome=success` → `under_confident`.
|
||||
- Соответствие → `accurate`.
|
||||
- `self_assessment_pending: true` → `no_self_assessment`.
|
||||
|
||||
**`error_root_cause`** (only if `events.error.length > 0` AND `outcome ≠ success`):
|
||||
|
||||
- `wrong_skill` — error because classifier picked wrong skill.
|
||||
- `wrong_tool` — error from tool within correct skill (e.g., Edit instead of MultiEdit on multi-occurrence).
|
||||
- `wrong_chain_order` — error from misordered chain steps.
|
||||
- `external_failure` — network/lock/race/API-down (not agent's fault).
|
||||
- `n/a` — no error or success outcome.
|
||||
|
||||
**`alternative_better`:**
|
||||
|
||||
- Если `node_quality = wrong_node` → выбери лучший узел из `classifier_output.alternatives_considered[].node`.
|
||||
- Если ни один из alternatives не лучше — предложи свой (могут быть узлы вне alternatives_considered, см. `docs/registry/nodes.yaml`).
|
||||
- Иначе → `null`.
|
||||
|
||||
**`outcome_reviewed`** (proxy — закрывает 19.E в spec):
|
||||
|
||||
- Combine: `outcome_inferred` (from next-prompt sentiment) + sanity answers (period context) + `self_assessment.confidence` vs actual.
|
||||
- `success` — task completed and user moved on positively.
|
||||
- `soft_success` — task completed but with caveats (corrections, partial).
|
||||
- `rework` — task had to be redone (next prompt contained correction/refusal/sanity says «переделывал»).
|
||||
- `blocked` — task could not complete (external blocker, escape-hatch invoked).
|
||||
|
||||
**`reasoning`:**
|
||||
|
||||
- 1-3 предложения объяснения твоего решения.
|
||||
- Конкретно: ссылайся на episode fields, not general principles.
|
||||
- Если использовал cross-episode context — упомяни.
|
||||
|
||||
## Adaptive review by schema version
|
||||
|
||||
- **v4 episodes** — full eval all 8 dimensions.
|
||||
- **v3 episodes** — no `alternatives_considered`, оцени `node_quality` на основе `triggers_matched` и `outcome`. `alternative_better` ставь null.
|
||||
- **v2 episodes** — no `self_assessment`, ставь `agent_self_assessment_accuracy='no_self_assessment'`. Остальное как обычно.
|
||||
- **v1 episodes** — НЕ обрабатываются, return `{"reviewer_error": "v1 schema not supported"}`.
|
||||
|
||||
## What you DON'T do
|
||||
|
||||
- Не редактируешь episode (controller сам пишет review.* поля по твоему JSON output).
|
||||
- Не правишь nodes.yaml.
|
||||
- Не правишь spec.
|
||||
- Не делаешь коммиты.
|
||||
- Не общаешься с пользователем — твой output идёт controller'у.
|
||||
- Не читаешь больше 10 соседних эпизодов (cost cap).
|
||||
- Не читаешь tools/* / source code — это вне scope review.
|
||||
|
||||
## Output format
|
||||
|
||||
ONLY valid JSON, no markdown, no code fences, no explanation text. Controller парсит твой output напрямую как JSON.
|
||||
|
||||
Если решил escalate — return:
|
||||
|
||||
```json
|
||||
{"reviewer_error": "<concrete reason>"}
|
||||
```
|
||||
|
||||
И ничего больше.
|
||||
|
||||
## Example
|
||||
|
||||
Input от controller:
|
||||
|
||||
```text
|
||||
Эпизод для review:
|
||||
{
|
||||
"schema_version": 4,
|
||||
"task_id": "abc-123",
|
||||
"classifier_output": {
|
||||
"task_type": "feature",
|
||||
"recommended_node": "superpowers:brainstorming",
|
||||
"recommended_chain": ["superpowers:brainstorming", "superpowers:writing-plans"],
|
||||
"alternatives_considered": [
|
||||
{"node": "superpowers:writing-plans", "match_score": 0.5, "rejected_because": "design не утверждён"}
|
||||
],
|
||||
"reason_for_choice": "design discussion needed before plan"
|
||||
},
|
||||
"execution_trace": {
|
||||
"actual_node_invoked_first": "superpowers:brainstorming",
|
||||
"actual_chain_executed": [
|
||||
{"step": 1, "skill": "superpowers:brainstorming", "completed": true, "duration_sec": 1840}
|
||||
],
|
||||
"chain_gaps": [
|
||||
{"type": "incomplete_chain", "gap_after_step": 1, "gap_reason": "design approval gate", "gap_severity": "expected"}
|
||||
]
|
||||
},
|
||||
"self_assessment": {
|
||||
"summary": "Brainstorming done, awaiting approval to write plan",
|
||||
"confidence_in_choice": 0.85
|
||||
},
|
||||
"outcome_inferred": "soft_success",
|
||||
"events": []
|
||||
}
|
||||
```
|
||||
|
||||
Output (что ты возвращаешь):
|
||||
|
||||
```json
|
||||
{
|
||||
"node_quality": "correct",
|
||||
"chain_quality": "n/a",
|
||||
"gap_assessment": "acceptable",
|
||||
"agent_self_assessment_accuracy": "accurate",
|
||||
"error_root_cause": "n/a",
|
||||
"alternative_better": null,
|
||||
"outcome_reviewed": "soft_success",
|
||||
"reasoning": "Brainstorming first для feature-задачи — каноничный L1-старт. Gap after step 1 ожидаем: дизайн нуждается в approval. Self-assessment confidence=0.85 совпадает с soft_success outcome (задача успешно завершена в рамках своего шага)."
|
||||
}
|
||||
```
|
||||
|
||||
## Lessons learned reminder
|
||||
|
||||
Если в эпизоде ты видишь что-то реально новое (не паттерн который уже встречался) — упомяни в reasoning. Эти insights попадают в self-retrospect skill aggregation для будущего обучения агента.
|
||||
|
||||
Но НЕ делай self-retrospect сам — это отдельный skill.
|
||||
@@ -55,6 +55,46 @@
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-tool-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-memory-coverage.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-tdd-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-branch-switch.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-verify-before-push.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -75,6 +115,31 @@
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-verify-record.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
@@ -83,9 +148,67 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/observer-stop-hook.mjs",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-stop-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-coverage-verify.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-classifier-match.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-prehook.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-prompt-injection.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-embedding-warmup.mjs",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: brain-retro
|
||||
description: Use ONCE PER SPRINT (or by explicit user invocation "брейн-ретро") to aggregate evidence from docs/observer/episodes-*.jsonl + notes/*.md and propose regulatory candidates. Read-only — never edits Tooling/Pravila/PSR_v1 automatically; only proposes.
|
||||
description: Use каждые 1-2 недели OR при триггере sanity-check threshold (Phase 3 cadence, spec §4.7). Also fires on explicit «брейн-ретро» / «/brain-retro». Aggregates evidence from docs/observer/episodes-*.jsonl + notes/*.md, asks 3-4 sanity questions via AskUserQuestion (PII-filtered), spawns reviewer-agent subagent per unreviewed episode (Opus, fallback to tools/brain-retro-opus-reviewer.mjs on subagent crash), and proposes regulatory candidates. Read-only — never edits Tooling/Pravila/PSR_v1 automatically; only proposes.
|
||||
---
|
||||
|
||||
# Brain Retro
|
||||
@@ -26,11 +26,15 @@ Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces can
|
||||
3. **Read optional notes**: glob `docs/observer/notes/*.md` filtered by date.
|
||||
4. **Update read-counter**: run `node tools/observer-of-observer.mjs record`. This atomically bumps `docs/observer/.read-counter.json` `last_read_at` to now and increments `read_count_last_period`. (Side-effect — used by C3 observer-of-observer for 54-week self-prune detection.)
|
||||
5. **Run the deterministic analyzer**: `node tools/brain-retro-analyzer.mjs docs/observer/episodes-YYYY-MM.jsonl` (pass every monthly file in the period). It returns JSON with `episodeCount`, `observerErrorCount`, `tasks` (episodes grouped into tasks), `causalChains` (error→fix candidates) and `factorMatrix` (outcome distribution per factor). The analyzer deduplicates the routing-gate double-write and infers the true `outcome` of each episode from the next episode's `prompt_signal` — never trust the stored `outcome` (it is `unknown` at write time).
|
||||
6. **Aggregate** per `references/aggregation-template.md` — fill the Factor analysis matrix from the analyzer's `factorMatrix`, the task groups from `tasks`, the causal-chain candidates from `causalChains`.
|
||||
5a. **[Phase 3] Sanity questions (spec §4.7)** — `node tools/brain-retro-sanity-generator.mjs` (called as a module from analyzer-driven flow, OR direct via `import { generateCandidateQuestions } from '../../../tools/brain-retro-sanity-generator.mjs'`) returns up to 5 candidate questions. Pick 3-4, ask via AskUserQuestion (multiple-choice + free comment). **Before persist:** sanitize free comments with `tools/observer-pii-filter.mjs` (`sanitize` export, RU_PHONE / EMAIL / TOKEN strip). Write answers to `docs/observer/sanity-checks/YYYY-MM-DD.json` `{schema_version: 1, questions: [...]}`.
|
||||
5b. **[Phase 3] Reviewer subagent pickup (spec §4.6)** — for each unreviewed episode in the period: `Task(subagent_type='reviewer-agent', prompt=<episode JSON + sanity-answers context>)`. Parse the returned JSON, write `review.*` + `outcome_reviewed` + `outcome_reviewed_source` into the episode. Per-episode try/catch — on subagent crash/timeout, fall back to `tools/brain-retro-opus-reviewer.mjs` `reviewViaDirectApi(episode)` (direct Opus API). If both fail, leave `review.reviewer_error: <msg>` for the next retro.
|
||||
6. **Aggregate** per `references/aggregation-template.md` — fill the Factor analysis matrix from the analyzer's `factorMatrix`, the task groups from `tasks`, the causal-chain candidates from `causalChains`, plus the new sections: sanity-check results, reviewer-agent outcomes distribution, self-retrospect trigger status.
|
||||
7. **Propose candidates** — clearly separated section «Candidates for owner review». Each candidate has rationale + suggested edit + rejection-option.
|
||||
8. **Save retro note**: `docs/observer/notes/YYYY-MM-DD-brain-retro.md` with full aggregation.
|
||||
8a. **Refresh STATUS.md**: `node tools/status-md-generator.mjs` — auto-rebuild dashboard so it reflects the just-finished retro (`Last /brain-retro: 0 day(s) ago`, current episode count, refreshed C1–C5 controller statuses). Without this, STATUS.md only updates on the next git commit.
|
||||
9. **Report to user**: high-signal summary.
|
||||
8a. **Refresh STATUS.md**: `node tools/status-md-generator.mjs` — auto-rebuild dashboard so it reflects the just-finished retro (`Last /brain-retro: 0 day(s) ago`, current episode count, refreshed C1–C5 controller statuses, cost report from `~/.claude/runtime/cost-daily.json`). Without this, STATUS.md only updates on the next git commit.
|
||||
9. **[Phase 3] Self-retrospect trigger (spec §4.8)** — read `docs/observer/.self-retrospect-counter.json`. If `episodes_since_last >= 50`, propose to the user invoking `/self-retrospect` (opt-in skill at `.claude/skills/self-retrospect/`). Bump `episodes_since_last` by the period's episode count regardless.
|
||||
10. **Cost report** — read `~/.claude/runtime/cost-daily.json`; include classifier + self_assessment + reviewer cost totals for the period in the retro note.
|
||||
11. **Report to user**: high-signal summary including sanity highlights, reviewer outcome distribution, and any escalations.
|
||||
|
||||
## Output anatomy
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: self-retrospect
|
||||
description: |
|
||||
Opt-in self-retrospect: один раз за период (по умолчанию ~50 эпизодов или
|
||||
«триггер от заказчика») контроллер прогоняется по своим эпизодам и
|
||||
отвечает на вопросы про собственные паттерны: где переоценил уверенность,
|
||||
где зря выбрал direct вместо навыка, где наоборот стоило выбрать direct
|
||||
но навык сработал лишним. Результат пишется как заметка в
|
||||
`docs/observer/notes/<YYYY-MM-DD>-self-retrospect.md`, НЕ как эпизод.
|
||||
|
||||
Triggers: явное «/self-retrospect» от заказчика, OR порог
|
||||
`docs/observer/.self-retrospect-counter.json:episodes_since_last >= 50`
|
||||
(контроллер видит порог в STATUS.md C5 и предлагает запуск).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md §4.8.
|
||||
tools: Read, Grep, Glob, AskUserQuestion, Write, Edit
|
||||
---
|
||||
|
||||
# self-retrospect — Phase 3 Task 19 stub
|
||||
|
||||
This is the **stub** for the opt-in self-retrospect skill (Phase 3 Task 19).
|
||||
The full procedure (read 50 episodes → answer 5-7 introspection questions
|
||||
via AskUserQuestion → write note → bump counter) is **wired in Phase 3 Task
|
||||
20** when the analyzer and STATUS.md generator surface the
|
||||
`episodes_since_last >= 50` threshold.
|
||||
|
||||
For now, when invoked:
|
||||
|
||||
1. Read `docs/observer/.self-retrospect-counter.json`.
|
||||
2. Read the last N episodes from `docs/observer/episodes-YYYY-MM.jsonl`
|
||||
(default N = `episodes_since_last`).
|
||||
3. Ask the user (via AskUserQuestion) 3-5 retrospective questions about
|
||||
own routing patterns over that window (template in `references/` —
|
||||
created in Task 20).
|
||||
4. Sanitize answers via `tools/observer-pii-filter.mjs` (`sanitize` export)
|
||||
before writing.
|
||||
5. Write `docs/observer/notes/YYYY-MM-DD-self-retrospect.md`.
|
||||
6. Reset counter: `episodes_since_last = 0`, `last_run_at = now`.
|
||||
|
||||
Until Task 20 wires steps 3 and the references template, invoking this
|
||||
skill should walk through steps 1-2 + 4-6 manually and ask the user the
|
||||
3-5 questions inline.
|
||||
@@ -2,6 +2,14 @@
|
||||
# .gitignore — Лидерра
|
||||
# =============================================================================
|
||||
|
||||
# ── Session junk (broken PS paths from parallel Claude sessions, deploy tarballs, ad-hoc screenshots) ──
|
||||
CTemp*
|
||||
CWindowsTemp*
|
||||
phase[0-9]*-update.tar.gz
|
||||
recheck-*.png
|
||||
.tmp-*.sql
|
||||
tools/cloudflared.*
|
||||
|
||||
# ── Node / npm ──────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
@@ -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().
|
||||
*
|
||||
|
||||
@@ -164,7 +164,14 @@ class ProjectController extends Controller
|
||||
{
|
||||
$request->validate(['is_active' => ['required', 'boolean']]);
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$project->update(['is_active' => $request->boolean('is_active')]);
|
||||
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||||
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта.
|
||||
$newActive = $request->boolean('is_active');
|
||||
$project->update([
|
||||
'is_active' => $newActive,
|
||||
'paused_at' => $newActive ? null : now(),
|
||||
]);
|
||||
|
||||
// #10: pause/resume must reach the supplier. The job's group recompute pushes
|
||||
// status=paused when no active project of the group remains (resume → active).
|
||||
|
||||
@@ -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 если
|
||||
@@ -83,7 +83,7 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'vid' => 'required|integer|min:1',
|
||||
'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'],
|
||||
'project' => ['required', 'string', 'max:255'], // Phase 3: regex /^B[123]_.+$/ снят — non-B → platform=DIRECT
|
||||
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
|
||||
'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"],
|
||||
'tag' => 'nullable|string|max:255',
|
||||
@@ -182,8 +182,12 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
private function parsePlatform(string $project): string
|
||||
{
|
||||
preg_match('/^(B[123])_/', $project, $m);
|
||||
// Phase 3: проекты без B-префикса → DIRECT (раньше silent fallback на 'B1'
|
||||
// приводил к неверной маршрутизации).
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return $m[1] ?? 'B1';
|
||||
return 'DIRECT';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,28 +11,26 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
/**
|
||||
* Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
|
||||
*
|
||||
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
|
||||
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
|
||||
* реального механизма аутентификации нет.
|
||||
* СТОПГЭП (2026-05-25): защита боевой админ-зоны (/admin + /api/admin/*)
|
||||
* перенесена на уровень nginx — отдельный HTTP Basic Auth с собственным
|
||||
* паролем (`/etc/nginx/.htpasswd-admin`, location ^~ /admin и ^~ /api/admin).
|
||||
* Поэтому middleware больше не закрывает зону на проде: дверь держит nginx.
|
||||
*
|
||||
* Поведение стаба:
|
||||
* - dev / testing (local, testing) → пропускаем. Admin-панель работает на
|
||||
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
|
||||
* - прочие окружения (production / staging) → fail-closed 503: зона
|
||||
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
|
||||
* открытый /api/admin/* в проде.
|
||||
* Ранее (Sprint 3F) здесь был fail-closed 503 вне dev/testing — он закрывал
|
||||
* всю админку на проде наглухо, т.к. настоящий saas-admin SSO (Yandex 360)
|
||||
* ещё не готов (гейтится Б-1 + DO-4). Замок 503 снят осознанно: оголять
|
||||
* /api/admin/* в интернет нельзя, но nginx-пароль её прикрывает.
|
||||
*
|
||||
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
|
||||
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
|
||||
* admin_user_id для audit-trail по-прежнему резолвится трейтом
|
||||
* ResolvesAdminUserId (стаб super_admin) — это отдельная зона.
|
||||
*
|
||||
* TODO (после Б-1 + DO-4): заменить nginx-дверь на настоящий saas-admin
|
||||
* guard (Yandex 360 SSO-сессия + роль), вернуть проверку в это middleware.
|
||||
*/
|
||||
class EnsureSaasAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -175,11 +171,16 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
*/
|
||||
private function parseProjectField(string $project): array
|
||||
{
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) !== 1) {
|
||||
throw new RuntimeException("Cannot parse supplier project field: '{$project}'");
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) {
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
} else {
|
||||
// Phase 3: проекты без B-префикса попадают в DIRECT.
|
||||
// Весь project считается identifier-частью; signal_type определяется
|
||||
// тем же regex'ом, что для $rest у B-префиксных.
|
||||
$platform = 'DIRECT';
|
||||
$rest = $project;
|
||||
}
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
|
||||
// Домен с латинским TLD ≥2 букв (последний сегмент — только буквы), допускается
|
||||
// в любой позиции строки. Соответствует чистому rest и встроенному в текст домену.
|
||||
@@ -205,19 +206,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 +250,73 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
}
|
||||
$project = $lockedProject;
|
||||
|
||||
// Phase 2 fix: merge с CSV-recovered deal если webhook догоняет.
|
||||
// Идемпотентность race condition между CsvReconcileJob (vid=NULL, recovered
|
||||
// from CSV) и webhook (vid=int, реальный supplier-id). До этой проверки они
|
||||
// создавали 2 deal'a (DD снят Spec B Phase 1). Merge выполняется только если:
|
||||
// - webhook ЕСТЬ настоящий vid (lead.vid !== null) — без vid merge'ить нечего;
|
||||
// - csv-recovered deal существует за последние 24h, тот же phone+project+tenant;
|
||||
// - csv-recovered deal БЕЗ source_crm_id (т.е. он именно CSV-recovered, не другой webhook).
|
||||
// При merge: UPDATE existing.source_crm_id, INSERT supplier_lead_deliveries,
|
||||
// БЕЗ chargeForDelivery (LeadCharge уже есть с момента CSV recovery).
|
||||
$existingMergeable = null;
|
||||
if ($lead->vid !== null) {
|
||||
$existingMergeable = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('phone', (string) $lead->phone)
|
||||
->where('project_id', $project->id)
|
||||
->whereNull('source_crm_id')
|
||||
->where('received_at', '>=', now()->subDay())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
}
|
||||
if ($existingMergeable !== null) {
|
||||
// Заполняем supplier_lead.id у обоих SupplierLead → одному Deal
|
||||
DB::table('supplier_lead_deliveries')->insert([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'deal_id' => $existingMergeable->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Обновляем только source_crm_id + updated_at через DB::table.
|
||||
// NB (регрессия 26.05.2026 04:12-05:03 UTC, 9 failed_jobs):
|
||||
// received_at — partition key, и lead_charges имеет FK
|
||||
// (deal_id, deal_received_at) с ON DELETE CASCADE, но
|
||||
// ON UPDATE NO ACTION (default). Любое изменение received_at
|
||||
// ломает FK даже в той же месячной партиции (даже DEFERRABLE
|
||||
// INITIALLY DEFERRED не помогает — проверка падает на COMMIT).
|
||||
// CSV-recovered received_at сохраняем как есть — отличие на минуты
|
||||
// несущественно, чем риск каскадного DELETE lead_charges.
|
||||
DB::table('deals')
|
||||
->where('id', $existingMergeable->id)
|
||||
->where('received_at', $existingMergeable->received_at)
|
||||
->update(['source_crm_id' => $lead->vid, 'updated_at' => now()]);
|
||||
|
||||
Log::info('supplier_lead.merged_into_csv_recovered', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'merged_into_deal_id' => $existingMergeable->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return true; // считаем «доставленным», но без второго списания
|
||||
}
|
||||
|
||||
// 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 +338,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 +368,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 +422,6 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'price_kopecks' => $e->priceKopecks,
|
||||
'balance_rub' => $e->balanceRub,
|
||||
'balance_leads' => $e->balanceLeads,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -126,10 +126,16 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
$missing = array_diff_key($csvByKey, $existingKeys);
|
||||
|
||||
$recoveredCount = 0;
|
||||
$unparseableCount = 0;
|
||||
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 уровне.
|
||||
// Считаем такие строки отдельно, чтобы исключить из формулы drift'а
|
||||
// (иначе ~40-50% мусора каждый запуск стабильно даёт false-positive drift_alert).
|
||||
$unparseableCount++;
|
||||
Log::info('csv_reconcile.unparseable_project_skipped', [
|
||||
'project' => $row['project'],
|
||||
]);
|
||||
|
||||
@@ -159,7 +165,14 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
$matchedCount = $totalCsvRows - count($missing);
|
||||
$driftRatio = $totalCsvRows > 0 ? count($missing) / $totalCsvRows : 0.0;
|
||||
// drift считается только по «реальным» пропускам (parseable, не junk):
|
||||
// real_missing = count(missing) - unparseable (всегда ≥ 0)
|
||||
// parseable_tot = total_csv_rows - unparseable
|
||||
// Это убирает класс «поставщик кладёт телефон/URL в поле project →
|
||||
// строки скипаются → drift искусственно завышен» (см. ПИЛОТ 22.05, 25.05).
|
||||
$realMissing = max(0, count($missing) - $unparseableCount);
|
||||
$parseableTotal = max(0, $totalCsvRows - $unparseableCount);
|
||||
$driftRatio = $parseableTotal > 0 ? $realMissing / $parseableTotal : 0.0;
|
||||
$status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok';
|
||||
|
||||
$update = [
|
||||
@@ -167,6 +180,7 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
'total_csv_rows' => $totalCsvRows,
|
||||
'matched_count' => $matchedCount,
|
||||
'recovered_count' => $recoveredCount,
|
||||
'unparseable_count' => $unparseableCount,
|
||||
'drift_ratio' => $driftRatio,
|
||||
'status' => $status,
|
||||
];
|
||||
@@ -217,14 +231,23 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает platform (B1/B2/B3) из имени проекта формата `B[123]_<rest>`.
|
||||
* Возвращает null если не парсится — caller пропустит строку с warning.
|
||||
* Извлекает platform из имени проекта:
|
||||
* - `B[123]_<rest>` → 'B1' / 'B2' / 'B3';
|
||||
* - Phase 3: иначе, если строка непустая и состоит из identifier-символов
|
||||
* (домены / телефоны / SMS-отправители) → 'DIRECT';
|
||||
* - откровенный мусор (только спец-символы, пусто) → null (unparseable).
|
||||
*/
|
||||
private function extractPlatform(string $project): ?string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
// Phase 3: всё что выглядит как разумный identifier (домен / телефон / SMS-sender) → DIRECT.
|
||||
// unparseable_count теперь только для откровенного мусора (пустые / только спец-символы).
|
||||
$trimmed = trim($project);
|
||||
if ($trimmed !== '' && preg_match('/^[\w\-.а-яА-Я0-9\/() +]+$/u', $trimmed) === 1) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -40,6 +40,7 @@ class Project extends Model
|
||||
'tag',
|
||||
'type',
|
||||
'is_active',
|
||||
'paused_at',
|
||||
'daily_limit_target',
|
||||
'effective_daily_limit_today',
|
||||
'effective_limit_calculated_at',
|
||||
@@ -69,6 +70,7 @@ class Project extends Model
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'paused_at' => 'datetime',
|
||||
'daily_limit_target' => 'integer',
|
||||
'effective_daily_limit_today' => 'integer',
|
||||
'region_mask' => 'integer',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,10 +128,17 @@ final class LedgerService
|
||||
{
|
||||
if ($lead->supplier_project_id !== null) {
|
||||
$sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first();
|
||||
if ($sp !== null && in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
|
||||
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
|
||||
if ($supplier !== null) {
|
||||
return (int) $supplier->id;
|
||||
if ($sp !== null) {
|
||||
if (in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
|
||||
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
|
||||
if ($supplier !== null) {
|
||||
return (int) $supplier->id;
|
||||
}
|
||||
}
|
||||
if ($sp->platform === 'DIRECT') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,6 +150,12 @@ final class LedgerService
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
// Phase 3: project без B-префикса (и не пустой) → DIRECT.
|
||||
if ($project !== '') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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,64 @@ 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();
|
||||
// Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
|
||||
// match с Лидерра-проектами, потому что project_supplier_links для DIRECT-row'ов
|
||||
// не создаются (новые DIRECT supplier_projects создаются автоматически при
|
||||
// получении webhook'а без B-префикса; explicit psl-link для них не настраивается).
|
||||
if ($supplierProject->platform === 'DIRECT') {
|
||||
$directSql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE projects.signal_type = ?
|
||||
AND LOWER(projects.signal_identifier) = LOWER(?)
|
||||
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;
|
||||
$directRows = DB::connection('pgsql_supplier')->select(
|
||||
$directSql,
|
||||
[$supplierProject->signal_type, $supplierProject->unique_key, $todayBit]
|
||||
);
|
||||
|
||||
return $candidates->values();
|
||||
return Project::hydrate($directRows)->values();
|
||||
}
|
||||
|
||||
// Existing B1/B2/B3 path — explicit project_supplier_links pivot.
|
||||
$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;
|
||||
|
||||
$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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class ProjectService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationsLogger $ops = new OperationsLogger,
|
||||
private readonly SupplierSnapshotGuard $snapshotGuard = new SupplierSnapshotGuard,
|
||||
) {}
|
||||
|
||||
public function update(Project $project, array $data): Project
|
||||
@@ -30,6 +31,15 @@ class ProjectService
|
||||
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
|
||||
);
|
||||
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
|
||||
// Если меняем источник (signal_identifier / sms_senders / sms_keyword) — guard.
|
||||
$sourceFieldsTouched = array_key_exists('signal_identifier', $data)
|
||||
|| array_key_exists('sms_senders', $data)
|
||||
|| array_key_exists('sms_keyword', $data);
|
||||
if ($sourceFieldsTouched) {
|
||||
$this->snapshotGuard->assertCanMutateSource($project, 'change_source');
|
||||
}
|
||||
|
||||
if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'errors' => [
|
||||
@@ -149,6 +159,11 @@ class ProjectService
|
||||
|
||||
public function delete(Project $project): void
|
||||
{
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
|
||||
// Guard поставщикова слепка ПЕРЕД has-deals (приоритетней) — клиент должен
|
||||
// увидеть формулировку про «уже заказали лиды», а не «есть сделки».
|
||||
$this->snapshotGuard->assertCanMutateSource($project, 'delete');
|
||||
|
||||
$hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
|
||||
if ($hasDeals) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
@@ -261,7 +276,13 @@ class ProjectService
|
||||
private function bulkPauseResume($query, bool $isActive): array
|
||||
{
|
||||
$ids = (clone $query)->pluck('id')->all();
|
||||
$updated = $query->update(['is_active' => $isActive]);
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||||
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта. Mass-update НЕ
|
||||
// триггерит model events, поэтому пишем явно в одном UPDATE.
|
||||
$updated = $query->update([
|
||||
'is_active' => $isActive,
|
||||
'paused_at' => $isActive ? null : DB::raw('NOW()'),
|
||||
]);
|
||||
foreach ($ids as $id) {
|
||||
SyncSupplierProjectJob::dispatch((int) $id);
|
||||
}
|
||||
@@ -291,8 +312,15 @@ class ProjectService
|
||||
try {
|
||||
$this->delete($model);
|
||||
$deleted++;
|
||||
} catch (HttpResponseException) {
|
||||
$skipped[] = ['id' => $p->id, 'reason' => 'has_deals'];
|
||||
} catch (HttpResponseException $e) {
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 12).
|
||||
// Разделяем причину: guard поставщика (нужно подождать) vs has-deals.
|
||||
$body = json_decode((string) $e->getResponse()->getContent(), true);
|
||||
$message = (string) ($body['errors']['project'][0] ?? '');
|
||||
$reason = str_contains($message, 'Мы уже начали сбор лидов')
|
||||
? 'supplier_snapshot_locked'
|
||||
: 'has_deals';
|
||||
$skipped[] = ['id' => $p->id, 'reason' => $reason];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Project;
|
||||
|
||||
use App\Models\Project;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Защита проекта от удаления/смены источника, пока поставщик crm.bp-gr.ru
|
||||
* может прислать по нему лиды по уже сделанному слепку.
|
||||
*
|
||||
* Slepok-час поставщика: 21:00 МСК (поставщик в 21:00 формирует заказ на завтра).
|
||||
* Grace: до следующего 21:00 МСК после pause + 24h на доставку хвоста.
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
|
||||
*/
|
||||
class SupplierSnapshotGuard
|
||||
{
|
||||
/** Час МСК, в который поставщик заказывает лиды на следующий день. */
|
||||
public const SUPPLIER_ORDER_HOUR_MSK = 21;
|
||||
|
||||
/** Сколько часов после слепка летит хвост лидов (одни сутки). */
|
||||
public const TAIL_DELIVERY_HOURS = 24;
|
||||
|
||||
public function computeGraceUntil(CarbonInterface $pausedAt): CarbonImmutable
|
||||
{
|
||||
$pausedMsk = CarbonImmutable::instance($pausedAt)->setTimezone('Europe/Moscow');
|
||||
|
||||
$next21 = $pausedMsk->setTime(self::SUPPLIER_ORDER_HOUR_MSK, 0, 0);
|
||||
if ($pausedMsk->gte($next21)) {
|
||||
$next21 = $next21->addDay();
|
||||
}
|
||||
|
||||
return $next21->addHours(self::TAIL_DELIVERY_HOURS);
|
||||
}
|
||||
|
||||
public function isProtected(Project $project, ?CarbonImmutable $now = null): bool
|
||||
{
|
||||
$hasLinks = DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->exists();
|
||||
if (! $hasLinks) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($project->is_active) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($project->paused_at === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$graceUntil = $this->computeGraceUntil($project->paused_at);
|
||||
$effectiveNow = $now ?? CarbonImmutable::now('Europe/Moscow');
|
||||
|
||||
return $effectiveNow->lt($graceUntil);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param 'delete'|'change_source' $action
|
||||
*/
|
||||
public function assertCanMutateSource(Project $project, string $action): void
|
||||
{
|
||||
if (! $this->isProtected($project)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$verb = $action === 'delete' ? 'Удалить' : 'Изменить источник';
|
||||
$message = 'Мы уже начали сбор лидов по этому проекту на завтра. '
|
||||
.'Пока поставьте на паузу — мы увидим это сегодня в 18:00 и завтра '
|
||||
.'не будем запускать сбор лидов по этому проекту. '
|
||||
.$verb.' можно будет послезавтра.';
|
||||
|
||||
throw new HttpResponseException(response()->json([
|
||||
'errors' => ['project' => [$message]],
|
||||
], 422));
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use InvalidArgumentException;
|
||||
*/
|
||||
class SupplierProjectResolver
|
||||
{
|
||||
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3'];
|
||||
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3', 'DIRECT'];
|
||||
|
||||
private const ALLOWED_SIGNAL_TYPES = ['site', 'call', 'sms'];
|
||||
|
||||
|
||||
@@ -47,4 +47,18 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
return null; // default render for non-JSON
|
||||
});
|
||||
|
||||
// Supplier webhook always returns JSON, even when client omits Accept header.
|
||||
// Without this render, Laravel's default ValidationException handler returns
|
||||
// 302 redirect to /, which strips POST body — losing supplier leads.
|
||||
// Confirmed 2026-05-25: 76 of 234 webhook hits today got 302 instead of 422.
|
||||
$exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
|
||||
if ($request->is('api/webhook/supplier/*')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
return null; // default render for other routes
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -9,8 +9,11 @@ return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Guard: only run if webhook_log exists (should always exist, but be safe)
|
||||
if (! Schema::hasTable('webhook_log')) {
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
// Guard: only run if webhook_log exists (на проде после legacy-webhook-removal
|
||||
// таблицы нет — миграция становится no-op).
|
||||
if (! $conn->getSchemaBuilder()->hasTable('webhook_log')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -18,16 +21,18 @@ return new class extends Migration
|
||||
base_path('/../db/migrations/2026_05_22_002_webhook_log_supplier_columns.sql')
|
||||
);
|
||||
|
||||
DB::unprepared($sql);
|
||||
$conn->unprepared($sql);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('webhook_log')) {
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
if (! $conn->getSchemaBuilder()->hasTable('webhook_log')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
$conn->unprepared(<<<'SQL'
|
||||
ALTER TABLE webhook_log
|
||||
DROP COLUMN IF EXISTS source,
|
||||
DROP COLUMN IF EXISTS status,
|
||||
|
||||
@@ -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).
|
||||
}
|
||||
};
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* supplier_csv_reconcile_log + unparseable_count: количество CSV-строк
|
||||
* за окно reconcile, у которых поле «project» не парсится в платформу
|
||||
* (поставщик иногда кладёт телефон/URL в Name → extractPlatform = null,
|
||||
* строка скипается в csv_reconcile.unparseable_project_skipped).
|
||||
*
|
||||
* Раньше эти строки попадали в знаменатель drift_ratio и счётчик missing,
|
||||
* стабильно завышая drift до ~40-50% (false-positive drift_alert каждый
|
||||
* запуск). Теперь они учитываются отдельно и вычитаются из формулы.
|
||||
*
|
||||
* Используется в CsvReconcileJob + AdminSupplierIntegrationController.
|
||||
* Таблица SaaS-level (без RLS), пишет/читает crm_supplier_worker
|
||||
* (BYPASSRLS) — pgsql_supplier connection.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conn->unprepared(<<<'SQL'
|
||||
ALTER TABLE supplier_csv_reconcile_log
|
||||
ADD COLUMN IF NOT EXISTS unparseable_count INTEGER NOT NULL DEFAULT 0;
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$conn->unprepared(<<<'SQL'
|
||||
ALTER TABLE supplier_csv_reconcile_log
|
||||
DROP COLUMN IF EXISTS unparseable_count;
|
||||
SQL);
|
||||
}
|
||||
};
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 supplier webhook reliability — расширяет platform enum в
|
||||
* supplier_projects и project_supplier_links до (B1,B2,B3,DIRECT).
|
||||
*
|
||||
* DIRECT — это «прямая» платформа поставщика без B-префикса в имени
|
||||
* проекта (e.g. `client.carmoney.ru`, `cashmotor.ru`, числовые телефоны).
|
||||
* До Phase 3 такие webhook'и отвергались с 302-редиректом и терялись:
|
||||
* наблюдалось 67 потерь/день на проде 25.05.2026 для tenant client1.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*
|
||||
* NB: chk_supplier_projects_b1_not_for_sms (B1+SMS deny) НЕ трогаем —
|
||||
* DIRECT+SMS этим constraint'ом не блокируется (он специфичен для B1).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 1) Расширить platform-колонки до VARCHAR(8) (было VARCHAR(4): "DIRECT" не вмещается).
|
||||
// supplier_manual_sync_queue.platform уже VARCHAR(8) — пропускаем.
|
||||
DB::statement('ALTER TABLE supplier_projects ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
DB::statement('ALTER TABLE project_supplier_links ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
|
||||
// 2) Расширить CHECK constraints на enum значения.
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
|
||||
DB::statement('ALTER TABLE supplier_leads DROP CONSTRAINT chk_supplier_leads_platform');
|
||||
DB::statement("ALTER TABLE supplier_leads ADD CONSTRAINT chk_supplier_leads_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Перед откатом — убедиться что в БД нет rows с platform='DIRECT',
|
||||
// иначе constraint провалится при ADD. Это ответственность того, кто
|
||||
// запускает migrate:rollback. На prod — отдельный cleanup SQL до отката:
|
||||
// DELETE FROM project_supplier_links WHERE platform='DIRECT';
|
||||
// DELETE FROM supplier_projects WHERE platform='DIRECT';
|
||||
// DELETE FROM supplier_leads WHERE platform='DIRECT';
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
DB::statement('ALTER TABLE supplier_leads DROP CONSTRAINT chk_supplier_leads_platform');
|
||||
DB::statement("ALTER TABLE supplier_leads ADD CONSTRAINT chk_supplier_leads_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
// Сужение TYPE обратно к VARCHAR(4) — только если все значения помещаются (B1/B2/B3 = 2 символа).
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
DB::statement('ALTER TABLE project_supplier_links ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
DB::statement('ALTER TABLE supplier_projects ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 — DIRECT supplier row (used by LedgerService::resolveSupplierId
|
||||
* fallback for platform='DIRECT'). cost_rub matches B1 (same supplier,
|
||||
* different routing).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$b1 = DB::table('suppliers')->where('code', 'b1')->first();
|
||||
if ($b1 === null) {
|
||||
// Если B1 нет — significant prod drift, не должно произойти.
|
||||
// Создаём с дефолтным cost_rub=1.00 (как на prod 25.05.2026).
|
||||
$costRub = '1.00';
|
||||
} else {
|
||||
$costRub = (string) $b1->cost_rub;
|
||||
}
|
||||
|
||||
// Используем raw SQL чтобы корректно сериализовать PG-array для accepts_types.
|
||||
DB::insert(
|
||||
"INSERT INTO suppliers (code, name, accepts_types, cost_rub, channel, is_active, sort_order, created_at)
|
||||
VALUES (?, ?, ARRAY['websites','calls','sms'], ?, ?, true, 4, NOW())
|
||||
ON CONFLICT (code) DO NOTHING",
|
||||
[
|
||||
'direct',
|
||||
'DIRECT — Прямые проекты',
|
||||
$costRub,
|
||||
'sites', // принимает любые сигналы; channel='sites' допустим в suppliers_channel_check
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('suppliers')->where('code', 'direct')->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->timestampTz('paused_at')->nullable()->after('is_active');
|
||||
$table->index('paused_at', 'projects_paused_at_idx');
|
||||
});
|
||||
|
||||
// Backfill: для уже paused проектов используем updated_at как best-effort
|
||||
// (для долго-paused — grace давно истёк; для свежих — близко к реальной паузе).
|
||||
DB::statement(<<<'SQL'
|
||||
UPDATE projects
|
||||
SET paused_at = updated_at
|
||||
WHERE is_active = false
|
||||
AND paused_at IS NULL
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->dropIndex('projects_paused_at_idx');
|
||||
$table->dropColumn('paused_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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> {
|
||||
|
||||
@@ -103,7 +103,22 @@ async function confirmAndRun(action: 'pause' | 'resume' | 'delete') {
|
||||
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
|
||||
const result = await store.bulkUpdate(payload);
|
||||
if (result.skipped.length > 0) {
|
||||
skipToastText.value = `Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`;
|
||||
const supplierLocked = result.skipped.filter((s) => s.reason === 'supplier_snapshot_locked').length;
|
||||
const withDeals = result.skipped.filter((s) => s.reason === 'has_deals').length;
|
||||
const groups: string[] = [];
|
||||
if (supplierLocked > 0) {
|
||||
groups.push(
|
||||
`${supplierLocked} — мы уже начали сбор лидов на завтра (поставьте проект на паузу, удалить можно будет послезавтра)`,
|
||||
);
|
||||
}
|
||||
if (withDeals > 0) {
|
||||
groups.push(`${withDeals} — по проекту есть сделки`);
|
||||
}
|
||||
// Fallback на старый текст, если reason неизвестный (защита от регрессии при добавлении новых причин).
|
||||
if (groups.length === 0) {
|
||||
groups.push(`${result.skipped.length} (конфликт с уже доставленными лидами)`);
|
||||
}
|
||||
skipToastText.value = `Применено: ${result.updated}. Пропущено: ${groups.join('; ')}.`;
|
||||
skipToastOpen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,11 +65,20 @@ async function onPause(): Promise<void> {
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
const ok = window.confirm(
|
||||
'Удалить проект? Действие необратимо. Если по проекту есть сделки — удаление будет заблокировано.',
|
||||
'Удалить проект? Действие необратимо. Если по проекту есть сделки или поставщик уже заказал лиды — удаление будет заблокировано.',
|
||||
);
|
||||
if (!ok) return;
|
||||
await store.del(props.project.id);
|
||||
emit('close');
|
||||
Object.keys(errors).forEach((k) => delete errors[k]);
|
||||
try {
|
||||
await store.del(props.project.id);
|
||||
emit('close');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
|
||||
if (err.response?.status === 422 && err.response.data?.errors) {
|
||||
Object.assign(errors, err.response.data.errors);
|
||||
}
|
||||
// НЕ закрываем drawer — клиент видит ошибку и может поставить проект на паузу.
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
@@ -130,6 +139,11 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
</header>
|
||||
|
||||
<div class="pdd-body">
|
||||
<!-- Общая ошибка уровня проекта (например, supplier-snapshot guard или has-deals на delete). -->
|
||||
<div v-if="errors.project" class="pdd-error pdd-error-banner" data-testid="pdd-error-project">
|
||||
{{ errors.project[0] }}
|
||||
</div>
|
||||
|
||||
<label class="pdd-field">
|
||||
<span class="pdd-label">Название</span>
|
||||
<input v-model="form.name" data-testid="pdd-name" class="pdd-input" />
|
||||
@@ -206,6 +220,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">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user