Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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,16 @@
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-tool-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -86,6 +96,26 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-stop-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-prehook.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -176,6 +176,10 @@ class PartitionsDropExpired extends Command
|
||||
*/
|
||||
private function dropPartition(string $partitionName): void
|
||||
{
|
||||
DB::statement("DROP TABLE IF EXISTS {$partitionName}");
|
||||
// DROP требует владения родителем — крутится через pgsql_supplier
|
||||
// (crm_supplier_worker — член владельца crm_migrator). См.
|
||||
// MonthlyPartitionManager::DDL_CONNECTION.
|
||||
DB::connection(MonthlyPartitionManager::DDL_CONNECTION)
|
||||
->statement("DROP TABLE IF EXISTS {$partitionName}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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().
|
||||
*
|
||||
|
||||
@@ -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 если
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,394 +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
|
||||
{
|
||||
// failed_webhook_jobs is an RLS-protected table. On production crm_app_user
|
||||
// (non-BYPASSRLS) there is no app.current_tenant_id GUC in the failed()
|
||||
// callback context. Use pgsql_supplier (crm_supplier_worker, BYPASSRLS) —
|
||||
// same pattern as RouteSupplierLeadJob::failed().
|
||||
DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->insert([
|
||||
'tenant_id' => $this->tenantId,
|
||||
'webhook_log_id' => $this->webhookLogId,
|
||||
'raw_payload' => json_encode($this->data, JSON_UNESCAPED_UNICODE),
|
||||
'exception' => $e->getMessage(),
|
||||
'retry_count' => $this->tries,
|
||||
'failed_at' => now(),
|
||||
]);
|
||||
|
||||
Log::error('webhook.job_failed_permanently', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'vid' => $this->data['vid'] ?? null,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
// TODO(production): Sentry::captureException($e);
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
@@ -44,9 +43,7 @@ use Throwable;
|
||||
* 5. Для каждого Project — DB::transaction с SET LOCAL app.current_tenant_id:
|
||||
* - lockForUpdate Tenant.
|
||||
* - Создать Deal (source_crm_id=vid).
|
||||
* - DuplicateDetector::findMaster — если найден master !== deal, mark
|
||||
* duplicate_of_id (без charge/counter/notify, ActivityLog с duplicate_of).
|
||||
* - Иначе: LedgerService::chargeForDelivery(tenant, deal, lead) — dual-balance
|
||||
* - LedgerService::chargeForDelivery(tenant, deal, lead) — dual-balance
|
||||
* списание (prepaid balance_leads-- ИЛИ rub balance_rub-=tier_price), INSERT
|
||||
* lead_charges + balance_transactions + supplier_lead_costs внутри той же
|
||||
* транзакции. На InsufficientBalanceException — Log::warning + rethrow
|
||||
@@ -86,7 +83,6 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
public function handle(
|
||||
LeadRouter $router,
|
||||
SupplierProjectResolver $resolver,
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
LeadDistributor $distributor,
|
||||
@@ -135,7 +131,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
$failures = [];
|
||||
foreach ($selected as $project) {
|
||||
try {
|
||||
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)) {
|
||||
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $subjectCode)) {
|
||||
$createdCount++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
@@ -205,19 +201,18 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
|
||||
/**
|
||||
* Создаёт deal-копию в одной транзакции для конкретного Project.
|
||||
* Возвращает true — если копия не дубль (баланс списан, счётчики выросли).
|
||||
* false — если копия помечена дублем (без списания).
|
||||
* Возвращает true — если deal создан и баланс списан, счётчики выросли.
|
||||
* false — если лимит исчерпан под блокировкой (deal не создаётся).
|
||||
*/
|
||||
private function createDealCopyForProject(
|
||||
SupplierLead $lead,
|
||||
Project $project,
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
?int $subjectCode,
|
||||
): bool {
|
||||
try {
|
||||
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode): bool {
|
||||
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $subjectCode): bool {
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
|
||||
|
||||
/** @var Tenant $tenant */
|
||||
@@ -250,6 +245,22 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
}
|
||||
$project = $lockedProject;
|
||||
|
||||
// Spec B: per-(supplier_lead, tenant) lock — одна поставка одному клиенту = один раз.
|
||||
// insertOrIgnore вернёт 0, если строка уже существует (повтор/гонка/CSV-recovery).
|
||||
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
if ($locked === 0) {
|
||||
Log::info('supplier_lead.delivery_already_locked', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = $lead->raw_payload ?? [];
|
||||
$receivedAt = isset($payload['time'])
|
||||
? Carbon::createFromTimestamp((int) $payload['time'])
|
||||
@@ -271,39 +282,10 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'subject_code' => $subjectCode,
|
||||
]);
|
||||
|
||||
$master = $duplicateDetector->findMaster(
|
||||
tenantId: (int) $tenant->id,
|
||||
phone: (string) $lead->phone,
|
||||
now: $receivedAt,
|
||||
);
|
||||
|
||||
// Только что созданный $deal сам попадает в выборку DuplicateDetector
|
||||
// (он уже в БД к моменту lookup'а), поэтому master может ===$deal.
|
||||
// Дубль — только если master найден И это НЕ сам deal.
|
||||
if ($master !== null && $master->id !== $deal->id) {
|
||||
$deal->update(['duplicate_of_id' => $master->id]);
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'deal_id' => $deal->id,
|
||||
'event' => ActivityLog::EVENT_DEAL_CREATED,
|
||||
'context' => [
|
||||
'source' => 'supplier_webhook',
|
||||
'duplicate_of' => $master->id,
|
||||
'supplier_lead_id' => $lead->id,
|
||||
],
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
app(PdAuditLogger::class)->record(
|
||||
action: 'created', subjectType: 'lead', subjectId: $deal->id,
|
||||
purpose: 'lead_create_supplier', tenantId: (int) $deal->tenant_id,
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
DB::table('supplier_lead_deliveries')
|
||||
->where('supplier_lead_id', $lead->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->update(['deal_id' => $deal->id]);
|
||||
|
||||
// Task 6: $ledger->chargeForDelivery бросит InsufficientBalanceException —
|
||||
// транзакция откатится, и outer catch ниже отловит для auto-pause flow.
|
||||
@@ -330,8 +312,8 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
actorTenantUserId: null, actorAdminUserId: null, ip: null,
|
||||
);
|
||||
|
||||
// ProcessWebhookJob-pattern: setRelation чтобы NotificationService
|
||||
// мог подтянуть deal->project без N+1 lookup'а под RLS.
|
||||
// setRelation чтобы NotificationService мог подтянуть
|
||||
// deal->project без N+1 lookup'а под RLS.
|
||||
$deal->setRelation('project', $project);
|
||||
$notifier->notifyNewLead($tenant, $deal);
|
||||
|
||||
|
||||
@@ -126,11 +126,15 @@ 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) {
|
||||
// Поставщик иногда кладёт в `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'],
|
||||
]);
|
||||
@@ -161,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 = [
|
||||
@@ -169,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,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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` заполняются админом).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Deal;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Антифрод-дедуп лидов по `(tenant_id, phone)` в окне 24 ч (Биз-19, §10.8.1).
|
||||
*
|
||||
* Цель: в pay-per-lead-сегменте поставщик может прислать одно физлицо дважды
|
||||
* (двойной submit формы / повторный звонок) — без защиты клиент платит за оба.
|
||||
*
|
||||
* Стратегия: ищем master-сделку (запись без `duplicate_of_id`) с тем же
|
||||
* `(tenant_id, phone)` и `received_at >= NOW() - INTERVAL '24 hours'`.
|
||||
* Если найдена — новая сделка получает `duplicate_of_id = master.id` и
|
||||
* НЕ списывает с баланса.
|
||||
*
|
||||
* Окно фиксированное 24 ч (не настраивается на MVP) — компромисс между
|
||||
* антифродом и легитимными повторными интересами.
|
||||
*
|
||||
* Цепочки не строятся: дубль ссылается ТОЛЬКО на master (запись без
|
||||
* `duplicate_of_id`), не на другой дубль. Если master найден среди дублей —
|
||||
* берётся его собственный `duplicate_of_id` (root master).
|
||||
*
|
||||
* Performance: существующий индекс `(tenant_id, phone)` достаточен, см. §10.8.1.
|
||||
*/
|
||||
class DuplicateDetector
|
||||
{
|
||||
public const WINDOW_HOURS = 24;
|
||||
|
||||
/**
|
||||
* Поиск master-сделки для (tenantId, phone) в окне 24 ч.
|
||||
*
|
||||
* Возвращает Deal-объект master'а либо null если master не найден.
|
||||
* Текущий момент `now` параметризуется для тестируемости — в production
|
||||
* по умолчанию `Carbon::now()`.
|
||||
*/
|
||||
public function findMaster(int $tenantId, string $phone, ?Carbon $now = null): ?Deal
|
||||
{
|
||||
$now ??= Carbon::now();
|
||||
$windowStart = $now->copy()->subHours(self::WINDOW_HOURS);
|
||||
|
||||
return Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('phone', $phone)
|
||||
->where('received_at', '>=', $windowStart)
|
||||
->whereNull('duplicate_of_id')
|
||||
->orderBy('received_at')
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ final class CsvLeadsParser
|
||||
return null;
|
||||
}
|
||||
|
||||
// Префикс B[123]_ из названия проекта срезается (паритет с ProcessWebhookJob).
|
||||
// Префикс B[123]_ из названия проекта срезается (паритет с RouteSupplierLeadJob парсером).
|
||||
$projectName = (string) preg_replace('/^B[123]_/', '', trim($project));
|
||||
if ($projectName === '') {
|
||||
$errors[] = ['line' => $dataLine, 'message' => 'Пустое название проекта'];
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
|
||||
@@ -28,6 +29,17 @@ use Illuminate\Support\Collection;
|
||||
class LeadRouter
|
||||
{
|
||||
/**
|
||||
* Возвращает ONE project per tenant_id — тот, у которого наибольший остаток
|
||||
* дневного лимита (DISTINCT ON (tenant_id) с ORDER BY remaining DESC, created_at, id).
|
||||
*
|
||||
* Семантика (Spec B Task 3): один лид продаётся не более чем 3 РАЗЛИЧНЫМ тенантам
|
||||
* (клиентам), каждый тенант получает ровно ОДИН проект — с наибольшим остатком.
|
||||
* LeadDistributor::selectRecipients (CAP=3) теперь ограничивает число тенантов,
|
||||
* а не число проектов, потому что входные данные уже one-per-tenant.
|
||||
*
|
||||
* Запрос через pgsql_supplier (BYPASSRLS crm_supplier_worker) — tenant ещё не
|
||||
* определён, SELECT видит проекты всех tenant'ов.
|
||||
*
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
|
||||
@@ -35,30 +47,31 @@ class LeadRouter
|
||||
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
/** @var Collection<int, Project> $candidates */
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->whereExists(function ($q) use ($supplierProject): void {
|
||||
$q->selectRaw('1')
|
||||
->from('project_supplier_links')
|
||||
->whereColumn('project_supplier_links.project_id', 'projects.id')
|
||||
->where('project_supplier_links.supplier_project_id', $supplierProject->id);
|
||||
})
|
||||
->where('is_active', true)
|
||||
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
|
||||
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
|
||||
->whereExists(function ($q): void {
|
||||
$q->selectRaw('1')
|
||||
->from('tenants')
|
||||
->whereColumn('tenants.id', 'projects.tenant_id')
|
||||
->where(function ($qq): void {
|
||||
$qq->where('tenants.balance_leads', '>', 0)
|
||||
->orWhere('tenants.balance_rub', '>', 0);
|
||||
});
|
||||
})
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM project_supplier_links psl
|
||||
WHERE psl.project_id = projects.id
|
||||
AND psl.supplier_project_id = ?
|
||||
)
|
||||
AND projects.is_active = true
|
||||
AND (projects.delivery_days_mask & ?) <> 0
|
||||
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = projects.tenant_id
|
||||
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
|
||||
)
|
||||
ORDER BY
|
||||
projects.tenant_id,
|
||||
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
|
||||
return $candidates->values();
|
||||
$rows = DB::connection('pgsql_supplier')->select($sql, [$supplierProject->id, $todayBit]);
|
||||
|
||||
return Project::hydrate($rows)->values();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,21 @@ use InvalidArgumentException;
|
||||
*/
|
||||
class MonthlyPartitionManager
|
||||
{
|
||||
/**
|
||||
* 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';
|
||||
|
||||
/**
|
||||
* Таблицы, партиционированные помесячно.
|
||||
* Ключ → имя таблицы, значение → колонка-ключ партиционирования.
|
||||
@@ -38,7 +53,7 @@ class MonthlyPartitionManager
|
||||
'auth_log' => 'created_at',
|
||||
'activity_log' => 'created_at',
|
||||
'tenant_operations_log' => 'created_at',
|
||||
'webhook_log' => 'received_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',
|
||||
@@ -90,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,
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -18,7 +18,7 @@ use InvalidArgumentException;
|
||||
* 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: raw_payload (JSONB)
|
||||
* (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
|
||||
*/
|
||||
class PdErasureService
|
||||
{
|
||||
@@ -32,7 +32,7 @@ class PdErasureService
|
||||
* @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, webhook_log: int}
|
||||
* @return array{users: int, leads: int, deals: int}
|
||||
*
|
||||
* @throws InvalidArgumentException если оба email и phone null
|
||||
*/
|
||||
@@ -47,7 +47,7 @@ class PdErasureService
|
||||
throw new InvalidArgumentException('Необходимо указать email или телефон субъекта.');
|
||||
}
|
||||
|
||||
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0, 'webhook_log' => 0];
|
||||
$counts = ['users' => 0, 'leads' => 0, 'deals' => 0];
|
||||
|
||||
DB::connection(self::DB)->transaction(function () use (
|
||||
$email, $phone, $tenantId, $actorAdminId, $requestId, &$counts
|
||||
@@ -176,50 +176,12 @@ class PdErasureService
|
||||
$counts['deals'] = $deals->count();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 4. webhook_log (raw_payload JSONB text-search)
|
||||
// ------------------------------------------------------------------
|
||||
$wlQuery = DB::connection(self::DB)->table('webhook_log');
|
||||
$conditions = [];
|
||||
$bindings = [];
|
||||
if ($email !== null) {
|
||||
$conditions[] = 'raw_payload::text LIKE ?';
|
||||
$bindings[] = '%'.$email.'%';
|
||||
}
|
||||
if ($phone !== null) {
|
||||
$conditions[] = 'raw_payload::text LIKE ?';
|
||||
$bindings[] = '%'.$phone.'%';
|
||||
}
|
||||
|
||||
if (! empty($conditions)) {
|
||||
$wlQuery->whereRaw('('.implode(' OR ', $conditions).')', $bindings);
|
||||
}
|
||||
|
||||
if ($tenantId !== null) {
|
||||
$wlQuery->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
// Batched update: обрабатываем по 500 строк
|
||||
$wlCount = 0;
|
||||
$wlQuery->select('id')->orderBy('id')->chunk(500, function ($rows) use (&$wlCount): void {
|
||||
$ids = $rows->pluck('id')->all();
|
||||
DB::connection(self::DB)->table('webhook_log')
|
||||
->whereIn('id', $ids)
|
||||
->update([
|
||||
'raw_payload' => DB::connection(self::DB)->raw(
|
||||
"JSONB_BUILD_OBJECT('erased', TRUE, 'erased_at', NOW()::TEXT)"
|
||||
),
|
||||
]);
|
||||
$wlCount += count($ids);
|
||||
});
|
||||
|
||||
$counts['webhook_log'] = $wlCount;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 5. Обновить pd_subject_requests если requestId передан
|
||||
// 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']}, webhook_log={$counts['webhook_log']}";
|
||||
."deals={$counts['deals']}";
|
||||
|
||||
DB::connection(self::DB)->table('pd_subject_requests')
|
||||
->where('id', $requestId)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
};
|
||||
+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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { AdminTenantDetail } from '../../composables/mockTenantDetail';
|
||||
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
|
||||
import TenantDetailHeader from '../../components/admin/tenant-detail/TenantDetailHeader.vue';
|
||||
import TenantDetailTabs from '../../components/admin/tenant-detail/TenantDetailTabs.vue';
|
||||
import TenantBalanceDialog from '../../components/admin/TenantBalanceDialog.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -64,6 +65,7 @@ watch(code, () => {
|
||||
|
||||
const ADMIN_USER_ID = 1;
|
||||
const impersonationOpen = ref(false);
|
||||
const balanceDialogOpen = ref(false);
|
||||
|
||||
const activeTab = ref<'finance' | 'users' | 'projects' | 'activity'>('finance');
|
||||
|
||||
@@ -71,14 +73,30 @@ function goBack() {
|
||||
router.push({ name: 'admin-tenants' });
|
||||
}
|
||||
|
||||
defineExpose({ tenant, activeTab, impersonationOpen, loadTenant });
|
||||
async function onBalanceSaved(): Promise<void> {
|
||||
await loadTenant();
|
||||
}
|
||||
|
||||
defineExpose({ tenant, activeTab, impersonationOpen, balanceDialogOpen, loadTenant });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container v-if="tenant" fluid class="tenant-detail pa-6">
|
||||
<TenantDetailHeader :tenant="tenant" @back="goBack" @impersonate="impersonationOpen = true" />
|
||||
<TenantDetailHeader
|
||||
:tenant="tenant"
|
||||
@back="goBack"
|
||||
@impersonate="impersonationOpen = true"
|
||||
@edit-balance="balanceDialogOpen = true"
|
||||
/>
|
||||
<TenantDetailTabs :tenant="tenant" :active-tab="activeTab" @update:active-tab="activeTab = $event" />
|
||||
<ImpersonationDialog v-model="impersonationOpen" :tenant="tenant" :requested-by="ADMIN_USER_ID" />
|
||||
<TenantBalanceDialog
|
||||
v-model="balanceDialogOpen"
|
||||
:tenant-id="tenant.id"
|
||||
:tenant-name="tenant.name"
|
||||
:current-balance-rub="tenant.balanceRub"
|
||||
@saved="onBalanceSaved"
|
||||
/>
|
||||
</v-container>
|
||||
|
||||
<v-container v-else-if="loading" fluid class="pa-6" data-testid="tenant-loading">
|
||||
|
||||
@@ -23,6 +23,7 @@ import * as adminApi from '../../api/admin';
|
||||
// `findComponent({ name: 'ImpersonationDialog' })` + `stubs`, defineAsyncComponent
|
||||
// ломает identity wrapper'а в test-utils.
|
||||
import ImpersonationDialog from '../../components/admin/ImpersonationDialog.vue';
|
||||
import TenantBalanceDialog from '../../components/admin/TenantBalanceDialog.vue';
|
||||
import TenantsStatsHeader from '../../components/admin/tenants/TenantsStatsHeader.vue';
|
||||
import TenantsFilters from '../../components/admin/tenants/TenantsFilters.vue';
|
||||
import TenantsTable from '../../components/admin/tenants/TenantsTable.vue';
|
||||
@@ -66,6 +67,9 @@ const filterTariffs = ref<string[]>([]);
|
||||
const impersonationOpen = ref(false);
|
||||
const impersonationTenant = ref<AdminTenant | null>(null);
|
||||
|
||||
const balanceDialogOpen = ref(false);
|
||||
const balanceTarget = ref<AdminTenant | null>(null);
|
||||
|
||||
const availableTariffs = computed(() => Array.from(new Set(tenantsState.map((t) => t.tariff))).sort());
|
||||
|
||||
function clearFilters() {
|
||||
@@ -80,12 +84,23 @@ function openImpersonation(tenant: AdminTenant) {
|
||||
impersonationOpen.value = true;
|
||||
}
|
||||
|
||||
function openBalanceDialog(tenant: AdminTenant) {
|
||||
balanceTarget.value = tenant;
|
||||
balanceDialogOpen.value = true;
|
||||
}
|
||||
|
||||
async function onBalanceSaved(): Promise<void> {
|
||||
await loadTenants();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
filterStatuses,
|
||||
filterTariffs,
|
||||
clearFilters,
|
||||
impersonationOpen,
|
||||
impersonationTenant,
|
||||
balanceDialogOpen,
|
||||
balanceTarget,
|
||||
tenantsState,
|
||||
stats,
|
||||
loading,
|
||||
@@ -137,9 +152,23 @@ const filteredTenants = computed<AdminTenant[]>(() => {
|
||||
@clear="clearFilters"
|
||||
/>
|
||||
|
||||
<TenantsTable :tenants="filteredTenants" @row-click="openTenantDetail" @impersonate="openImpersonation" />
|
||||
<TenantsTable
|
||||
:tenants="filteredTenants"
|
||||
@row-click="openTenantDetail"
|
||||
@impersonate="openImpersonation"
|
||||
@edit-balance="openBalanceDialog"
|
||||
/>
|
||||
|
||||
<ImpersonationDialog v-model="impersonationOpen" :tenant="impersonationTenant" :requested-by="ADMIN_USER_ID" />
|
||||
|
||||
<TenantBalanceDialog
|
||||
v-if="balanceTarget"
|
||||
v-model="balanceDialogOpen"
|
||||
:tenant-id="balanceTarget.id"
|
||||
:tenant-name="balanceTarget.name"
|
||||
:current-balance-rub="balanceTarget.balanceRub"
|
||||
@saved="onBalanceSaved"
|
||||
/>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Низкий баланс</title>
|
||||
</head>
|
||||
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
|
||||
<h1 style="color: #0F6E56; font-size: 20px;">Лидерра. Низкий баланс</h1>
|
||||
|
||||
<p>Здравствуйте, {{ $recipient->first_name ?? $recipient->email }}.</p>
|
||||
|
||||
<p>На балансе аккаунта <strong>{{ $tenant->organization_name ?? $tenant->subdomain }}</strong>
|
||||
осталось <strong>{{ $tenant->balance_leads }} лидов</strong>
|
||||
(порог уведомления — {{ $thresholdLeads }} лидов).</p>
|
||||
|
||||
<p style="background: #FEF3F2; padding: 12px; border-left: 3px solid #B94837;">
|
||||
Если баланс закончится — все входящие лиды будут отклоняться.
|
||||
</p>
|
||||
|
||||
<p style="margin-top: 24px;">Откройте Биллинг в CRM, чтобы пополнить баланс.</p>
|
||||
|
||||
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
|
||||
Это автоматическое уведомление о событии «Низкий баланс». Чтобы изменить настройки уведомлений — перейдите в Настройки → Уведомления.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,27 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Баланс закончился</title>
|
||||
</head>
|
||||
<body style="font-family: Inter, -apple-system, sans-serif; max-width: 600px; margin: 0 auto; padding: 24px; color: #081319;">
|
||||
<h1 style="color: #B94837; font-size: 20px;">Лидерра. Баланс закончился</h1>
|
||||
|
||||
<p>Здравствуйте, {{ $recipient->first_name ?? $recipient->email }}.</p>
|
||||
|
||||
<p>Баланс аккаунта <strong>{{ $tenant->organization_name ?? $tenant->subdomain }}</strong>
|
||||
обнулился — <strong>входящие лиды отклоняются</strong>.</p>
|
||||
|
||||
<p style="background: #FEF3F2; padding: 12px; border-left: 3px solid #B94837;">
|
||||
<strong>Что делать:</strong> пополните баланс в разделе Биллинг.
|
||||
После пополнения новые лиды снова начнут приниматься.
|
||||
</p>
|
||||
|
||||
<p>Уже отклонённые лиды не возвращаются — посмотрите их в журнале
|
||||
«Отклонённые лиды» в разделе Отчёты.</p>
|
||||
|
||||
<p style="color: #66635C; font-size: 12px; margin-top: 32px;">
|
||||
Это автоматическое уведомление о событии «Нулевой баланс». Чтобы изменить настройки уведомлений — перейдите в Настройки → Уведомления.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
+2
-6
@@ -98,6 +98,8 @@ Route::middleware('saas-admin')->group(function () {
|
||||
Route::get('/api/admin/tenants', 'App\Http\Controllers\Api\AdminTenantsController@index');
|
||||
Route::get('/api/admin/tenants/{subdomain}', 'App\Http\Controllers\Api\AdminTenantsController@show')
|
||||
->where('subdomain', '[a-z0-9_-]+');
|
||||
Route::patch('/api/admin/tenants/{id}/balance', 'App\Http\Controllers\Api\AdminTenantsController@updateBalance')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// SaaS-admin → Биллинг: aggregates пополнений/списаний за текущий месяц.
|
||||
Route::get('/api/admin/billing', 'App\Http\Controllers\Api\AdminBillingController@index');
|
||||
@@ -268,12 +270,6 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/projects')->group(fu
|
||||
Route::patch('/{id}/toggle-active', 'App\Http\Controllers\Api\ProjectController@toggleActive')->name('projects.toggle')->where('id', '[0-9]+');
|
||||
});
|
||||
|
||||
// Receive endpoint для входящих webhook'ов (narrative §5.5).
|
||||
// Auth — по `tenants.webhook_token` в URL (без middleware, проверка внутри controller).
|
||||
// На prod: + HMAC-валидация X-Webhook-Signature + per-token rate-limit.
|
||||
Route::post('/api/webhook/{token}', 'App\Http\Controllers\Api\WebhookReceiveController@receive')
|
||||
->where('token', '[A-Za-z0-9\-_]+');
|
||||
|
||||
// Supplier-integration webhook (Plan 2/5, spec §5.1).
|
||||
// Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru.
|
||||
// Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist
|
||||
|
||||
@@ -77,7 +77,6 @@ foreach ($accounts as $a) {
|
||||
[
|
||||
'organization_name' => $a['org_name'],
|
||||
'contact_email' => $user->email,
|
||||
'webhook_token' => Str::random(64),
|
||||
'timezone' => 'Europe/Moscow',
|
||||
'locale' => 'ru',
|
||||
'is_trial' => true,
|
||||
|
||||
@@ -164,7 +164,6 @@ it('executeErasure anonymises user email first_name phone and writes pd_processi
|
||||
'organization_name' => 'PD User Test',
|
||||
'contact_email' => 'pd-u@test.local',
|
||||
'status' => 'active',
|
||||
'webhook_token' => bin2hex(random_bytes(16)),
|
||||
'balance_rub' => '0.00',
|
||||
'balance_leads' => 0,
|
||||
'is_trial' => false,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function makeBalanceTenant(string $balanceRub): Tenant
|
||||
{
|
||||
return Tenant::factory()->create(['balance_rub' => $balanceRub]);
|
||||
}
|
||||
|
||||
it('sets exact balance and records signed manual_adjustment delta', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '2500.00',
|
||||
'reason' => 'Коррекция тестового баланса',
|
||||
]);
|
||||
|
||||
$resp->assertOk()
|
||||
->assertJsonPath('balance_rub', '2500.00')
|
||||
->assertJsonPath('delta', '1500.00');
|
||||
|
||||
$tenant->refresh();
|
||||
expect((string) $tenant->balance_rub)->toBe('2500.00');
|
||||
|
||||
$tx = BalanceTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
|
||||
->latest('id')->first();
|
||||
expect($tx)->not->toBeNull();
|
||||
expect((string) $tx->amount_rub)->toBe('1500.00');
|
||||
expect((string) $tx->balance_rub_after)->toBe('2500.00');
|
||||
expect($tx->amount_leads)->toBeNull();
|
||||
expect($tx->description)->toBe('Коррекция тестового баланса');
|
||||
});
|
||||
|
||||
it('records negative delta when lowering balance', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '300.00',
|
||||
]);
|
||||
|
||||
$resp->assertOk()->assertJsonPath('delta', '-700.00');
|
||||
|
||||
$tx = BalanceTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
|
||||
->latest('id')->first();
|
||||
expect((string) $tx->amount_rub)->toBe('-700.00');
|
||||
expect($tx->description)->toBe('Ручная корректировка баланса (админ)');
|
||||
});
|
||||
|
||||
it('accepts negative target balance (debt correction)', function () {
|
||||
$tenant = makeBalanceTenant('0.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '-500.00',
|
||||
])->assertOk()->assertJsonPath('balance_rub', '-500.00');
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('-500.00');
|
||||
});
|
||||
|
||||
it('rejects no-op (target equals current) with 422', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '1000.00',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('rejects malformed balance_rub with 422', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '10.123',
|
||||
])->assertStatus(422);
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => 'abc',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('returns 404 for missing or soft-deleted tenant', function () {
|
||||
$this->patchJson('/api/admin/tenants/99999999/balance', [
|
||||
'balance_rub' => '100.00',
|
||||
])->assertStatus(404);
|
||||
|
||||
$tenant = makeBalanceTenant('100.00');
|
||||
$tenant->delete();
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '200.00',
|
||||
])->assertStatus(404);
|
||||
});
|
||||
@@ -14,7 +14,6 @@ function makeBillingTenant(array $overrides = []): int
|
||||
'subdomain' => 'bt-'.bin2hex(random_bytes(4)),
|
||||
'organization_name' => 'Billing Test Co',
|
||||
'contact_email' => 'bt-'.bin2hex(random_bytes(3)).'@test.local',
|
||||
'webhook_token' => bin2hex(random_bytes(16)),
|
||||
'status' => 'active',
|
||||
'balance_rub' => '5000.00',
|
||||
'is_trial' => false,
|
||||
|
||||
@@ -64,7 +64,6 @@ test('GET /api/admin/incidents/{id} разрешает имена affected_tenan
|
||||
'subdomain' => 'inc-'.bin2hex(random_bytes(4)),
|
||||
'organization_name' => 'Affected Org',
|
||||
'contact_email' => 'a@test.local',
|
||||
'webhook_token' => bin2hex(random_bytes(16)),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$id = makeShowIncident($this->adminId, ['affected_tenant_ids' => '{'.$tenantId.'}']);
|
||||
|
||||
@@ -6,8 +6,11 @@ use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
// ensureMonth/dropPartition теперь идут через pgsql_supplier — нужен shared PDO,
|
||||
// иначе CREATE/DROP уйдут мимо test-транзакции (см. MonthlyPartitionManager::DDL_CONNECTION).
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Guard: check whether auth_log is partitioned. Tests in this file require
|
||||
|
||||
@@ -69,7 +69,6 @@ function ensureTenant(int $seed): int
|
||||
'organization_name' => "Test Chain {$seed}",
|
||||
'subdomain' => "test-chain-{$seed}",
|
||||
'contact_email' => "chain{$seed}@example.com",
|
||||
'webhook_token' => bin2hex(random_bytes(16))."-seed{$seed}",
|
||||
'status' => 'active',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
SystemSetting::query()->where('key', 'supplier_webhook_secret')->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()->where('key', 'supplier_ip_allowlist')->update(['value' => '[]']);
|
||||
// Clear rate limiter between tests
|
||||
RateLimiter::clear('supplier-webhook:127.0.0.1');
|
||||
});
|
||||
|
||||
it('logs status=received when lead is accepted (202)', function () {
|
||||
Bus::fake();
|
||||
|
||||
$this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 900001,
|
||||
'project' => 'B1_log-test.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])->assertStatus(202);
|
||||
|
||||
$log = DB::table('webhook_log')
|
||||
->where('status', 'received')
|
||||
->where('source', 'supplier')
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe('received');
|
||||
expect($log->source)->toBe('supplier');
|
||||
expect($log->tenant_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('logs status=rejected_secret when secret is wrong (404)', function () {
|
||||
$this->postJson('/api/webhook/supplier/wrong-secret-here', [
|
||||
'vid' => 900002,
|
||||
'project' => 'B1_log-test.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])->assertStatus(404);
|
||||
|
||||
$log = DB::table('webhook_log')
|
||||
->where('status', 'rejected_secret')
|
||||
->where('source', 'supplier')
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe('rejected_secret');
|
||||
expect($log->tenant_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('logs status=rejected_ip when IP is not in allowlist (404)', function () {
|
||||
SystemSetting::query()->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '["1.2.3.4"]']);
|
||||
|
||||
$this->withServerVariables(['REMOTE_ADDR' => '5.6.7.8'])
|
||||
->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 900003,
|
||||
'project' => 'B1_log-test.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])->assertStatus(404);
|
||||
|
||||
$log = DB::table('webhook_log')
|
||||
->where('status', 'rejected_ip')
|
||||
->where('source', 'supplier')
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe('rejected_ip');
|
||||
expect($log->tenant_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('logs status=rate_limited when per-IP rate limit exceeded (429)', function () {
|
||||
Bus::fake();
|
||||
// Saturate the rate limiter
|
||||
$key = 'supplier-webhook:127.0.0.1';
|
||||
$limit = 600; // RATE_LIMIT_PER_MINUTE constant
|
||||
for ($i = 0; $i < $limit; $i++) {
|
||||
RateLimiter::hit($key, 60);
|
||||
}
|
||||
|
||||
$this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 900004,
|
||||
'project' => 'B1_log-test.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])->assertStatus(429);
|
||||
|
||||
$log = DB::table('webhook_log')
|
||||
->where('status', 'rate_limited')
|
||||
->where('source', 'supplier')
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->status)->toBe('rate_limited');
|
||||
expect($log->tenant_id)->toBeNull();
|
||||
});
|
||||
@@ -12,8 +12,12 @@ use App\Services\Import\CsvLeadsParser;
|
||||
use App\Services\Import\HistoricalImportService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
// HistoricalImportService::importBatch вызывает MonthlyPartitionManager::ensureRange,
|
||||
// которая делает CREATE через pgsql_supplier — нужен shared PDO, иначе DDL уйдёт
|
||||
// мимо test-транзакции.
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->tenant = Tenant::factory()->create(['balance_leads' => 5]);
|
||||
|
||||
@@ -14,8 +14,11 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
// ImportLeadsJob запускает HistoricalImportService → MonthlyPartitionManager →
|
||||
// CREATE через pgsql_supplier. Нужен shared PDO.
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->tenant = Tenant::factory()->create();
|
||||
|
||||
@@ -6,8 +6,11 @@ use App\Services\MonthlyPartitionManager;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
// ensureMonth теперь делает CREATE через pgsql_supplier (см. MonthlyPartitionManager::DDL_CONNECTION).
|
||||
// Без SharesSupplierPdo DDL уйдёт мимо test-транзакции и партиции протечь в test DB.
|
||||
|
||||
function partitionExists(string $name): bool
|
||||
{
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
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;
|
||||
@@ -38,7 +37,6 @@ function runRouteJob(int $supplierLeadId): void
|
||||
(new RouteSupplierLeadJob($supplierLeadId))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(DuplicateDetector::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
@@ -56,11 +54,13 @@ it('is terminal (does not throw / re-queue) when the supplier lead does not exis
|
||||
$missingId = 999999;
|
||||
expect(SupplierLead::find($missingId))->toBeNull();
|
||||
|
||||
$countBefore = DB::table('deals')->count();
|
||||
|
||||
// Не должно бросать исключение (иначе сработает failed() -> retry-цикл).
|
||||
runRouteJob($missingId);
|
||||
|
||||
// Никаких побочных эффектов.
|
||||
expect(Deal::count())->toBe(0);
|
||||
// Никаких побочных эффектов — количество сделок не изменилось.
|
||||
expect(DB::table('deals')->count())->toBe($countBefore);
|
||||
});
|
||||
|
||||
it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void {
|
||||
@@ -73,7 +73,7 @@ it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', funct
|
||||
$tenants = collect();
|
||||
$projects = collect();
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$t = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$tenants->push($t);
|
||||
$projects->push(Project::factory()->create([
|
||||
'tenant_id' => $t->id,
|
||||
@@ -125,13 +125,13 @@ it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', funct
|
||||
}
|
||||
});
|
||||
|
||||
it('decrements balance_leads for each tenant by 1', function (): void {
|
||||
it('charges balance_rub for tenant after routing', function (): void {
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'test.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
@@ -152,55 +152,7 @@ it('decrements balance_leads for each tenant by 1', function (): void {
|
||||
|
||||
runRouteJob($lead->id);
|
||||
|
||||
expect($tenant->fresh()->balance_leads)->toBe(99);
|
||||
});
|
||||
|
||||
it('marks duplicate via DuplicateDetector — no charge, no counter increment', function (): void {
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'test.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'test.ru',
|
||||
'is_active' => true,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$master = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'source_crm_id' => 999,
|
||||
'project_id' => $project->id,
|
||||
'phone' => '79991234567',
|
||||
'phones' => ['79991234567'],
|
||||
'status' => 'new',
|
||||
'received_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
$vid = 1000;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
|
||||
runRouteJob($lead->id);
|
||||
|
||||
expect($tenant->fresh()->balance_leads)->toBe(100);
|
||||
expect($project->fresh()->delivered_today)->toBe(0);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$duplicate = Deal::where('source_crm_id', $vid)->first();
|
||||
expect($duplicate)->not->toBeNull();
|
||||
expect($duplicate->duplicate_of_id)->toBe($master->id);
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
|
||||
});
|
||||
|
||||
it('throws DomainException when payload encodes B1+SMS combo', function (): void {
|
||||
@@ -234,7 +186,7 @@ it('handles orphan supplier_project (no matching liderra-projects) — 0 deals,
|
||||
expect($lead->supplier_project_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean', function (): void {
|
||||
it('same phone pre-existing does not suppress new delivery (Spec B)', function (): void {
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
@@ -244,7 +196,7 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean
|
||||
$tenants = collect();
|
||||
$projects = collect();
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$t = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$tenants->push($t);
|
||||
$projects->push(Project::factory()->create([
|
||||
'tenant_id' => $t->id,
|
||||
@@ -258,14 +210,14 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean
|
||||
linkProjectToSupplier($projects->last(), $supplier);
|
||||
}
|
||||
|
||||
// Tenant #0 имеет master deal с тем же phone в окне 24 ч — будет дубль.
|
||||
$masterTenant = $tenants[0];
|
||||
$masterProject = $projects[0];
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'");
|
||||
$master = Deal::create([
|
||||
'tenant_id' => $masterTenant->id,
|
||||
// Tenant #0 имеет pre-existing deal с тем же phone — под новым правилом НЕ подавляет.
|
||||
$firstTenant = $tenants[0];
|
||||
$firstProject = $projects[0];
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$firstTenant->id}'");
|
||||
Deal::create([
|
||||
'tenant_id' => $firstTenant->id,
|
||||
'source_crm_id' => 555,
|
||||
'project_id' => $masterProject->id,
|
||||
'project_id' => $firstProject->id,
|
||||
'phone' => '79991234567',
|
||||
'phones' => ['79991234567'],
|
||||
'status' => 'new',
|
||||
@@ -285,22 +237,19 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean
|
||||
|
||||
$lead->refresh();
|
||||
expect($lead->processed_at)->not->toBeNull();
|
||||
expect($lead->deals_created_count)->toBe(2); // 2 чистых, 1 дубль не считается
|
||||
// Spec B: pre-existing master does NOT suppress — all 3 charged.
|
||||
expect($lead->deals_created_count)->toBe(3);
|
||||
|
||||
// Tenant #0: deal помечен duplicate_of_id, balance НЕ списан, delivered_today = 0
|
||||
expect($masterTenant->fresh()->balance_leads)->toBe(100);
|
||||
expect($masterProject->fresh()->delivered_today)->toBe(0);
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$masterTenant->id}'");
|
||||
$dupDeal = Deal::query()->where('source_crm_id', $vid)->first();
|
||||
expect($dupDeal->duplicate_of_id)->toBe($master->id);
|
||||
|
||||
// Tenant #1, #2: balance списан, delivered_today инкрементирован
|
||||
foreach ([1, 2] as $i) {
|
||||
// All 3 tenants: balance decremented, delivered_today incremented.
|
||||
foreach (range(0, 2) as $i) {
|
||||
$t = $tenants[$i];
|
||||
$p = $projects[$i];
|
||||
expect($t->fresh()->balance_leads)->toBe(99);
|
||||
expect((string) $t->fresh()->balance_rub)->toBe('99500.00');
|
||||
expect($p->fresh()->delivered_today)->toBe(1);
|
||||
}
|
||||
|
||||
// 3 deal rows exist for this vid (one per tenant).
|
||||
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('idempotent on retry — second handle() returns early, no ghost duplicate deals (Plan 2.5 fix #3)', function (): void {
|
||||
@@ -317,7 +266,7 @@ it('idempotent on retry — second handle() returns early, no ghost duplicate de
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'retry-idempotent.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
@@ -347,7 +296,7 @@ it('idempotent on retry — second handle() returns early, no ghost duplicate de
|
||||
$lead->refresh();
|
||||
expect($lead->processed_at)->not->toBeNull();
|
||||
expect($lead->deals_created_count)->toBe(1);
|
||||
expect($tenant->fresh()->balance_leads)->toBe(99);
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
|
||||
expect($project->fresh()->delivered_today)->toBe(1);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
@@ -362,7 +311,7 @@ it('idempotent on retry — second handle() returns early, no ghost duplicate de
|
||||
expect($lead->deals_created_count)->toBe(1);
|
||||
|
||||
// НИКАКИХ дублей не появилось: balance, counter, deal-row.
|
||||
expect($tenant->fresh()->balance_leads)->toBe(99);
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
|
||||
expect($project->fresh()->delivered_today)->toBe(1);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
@@ -385,7 +334,7 @@ it('handles partial failure: one project throws, others continue routing', funct
|
||||
$tenants = collect();
|
||||
$projects = collect();
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$t = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$tenants->push($t);
|
||||
$projects->push(Project::factory()->create([
|
||||
'tenant_id' => $t->id,
|
||||
@@ -417,8 +366,8 @@ it('handles partial failure: one project throws, others continue routing', funct
|
||||
expect($lead->deals_created_count)->toBe(2); // tenant 0 + 2; tenant 1 упал
|
||||
|
||||
// Tenants 0 и 2 успешно списаны
|
||||
expect($tenants[0]->fresh()->balance_leads)->toBe(99);
|
||||
expect($tenants[2]->fresh()->balance_leads)->toBe(99);
|
||||
expect((string) $tenants[0]->fresh()->balance_rub)->toBe('99500.00');
|
||||
expect((string) $tenants[2]->fresh()->balance_rub)->toBe('99500.00');
|
||||
});
|
||||
|
||||
it('routes B1 lead whose project name embeds a domain in free text (carmoney/caranga/krk)', function (string $projectField, string $domain): void {
|
||||
@@ -431,7 +380,7 @@ it('routes B1 lead whose project name embeds a domain in free text (carmoney/car
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => $domain,
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
@@ -486,7 +435,7 @@ it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'race-recheck.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
@@ -532,8 +481,8 @@ it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2
|
||||
// delivered_in_month НЕ инкрементнулся.
|
||||
expect($project->fresh()->delivered_in_month)->toBe(5);
|
||||
|
||||
// balance_leads НЕ списан.
|
||||
expect($tenant->fresh()->balance_leads)->toBe(100);
|
||||
// balance_rub НЕ списан.
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('100000.00');
|
||||
|
||||
// Deal-row не создался.
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
@@ -553,7 +502,7 @@ it('caps deal creation at 3 recipients and tags deal with subject from payload',
|
||||
|
||||
// 5 eligible клиентов, привязанных к sp через pivot, с балансом и лимитом
|
||||
foreach (range(1, 5) as $i) {
|
||||
$t = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $t->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
@@ -573,7 +522,6 @@ it('caps deal creation at 3 recipients and tags deal with subject from payload',
|
||||
(new RouteSupplierLeadJob($lead->id))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(DuplicateDetector::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
|
||||
@@ -2,19 +2,13 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Mail\InvoicePaidNotification;
|
||||
use App\Mail\LowBalanceNotification;
|
||||
use App\Mail\TopupSuccessNotification;
|
||||
use App\Mail\ZeroBalanceNotification;
|
||||
use App\Models\InAppNotification;
|
||||
use App\Models\RejectedDealsLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
@@ -23,18 +17,6 @@ beforeEach(function () {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
function balancePayload(int $vid = 500): array
|
||||
{
|
||||
return [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_Caranga',
|
||||
'tag' => 'Caranga',
|
||||
'phone' => '79000000'.$vid,
|
||||
'phones' => ['79000000'.$vid],
|
||||
'time' => time(),
|
||||
];
|
||||
}
|
||||
|
||||
function makeUserForBalance(Tenant $tenant, string $email, array $events = []): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
@@ -53,102 +35,6 @@ function makeUserForBalance(Tenant $tenant, string $email, array $events = []):
|
||||
]);
|
||||
}
|
||||
|
||||
// ============== low_balance ==============
|
||||
|
||||
test('low_balance: при пересечении порога сверху-вниз → email + inapp', function () {
|
||||
// Default threshold: 10 (system_settings seeded). Установим balance=11.
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 11]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(10); // 11 → 10 (пересекли порог)
|
||||
|
||||
Mail::assertSent(LowBalanceNotification::class, 1);
|
||||
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('low_balance: balance уже < threshold — НЕ шлёт повторно', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 5]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(4); // 5 → 4 (всё ещё < threshold=10)
|
||||
|
||||
// Не пересекали порог — НЕ шлём.
|
||||
Mail::assertNothingSent();
|
||||
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('low_balance: balance > threshold после decrement — НЕ шлёт', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 50]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(49);
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('low_balance: prefs.low_balance.email=false — только inapp', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 11]);
|
||||
makeUserForBalance($tenant, 'on@example.ru', [
|
||||
'low_balance' => ['email' => false, 'inapp' => true],
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
expect(InAppNotification::query()->where('event', 'low_balance')->count())->toBe(1);
|
||||
});
|
||||
|
||||
// ============== zero_balance ==============
|
||||
|
||||
test('zero_balance: первое отклонение → email + inapp', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
Mail::assertSent(ZeroBalanceNotification::class, 1);
|
||||
expect(InAppNotification::query()->where('event', 'zero_balance')->count())->toBe(1);
|
||||
expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('zero_balance: 2-е отклонение в течение часа — НЕ дублирует email', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload(vid: 1)))->handle();
|
||||
Mail::assertSent(ZeroBalanceNotification::class, 1);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload(vid: 2)))->handle();
|
||||
Mail::assertSent(ZeroBalanceNotification::class, 1); // всё ещё один
|
||||
expect(RejectedDealsLog::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('zero_balance: отклонение через >1ч — снова шлёт', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
makeUserForBalance($tenant, 'on@example.ru');
|
||||
|
||||
// Создаём старый RejectedDealsLog (>1ч назад) — он не должен суппрессить.
|
||||
DB::table('rejected_deals_log')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'reason' => RejectedDealsLog::REASON_ZERO_BALANCE,
|
||||
'payload' => json_encode(['vid' => 999]),
|
||||
'created_at' => Carbon::now()->subHours(2),
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, balancePayload()))->handle();
|
||||
|
||||
Mail::assertSent(ZeroBalanceNotification::class, 1);
|
||||
});
|
||||
|
||||
// ============== topup_success ==============
|
||||
|
||||
test('topup_success: notifyTopupSuccess создаёт email + inapp', function () {
|
||||
@@ -204,18 +90,3 @@ test('invoice_paid: prefs=email:false — только inapp', function () {
|
||||
Mail::assertNothingSent();
|
||||
expect(InAppNotification::query()->where('event', 'invoice_paid')->count())->toBe(1);
|
||||
});
|
||||
|
||||
// ============== isolation ==============
|
||||
|
||||
test('balance events изолированы между тенантами', function () {
|
||||
$tenantA = Tenant::factory()->create(['balance_leads' => 11]);
|
||||
$tenantB = Tenant::factory()->create(['balance_leads' => 11]);
|
||||
$userA = makeUserForBalance($tenantA, 'a@example.ru');
|
||||
makeUserForBalance($tenantB, 'b@example.ru');
|
||||
|
||||
(new ProcessWebhookJob($tenantA->id, balancePayload()))->handle();
|
||||
|
||||
Mail::assertSent(LowBalanceNotification::class, 1);
|
||||
Mail::assertSent(fn (LowBalanceNotification $m) => $m->hasTo($userA->email));
|
||||
Mail::assertNotSent(fn (LowBalanceNotification $m) => $m->hasTo('b@example.ru'));
|
||||
});
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Mail\NewLeadNotification;
|
||||
use App\Models\InAppNotification;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@@ -14,12 +12,8 @@ use Illuminate\Support\Facades\Mail;
|
||||
/**
|
||||
* Тесты in-app канала уведомлений (schema v8.10 in_app_notifications).
|
||||
*
|
||||
* Канал inapp в матрице users.notification_preferences. INSERT row при
|
||||
* триггере события (new_lead/...). UI читает unread-count и список
|
||||
* последних 50 (этап 2b — отдельный коммит).
|
||||
*
|
||||
* Schema-default: notification_preferences.new_lead.inapp=true → в отличие
|
||||
* от email, большинство user'ов получает in-app по умолчанию.
|
||||
* Тесты через ProcessWebhookJob удалены — job убран как legacy-рудимент.
|
||||
* Оставлен прямой вызов NotificationService::notifyInApp.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
@@ -27,164 +21,6 @@ beforeEach(function () {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function inAppPayload(int $vid = 300, ?int $time = null): array
|
||||
{
|
||||
return [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_Caranga',
|
||||
'tag' => 'Caranga',
|
||||
'phone' => '79001234567',
|
||||
'phones' => ['79001234567'],
|
||||
'time' => $time ?? time(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $newLeadPrefs
|
||||
*/
|
||||
function makeUserWithInAppPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => $email,
|
||||
'notification_preferences' => [
|
||||
'new_lead' => $newLeadPrefs,
|
||||
'reminder' => ['inapp' => true, 'push' => true, 'email' => true],
|
||||
'low_balance' => ['email' => true],
|
||||
'zero_balance' => ['email' => true],
|
||||
'topup_success' => ['email' => true],
|
||||
'invoice_paid' => ['email' => true],
|
||||
'new_device_login' => ['email' => true],
|
||||
'marketing' => ['email' => false],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
test('webhook: in_app_notification создаётся для user с inapp=true', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$user = makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true, 'email' => false]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
$notif = InAppNotification::query()->first();
|
||||
expect($notif->user_id)->toBe($user->id);
|
||||
expect($notif->tenant_id)->toBe($tenant->id);
|
||||
expect($notif->event)->toBe('new_lead');
|
||||
expect($notif->title)->toContain('Caranga');
|
||||
expect($notif->body)->toBe('79001234567'); // phone (no contact_name)
|
||||
expect($notif->read_at)->toBeNull();
|
||||
expect($notif->payload['project_name'])->toBe('Caranga');
|
||||
});
|
||||
|
||||
test('webhook: user с inapp=false НЕ получает in-app row', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithInAppPrefs($tenant, 'off@example.ru', ['inapp' => false, 'email' => false]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('webhook: schema-default (inapp=true) ставит row', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
// Без override prefs — берётся schema DEFAULT (new_lead.inapp=true).
|
||||
User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'default@example.ru',
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('webhook: 2 user\'а с inapp=true получают по 1 row, 1 user с inapp=false — нет', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$a = makeUserWithInAppPrefs($tenant, 'a@example.ru', ['inapp' => true]);
|
||||
$b = makeUserWithInAppPrefs($tenant, 'b@example.ru', ['inapp' => true]);
|
||||
makeUserWithInAppPrefs($tenant, 'c@example.ru', ['inapp' => false]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(2);
|
||||
expect(InAppNotification::query()->where('user_id', $a->id)->exists())->toBeTrue();
|
||||
expect(InAppNotification::query()->where('user_id', $b->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('webhook: inactive user НЕ получает in-app', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
User::factory()->inactive()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'inactive@example.ru',
|
||||
'notification_preferences' => ['new_lead' => ['inapp' => true]],
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('webhook: user другого тенанта НЕ получает (RLS isolation)', function () {
|
||||
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$userA = makeUserWithInAppPrefs($tenantA, 'a@example.ru', ['inapp' => true]);
|
||||
makeUserWithInAppPrefs($tenantB, 'b@example.ru', ['inapp' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenantA->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
expect(InAppNotification::query()->first()->user_id)->toBe($userA->id);
|
||||
});
|
||||
|
||||
test('webhook: дубль (Биз-19) НЕ создаёт повторный in-app row', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 1)))->handle();
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
|
||||
// Второй webhook с тем же phone в окне 24ч → дубль, нет chargeNewLead → нет notify.
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 2)))->handle();
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('webhook: повторный vid (UPDATE) НЕ создаёт повторный in-app row', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle();
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload(vid: 100)))->handle();
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('webhook: оба канала (inapp+email=true) — 1 in-app row + 1 email', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithInAppPrefs($tenant, 'both@example.ru', ['inapp' => true, 'email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
expect(InAppNotification::query()->count())->toBe(1);
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
});
|
||||
|
||||
test('webhook: payload содержит deal_id для UI deep-link', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithInAppPrefs($tenant, 'on@example.ru', ['inapp' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, inAppPayload()))->handle();
|
||||
|
||||
$notif = InAppNotification::query()->first();
|
||||
expect($notif->deal_id)->not->toBeNull();
|
||||
expect($notif->payload)->toHaveKey('deal_id');
|
||||
expect($notif->payload['deal_id'])->toBe($notif->deal_id);
|
||||
});
|
||||
|
||||
test('NotificationService::notifyInApp: вызов напрямую создаёт row', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
@@ -1,199 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Mail\NewLeadNotification;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Тесты email-уведомления о новом лиде (ТЗ §18.5, событие new_lead).
|
||||
*
|
||||
* Проверяет интеграцию NotificationService → ProcessWebhookJob: после успешного
|
||||
* chargeNewLead все активные user'ы тенанта с notification_preferences.new_lead.email=true
|
||||
* получают NewLeadNotification. Mail::fake() перехватывает реальную отправку.
|
||||
*
|
||||
* Schema-default: notification_preferences.new_lead.email=false → по умолчанию
|
||||
* никто не получает emails. Тесты явно ставят email=true для нужных user'ов.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function newLeadPayload(int $vid = 200, ?int $time = null): array
|
||||
{
|
||||
return [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_Caranga',
|
||||
'tag' => 'Caranga',
|
||||
'phone' => '79001234567',
|
||||
'phones' => ['79001234567'],
|
||||
'time' => $time ?? time(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $newLeadPrefs
|
||||
*/
|
||||
function makeUserWithPrefs(Tenant $tenant, string $email, array $newLeadPrefs): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => $email,
|
||||
'notification_preferences' => [
|
||||
'new_lead' => $newLeadPrefs,
|
||||
'reminder' => ['inapp' => true, 'push' => true, 'email' => true],
|
||||
'low_balance' => ['email' => true],
|
||||
'zero_balance' => ['email' => true],
|
||||
'topup_success' => ['email' => true],
|
||||
'invoice_paid' => ['email' => true],
|
||||
'new_device_login' => ['email' => true],
|
||||
'marketing' => ['email' => false],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
test('webhook: NewLeadNotification отправляется user\'ам с email=true', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$userOn = makeUserWithPrefs($tenant, 'on@example.ru', ['inapp' => true, 'push' => true, 'email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail) use ($userOn): bool {
|
||||
return $mail->manager->id === $userOn->id
|
||||
&& $mail->hasTo('on@example.ru');
|
||||
});
|
||||
});
|
||||
|
||||
test('webhook: user с email=false НЕ получает', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenant, 'off@example.ru', ['inapp' => true, 'push' => true, 'email' => false]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('webhook: schema-default не шлёт (new_lead.email=false по дефолту)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
// Не передаём notification_preferences — берётся schema DEFAULT.
|
||||
User::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'default@example.ru',
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('webhook: рассылается всем активным user\'ам с email=true', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenant, 'a@example.ru', ['email' => true]);
|
||||
makeUserWithPrefs($tenant, 'b@example.ru', ['email' => true]);
|
||||
makeUserWithPrefs($tenant, 'c@example.ru', ['email' => false]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertSent(NewLeadNotification::class, 2);
|
||||
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru'));
|
||||
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru'));
|
||||
Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('c@example.ru'));
|
||||
});
|
||||
|
||||
test('webhook: inactive user с email=true НЕ получает (is_active=false)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
User::factory()->inactive()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => 'inactive@example.ru',
|
||||
'notification_preferences' => [
|
||||
'new_lead' => ['email' => true],
|
||||
],
|
||||
]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('webhook: soft-deleted user НЕ получает', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$user = makeUserWithPrefs($tenant, 'deleted@example.ru', ['email' => true]);
|
||||
$user->delete();
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
test('webhook: user другого тенанта НЕ получает (изоляция)', function () {
|
||||
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenantA, 'a@example.ru', ['email' => true]);
|
||||
makeUserWithPrefs($tenantB, 'b@example.ru', ['email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenantA->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
Mail::assertSent(fn (NewLeadNotification $mail) => $mail->hasTo('a@example.ru'));
|
||||
Mail::assertNotSent(fn (NewLeadNotification $mail) => $mail->hasTo('b@example.ru'));
|
||||
});
|
||||
|
||||
test('webhook: дубль-сделка (Биз-19) НЕ шлёт повторное уведомление', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
|
||||
|
||||
// Первая сделка — master.
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 1)))->handle();
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
|
||||
// Вторая сделка с тем же phone в окне 24 ч — дубль, баланс НЕ списывается,
|
||||
// chargeNewLead НЕ вызывается, уведомление НЕ шлётся.
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 2)))->handle();
|
||||
Mail::assertSent(NewLeadNotification::class, 1); // всё ещё одно
|
||||
});
|
||||
|
||||
test('webhook: повторный vid (idempotent UPDATE) НЕ шлёт повторное уведомление', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle();
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
|
||||
// Повторный webhook с тем же vid — UPDATE, не INSERT. wasRecentlyCreated=false → return.
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload(vid: 100)))->handle();
|
||||
Mail::assertSent(NewLeadNotification::class, 1);
|
||||
});
|
||||
|
||||
test('webhook: balance=0 (RejectedDealsLog) НЕ шлёт NewLeadNotification', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
// chargeNewLead НЕ вызывается при balance=0 — NewLeadNotification не шлётся.
|
||||
// (ZeroBalanceNotification ШЛЁТСЯ — это покрывается отдельным тестом.)
|
||||
Mail::assertNotSent(NewLeadNotification::class);
|
||||
expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('NewLeadNotification: subject содержит project_name', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
makeUserWithPrefs($tenant, 'on@example.ru', ['email' => true]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, newLeadPayload()))->handle();
|
||||
|
||||
Mail::assertSent(NewLeadNotification::class, function (NewLeadNotification $mail): bool {
|
||||
return str_contains($mail->envelope()->subject, 'Caranga');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ beforeEach(function () {
|
||||
$this->partitionsBefore = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->pluck('relname')->all();
|
||||
});
|
||||
|
||||
@@ -25,7 +25,7 @@ afterEach(function () {
|
||||
$partitionsAfter = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->pluck('relname')->all();
|
||||
|
||||
// DETACH перед DROP: иначе `DROP TABLE ... CASCADE` сносит FK от
|
||||
@@ -61,7 +61,7 @@ test('идемпотентность: повторный запуск не па
|
||||
$afterFirst = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->count();
|
||||
|
||||
// Повторный запуск — должен только skip'ать.
|
||||
@@ -71,14 +71,15 @@ test('идемпотентность: повторный запуск не па
|
||||
$afterSecond = collect(DB::select("
|
||||
SELECT relname FROM pg_class
|
||||
WHERE relkind = 'r'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|webhook_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
AND relname ~ '^(deals|supplier_lead_costs|auth_log|activity_log|tenant_operations_log|balance_transactions|pd_processing_log|saas_admin_audit_log)_[0-9]{4}_[0-9]{2}$'
|
||||
"))->count();
|
||||
|
||||
expect($afterSecond)->toBe($afterFirst);
|
||||
|
||||
// Output второго запуска должен сказать «0 created» по всем 9 таблицам × 6 месяцев = 54 партиции.
|
||||
// Output второго запуска должен сказать «0 created» по всем 8 таблицам × 6 месяцев = 48 партиций.
|
||||
// (webhook_log удалён в миграции 2026_05_24_140000_drop_legacy_webhook_artefacts)
|
||||
$output = Artisan::output();
|
||||
expect($output)->toContain('0 created, 54 skipped');
|
||||
expect($output)->toContain('0 created, 48 skipped');
|
||||
});
|
||||
|
||||
test('--ahead=0 создаёт только текущий месяц', function () {
|
||||
@@ -100,7 +101,6 @@ test('партиция корректно принимает INSERT в окно
|
||||
'subdomain' => 'partition-test-'.uniqid(),
|
||||
'organization_name' => 'PartitionTest',
|
||||
'contact_email' => 'pt@test.local',
|
||||
'webhook_token' => str_repeat('p', 64),
|
||||
'api_key_limit' => 5,
|
||||
]);
|
||||
$projectId = DB::table('projects')->insertGetId([
|
||||
|
||||
@@ -4,11 +4,9 @@ declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 152-ФЗ: pd_processing_log 'created' записывается при создании сделки
|
||||
* по всем трём путям — ручной API, поставщик (RouteSupplierLeadJob),
|
||||
* вебхук (ProcessWebhookJob).
|
||||
* по двум живым путям — ручной API, поставщик (RouteSupplierLeadJob).
|
||||
*/
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
@@ -17,7 +15,6 @@ use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
@@ -78,7 +75,7 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'pd-test.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
@@ -107,7 +104,6 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl
|
||||
(new RouteSupplierLeadJob($lead->id))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(DuplicateDetector::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
@@ -130,36 +126,3 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl
|
||||
|
||||
expect($rows)->toBe(1);
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Path C: webhook via ProcessWebhookJob
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
it('writes pd_processing_log created (webhook) when deal created via ProcessWebhookJob', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
$vid = 55566;
|
||||
(new ProcessWebhookJob($tenant->id, [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_PdWebhookTest',
|
||||
'tag' => 'PdWebhookTest',
|
||||
'phone' => '79001112233',
|
||||
'phones' => ['79001112233'],
|
||||
'time' => time(),
|
||||
]))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
|
||||
$rows = DB::table('pd_processing_log')
|
||||
->where('action', 'created')
|
||||
->where('purpose', 'lead_create_webhook')
|
||||
->where('subject_type', 'lead')
|
||||
->where('subject_id', $deal->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNull('actor_tenant_user_id')
|
||||
->whereNull('actor_admin_user_id')
|
||||
->count();
|
||||
|
||||
expect($rows)->toBe(1);
|
||||
});
|
||||
|
||||
@@ -14,8 +14,11 @@ use App\Services\Import\CsvLeadsParser;
|
||||
use App\Services\Import\HistoricalImportService;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
// HistoricalImportService → MonthlyPartitionManager → CREATE через pgsql_supplier
|
||||
// (см. MonthlyPartitionManager::DDL_CONNECTION). Нужен shared PDO.
|
||||
|
||||
it('writes pd_processing_log created on historical import for each new deal', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
@@ -19,8 +19,8 @@ it('tenants table has delivered_in_month column with CHECK >= 0', function () {
|
||||
expect(Schema::hasColumn('tenants', 'delivered_in_month'))->toBeTrue();
|
||||
DB::table('tenants')->where('id', '<', 0)->update(['delivered_in_month' => 5]); // no-op
|
||||
expect(fn () => DB::statement(
|
||||
'INSERT INTO tenants (subdomain, organization_name, contact_email, webhook_token, delivered_in_month) '.
|
||||
"VALUES ('t-neg-test', 'X', 'x@x', 'wtok-neg-test-99999999', -1)"
|
||||
'INSERT INTO tenants (subdomain, organization_name, contact_email, delivered_in_month) '.
|
||||
"VALUES ('t-neg-test', 'X', 'x@x', -1)"
|
||||
))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
@@ -59,32 +59,33 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('schema.sql v8.26 has correct metrics — 65 base tables, 123 indexes, 40 RLS policies', function () {
|
||||
it('schema.sql v8.35 has correct metrics — 66 base tables, 120 indexes, 40 RLS policies', function () {
|
||||
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
|
||||
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
|
||||
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.26.
|
||||
// источник истины метрик.
|
||||
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика.
|
||||
// v8.22 (Plan 6/C9): +1 GIN-индекс idx_projects_regions.
|
||||
// v8.25 (supplier-failover): +1 таблица supplier_manual_sync_queue, +2 индекса.
|
||||
// v8.26 (project-migration-redesign Plans 1-3): +1 таблица project_supplier_links (M:N pivot)
|
||||
// + 2 индекса (supplier_projects_platform_key_subject_unique, idx_psl_*).
|
||||
// v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6).
|
||||
// v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2.
|
||||
// v8.35 (legacy webhook removal): −2 таблицы (webhook_log partitioned + rejected_deals_log)
|
||||
// −5 индексов, −2 RLS-политики, −2 колонки tenants.webhook_token/webhook_token_rotated_at.
|
||||
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
|
||||
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
|
||||
$schema = file_get_contents($schemaPath);
|
||||
expect($schema)->not->toBeFalse();
|
||||
|
||||
// v8.30: +1 таблица scheduler_heartbeats (SaaS-level, hole #6).
|
||||
// v8.31: 7 audit-таблиц переведены в PARTITION BY RANGE, hole #2.
|
||||
//
|
||||
// 67 base tables = все CREATE TABLE минус PARTITION OF.
|
||||
// 66 base tables = все CREATE TABLE минус PARTITION OF.
|
||||
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
|
||||
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
|
||||
$baseTables = $createTables - $partitionOf;
|
||||
expect($baseTables)->toBe(67);
|
||||
expect($baseTables)->toBe(66);
|
||||
|
||||
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
|
||||
expect($createIndexes)->toBe(126); // v8.31: +3 индекса audit-таблиц после partitioning
|
||||
expect($createIndexes)->toBe(120); // v8.35: −5 индексов (webhook_log ×2, rejected_deals_log ×2, tenants.webhook_token ×1)
|
||||
|
||||
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
|
||||
expect($createPolicies)->toBe(41); // v8.31: +1 политика на partitioned audit-таблицах
|
||||
expect($createPolicies)->toBe(40); // v8.35: −2 политики (webhook_log + rejected_deals_log)
|
||||
});
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\RejectedDealsLog;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\WebhookDedupKey;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
/**
|
||||
* Тесты ProcessWebhookJob — двустадийный dedup v8.6 (CTO-17).
|
||||
*
|
||||
* Проверяет ключевую архитектурную инвариант: один и тот же vid должен
|
||||
* обновлять существующую сделку (а не создавать дубль), и баланс должен
|
||||
* списываться ровно один раз. См. narrative ТЗ §5.5.
|
||||
*
|
||||
* NB: Job::handle() сам открывает DB::transaction. DatabaseTransactions
|
||||
* trait оборачивает каждый тест в outer-транзакцию — Laravel-PG-driver
|
||||
* корректно обрабатывает nested через savepoints.
|
||||
*
|
||||
* SharesSupplierPdo: failed() now inserts via pgsql_supplier (BYPASSRLS) —
|
||||
* share PDO so DatabaseTransactions cross-connection visibility works on dev.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
function makePayload(int $vid = 432176649, ?int $time = null): array
|
||||
{
|
||||
return [
|
||||
'vid' => $vid,
|
||||
'project' => 'B2_Caranga', // префикс должен обрезаться до 'Caranga'
|
||||
'tag' => 'Caranga',
|
||||
'phone' => '79001234567',
|
||||
'phones' => ['79001234567'],
|
||||
'time' => $time ?? time(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт активного поставщика и привязывает его к проекту через project_suppliers.
|
||||
* Используется в тестах SupplierLeadCost-ветки.
|
||||
*/
|
||||
function seedSupplierForProject(Project $project, float $costRub = 50.00): int
|
||||
{
|
||||
$supplierId = (int) DB::table('suppliers')->insertGetId([
|
||||
'code' => 'b1-test-'.Str::lower(Str::random(6)),
|
||||
'name' => 'B1 Test',
|
||||
'accepts_types' => '{websites,calls}',
|
||||
'cost_rub' => $costRub,
|
||||
'channel' => 'sites',
|
||||
'quality_score' => 1.00,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
DB::table('project_suppliers')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_id' => $supplierId,
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
return $supplierId;
|
||||
}
|
||||
|
||||
test('новая сделка: INSERT в deals + INSERT в webhook_dedup_keys, баланс -1', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 100)))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(9);
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
expect($deal->source_crm_id)->toBe(100);
|
||||
expect($deal->phone)->toBe('79001234567');
|
||||
expect($deal->status)->toBe('new');
|
||||
expect($deal->project->name)->toBe('Caranga'); // префикс B2_ обрезан
|
||||
|
||||
$dedup = WebhookDedupKey::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('source_crm_id', 100)
|
||||
->first();
|
||||
expect($dedup)->not->toBeNull();
|
||||
expect($dedup->deal_id)->toBe($deal->id);
|
||||
});
|
||||
|
||||
test('дубль vid: UPDATE существующей сделки, баланс НЕ списывается второй раз', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$vid = 200;
|
||||
|
||||
// Первый webhook
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(9);
|
||||
$dealsAfterFirst = Deal::query()->where('tenant_id', $tenant->id)->count();
|
||||
|
||||
// Второй webhook с тем же vid (но новым phone — будет UPDATE)
|
||||
$payload2 = makePayload(vid: $vid);
|
||||
$payload2['phone'] = '79009999999';
|
||||
(new ProcessWebhookJob($tenant->id, $payload2))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(9); // баланс не изменился
|
||||
expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe($dealsAfterFirst);
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->first();
|
||||
expect($deal->phone)->toBe('79009999999'); // обновлён phone
|
||||
|
||||
// dedup-ключ всё ещё ровно один
|
||||
expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->where('source_crm_id', $vid)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('баланс=0: запись в лог, без INSERT в deals и dedup_keys', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 300)))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(0);
|
||||
expect(Deal::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
|
||||
expect(WebhookDedupKey::query()->where('tenant_id', $tenant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('изоляция тенантов: одинаковый vid у разных тенантов = разные сделки', function () {
|
||||
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenantA->id, makePayload(vid: 555)))->handle();
|
||||
(new ProcessWebhookJob($tenantB->id, makePayload(vid: 555)))->handle();
|
||||
|
||||
expect(Deal::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
|
||||
expect(Deal::query()->where('tenant_id', $tenantB->id)->count())->toBe(1);
|
||||
expect(WebhookDedupKey::query()->count())->toBeGreaterThanOrEqual(2);
|
||||
|
||||
$tenantA->refresh();
|
||||
$tenantB->refresh();
|
||||
expect($tenantA->balance_leads)->toBe(9);
|
||||
expect($tenantB->balance_leads)->toBe(9);
|
||||
});
|
||||
|
||||
test('findOrCreate проекта: повторный webhook с тем же project не создаёт дубля', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 401)))->handle();
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 402)))->handle();
|
||||
|
||||
expect(Project::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('ON DELETE CASCADE: удаление сделки очищает webhook_dedup_keys', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 700)))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
DB::table('deals')
|
||||
->where('id', $deal->id)
|
||||
->where('received_at', $deal->received_at)
|
||||
->delete();
|
||||
|
||||
expect(WebhookDedupKey::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('source_crm_id', 700)
|
||||
->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('новая сделка создаёт BalanceTransaction (lead_charge -1)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 800)))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
$tx = BalanceTransaction::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_LEAD_CHARGE)
|
||||
->first();
|
||||
|
||||
expect($tx)->not->toBeNull();
|
||||
expect($tx->amount_leads)->toBe(-1);
|
||||
expect($tx->balance_leads_after)->toBe(9);
|
||||
expect($tx->related_type)->toBe(Deal::class);
|
||||
expect($tx->related_id)->toBe($deal->id);
|
||||
});
|
||||
|
||||
test('дубль vid НЕ создаёт BalanceTransaction', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$vid = 801;
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: $vid)))->handle();
|
||||
|
||||
expect(BalanceTransaction::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('новая сделка создаёт ActivityLog event=deal.created', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 802)))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
$log = ActivityLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('deal_id', $deal->id)
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull();
|
||||
expect($log->event)->toBe(ActivityLog::EVENT_DEAL_CREATED);
|
||||
expect($log->user_id)->toBeNull();
|
||||
expect($log->context)->toBe(['source' => 'webhook']);
|
||||
});
|
||||
|
||||
test('баланс=0 пишет в RejectedDealsLog с reason=zero_balance', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 803)))->handle();
|
||||
|
||||
$rejected = RejectedDealsLog::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->first();
|
||||
|
||||
expect($rejected)->not->toBeNull();
|
||||
expect($rejected->reason)->toBe(RejectedDealsLog::REASON_ZERO_BALANCE);
|
||||
expect($rejected->payload['vid'])->toBe(803);
|
||||
});
|
||||
|
||||
test('SupplierLeadCost создаётся со snapshot cost_rub из supplier', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Caranga', // совпадает с обрезанным project из payload
|
||||
]);
|
||||
$supplierId = seedSupplierForProject($project, costRub: 75.50);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 804)))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
$cost = SupplierLeadCost::query()
|
||||
->where('deal_id', $deal->id)
|
||||
->where('received_at', $deal->received_at)
|
||||
->first();
|
||||
|
||||
expect($cost)->not->toBeNull();
|
||||
expect($cost->supplier_id)->toBe($supplierId);
|
||||
expect((string) $cost->cost_rub)->toBe('75.50');
|
||||
expect($cost->supplier_lead_id)->toBe(804);
|
||||
});
|
||||
|
||||
test('SupplierLeadCost НЕ создаётся если у проекта нет активного supplier', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
(new ProcessWebhookJob($tenant->id, makePayload(vid: 805)))->handle();
|
||||
|
||||
$deal = Deal::query()->where('tenant_id', $tenant->id)->first();
|
||||
expect(SupplierLeadCost::query()
|
||||
->where('deal_id', $deal->id)
|
||||
->count())->toBe(0);
|
||||
|
||||
// Сделка всё равно создаётся, баланс списан, ActivityLog есть.
|
||||
expect($deal)->not->toBeNull();
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(9);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Биз-19: антифрод-дедуп по phone в окне 24 ч (DuplicateDetector, §10.8.1)
|
||||
// =============================================================================
|
||||
|
||||
test('Биз-19: master в окне 24ч → дубль, баланс НЕ списывается', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$phone = '79007770001';
|
||||
|
||||
// Master: пришёл вчера в 12:00.
|
||||
$masterPayload = makePayload(vid: 901, time: now()->subHours(12)->timestamp);
|
||||
$masterPayload['phone'] = $phone;
|
||||
$masterPayload['phones'] = [$phone];
|
||||
(new ProcessWebhookJob($tenant->id, $masterPayload))->handle();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(9);
|
||||
|
||||
// Дубль: пришёл сейчас, в окне 24 ч.
|
||||
$dupPayload = makePayload(vid: 902, time: now()->timestamp);
|
||||
$dupPayload['phone'] = $phone;
|
||||
$dupPayload['phones'] = [$phone];
|
||||
(new ProcessWebhookJob($tenant->id, $dupPayload))->handle();
|
||||
|
||||
$master = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 901)->first();
|
||||
$dup = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 902)->first();
|
||||
|
||||
expect($master->duplicate_of_id)->toBeNull();
|
||||
expect($dup->duplicate_of_id)->toBe($master->id);
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(9); // только master списан, дубль — нет
|
||||
expect(BalanceTransaction::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
expect(SupplierLeadCost::query()->where('deal_id', $dup->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('Биз-19: master старше 24ч → НЕ дубль, баланс списывается дважды', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$phone = '79007770002';
|
||||
|
||||
// Master: пришёл 25 часов назад — за окном.
|
||||
$masterPayload = makePayload(vid: 911, time: now()->subHours(25)->timestamp);
|
||||
$masterPayload['phone'] = $phone;
|
||||
$masterPayload['phones'] = [$phone];
|
||||
(new ProcessWebhookJob($tenant->id, $masterPayload))->handle();
|
||||
|
||||
// Новая сделка с тем же phone — master уже за окном.
|
||||
$newPayload = makePayload(vid: 912, time: now()->timestamp);
|
||||
$newPayload['phone'] = $phone;
|
||||
$newPayload['phones'] = [$phone];
|
||||
(new ProcessWebhookJob($tenant->id, $newPayload))->handle();
|
||||
|
||||
$deal911 = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 911)->first();
|
||||
$deal912 = Deal::query()->where('tenant_id', $tenant->id)->where('source_crm_id', 912)->first();
|
||||
|
||||
expect($deal911->duplicate_of_id)->toBeNull();
|
||||
expect($deal912->duplicate_of_id)->toBeNull();
|
||||
|
||||
$tenant->refresh();
|
||||
expect($tenant->balance_leads)->toBe(8); // оба списаны
|
||||
expect(BalanceTransaction::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('Биз-19: дубли изолированы по tenant_id', function () {
|
||||
$tenantA = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$tenantB = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$phone = '79007770003';
|
||||
|
||||
$payloadA = makePayload(vid: 921);
|
||||
$payloadA['phone'] = $phone;
|
||||
$payloadA['phones'] = [$phone];
|
||||
(new ProcessWebhookJob($tenantA->id, $payloadA))->handle();
|
||||
|
||||
// Тот же phone у tenantB — НЕ должен считаться дублем.
|
||||
$payloadB = makePayload(vid: 922);
|
||||
$payloadB['phone'] = $phone;
|
||||
$payloadB['phones'] = [$phone];
|
||||
(new ProcessWebhookJob($tenantB->id, $payloadB))->handle();
|
||||
|
||||
$dealA = Deal::query()->where('tenant_id', $tenantA->id)->first();
|
||||
$dealB = Deal::query()->where('tenant_id', $tenantB->id)->first();
|
||||
|
||||
expect($dealA->duplicate_of_id)->toBeNull();
|
||||
expect($dealB->duplicate_of_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('Биз-19: ActivityLog для дубля содержит context.duplicate_of', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$phone = '79007770004';
|
||||
|
||||
$masterPayload = makePayload(vid: 931, time: now()->subHours(2)->timestamp);
|
||||
$masterPayload['phone'] = $phone;
|
||||
$masterPayload['phones'] = [$phone];
|
||||
(new ProcessWebhookJob($tenant->id, $masterPayload))->handle();
|
||||
|
||||
$dupPayload = makePayload(vid: 932, time: now()->timestamp);
|
||||
$dupPayload['phone'] = $phone;
|
||||
$dupPayload['phones'] = [$phone];
|
||||
(new ProcessWebhookJob($tenant->id, $dupPayload))->handle();
|
||||
|
||||
$master = Deal::query()->where('source_crm_id', 931)->first();
|
||||
$dup = Deal::query()->where('source_crm_id', 932)->first();
|
||||
|
||||
$masterLog = ActivityLog::query()->where('deal_id', $master->id)->first();
|
||||
$dupLog = ActivityLog::query()->where('deal_id', $dup->id)->first();
|
||||
|
||||
expect($masterLog->context)->toBe(['source' => 'webhook']);
|
||||
expect($dupLog->context)->toBe(['source' => 'webhook', 'duplicate_of' => $master->id]);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// failed() callback — финальная обработка после исчерпания ретраев
|
||||
// =============================================================================
|
||||
|
||||
test('failed() пишет упавший job в failed_webhook_jobs', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$webhookLogId = (int) DB::table('webhook_log')->insertGetId([
|
||||
'tenant_id' => $tenant->id,
|
||||
'raw_payload' => json_encode(['vid' => 1001]),
|
||||
'received_at' => now(),
|
||||
]);
|
||||
$payload = makePayload(vid: 1001);
|
||||
|
||||
$job = new ProcessWebhookJob($tenant->id, $payload, webhookLogId: $webhookLogId);
|
||||
$job->failed(new RuntimeException('boom: db down'));
|
||||
|
||||
$row = DB::table('failed_webhook_jobs')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->first();
|
||||
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->webhook_log_id)->toBe($webhookLogId);
|
||||
expect($row->exception)->toBe('boom: db down');
|
||||
expect($row->retry_count)->toBe(3);
|
||||
expect($row->resolved_at)->toBeNull();
|
||||
expect(json_decode($row->raw_payload, true)['vid'])->toBe(1001);
|
||||
});
|
||||
|
||||
test('failed() работает БЕЗ webhookLogId (NULL ok)', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
|
||||
$job = new ProcessWebhookJob($tenant->id, makePayload(vid: 1002));
|
||||
$job->failed(new RuntimeException('no webhook log id'));
|
||||
|
||||
$row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first();
|
||||
expect($row)->not->toBeNull();
|
||||
expect($row->webhook_log_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('failed() записывает payload с UTF-8 кириллицей корректно', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 10]);
|
||||
$payload = makePayload(vid: 1003);
|
||||
$payload['contact_name'] = 'Дмитрий Петров';
|
||||
|
||||
$job = new ProcessWebhookJob($tenant->id, $payload);
|
||||
$job->failed(new RuntimeException('utf-8 test'));
|
||||
|
||||
$row = DB::table('failed_webhook_jobs')->where('tenant_id', $tenant->id)->first();
|
||||
$decoded = json_decode($row->raw_payload, true);
|
||||
|
||||
expect($decoded['contact_name'])->toBe('Дмитрий Петров');
|
||||
});
|
||||
@@ -45,14 +45,12 @@ SQL);
|
||||
'subdomain' => 'rls-tenant-a-'.uniqid(),
|
||||
'organization_name' => 'RLS Tenant A',
|
||||
'contact_email' => 'a@rls-test.local',
|
||||
'webhook_token' => 'whtA'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT),
|
||||
'api_key_limit' => 5,
|
||||
]);
|
||||
$this->tenant2Id = DB::table('tenants')->insertGetId([
|
||||
'subdomain' => 'rls-tenant-b-'.uniqid(),
|
||||
'organization_name' => 'RLS Tenant B',
|
||||
'contact_email' => 'b@rls-test.local',
|
||||
'webhook_token' => 'whtB'.str_pad((string) random_int(0, 999999999), 60, '0', STR_PAD_LEFT),
|
||||
'api_key_limit' => 5,
|
||||
]);
|
||||
|
||||
|
||||
@@ -5,21 +5,25 @@ declare(strict_types=1);
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
/**
|
||||
* J2 (Sprint 3F) — стаб-гейт SaaS-admin зоны.
|
||||
* J2 (Sprint 3F) — гейт SaaS-admin зоны.
|
||||
*
|
||||
* EnsureSaasAdmin на /api/admin/*: dev/testing пропускает (admin-панель
|
||||
* работает на dev), прочие окружения — fail-closed 503 до подключения
|
||||
* реального Yandex 360 SSO (TODO под Б-1+DO-4).
|
||||
* EnsureSaasAdmin на /api/admin/*: пропускает запрос во ВСЕХ окружениях.
|
||||
* Защита боевой админ-зоны (/admin + /api/admin/*) перенесена на nginx
|
||||
* (HTTP Basic Auth, отдельный пароль — /etc/nginx/.htpasswd-admin), потому
|
||||
* что настоящий saas-admin SSO (Yandex 360) ещё не готов (Б-1 + DO-4).
|
||||
* Ранее middleware fail-closed 503 вне dev/testing — это закрывало всю
|
||||
* админку на проде наглухо; стопгэп заменил замок на nginx-дверь.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
test('/api/admin/* пропускается на testing-окружении (стаб permissive)', function () {
|
||||
// Дефолтное тестовое окружение = testing → middleware пропускает.
|
||||
test('/api/admin/* пропускается на testing-окружении', function () {
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(200);
|
||||
});
|
||||
|
||||
test('/api/admin/* возвращает 503 вне dev/testing (стаб fail-closed)', function () {
|
||||
test('/api/admin/* пропускается и на production (замок 503 снят, дверь держит nginx)', function () {
|
||||
$this->app->detectEnvironment(fn () => 'production');
|
||||
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(503);
|
||||
// Раньше тут был 503. Теперь приложение зону не закрывает — её держит
|
||||
// nginx basic-auth (стопгэп до реального Yandex 360 SSO).
|
||||
$this->getJson('/api/admin/tenants')->assertStatus(200);
|
||||
});
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
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;
|
||||
@@ -66,7 +65,6 @@ function runJob(int $leadId): void
|
||||
(new RouteSupplierLeadJob($leadId))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(DuplicateDetector::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
@@ -149,7 +147,7 @@ it('sharing-flow isolation: tenant A on zero paused, tenant B with balance recei
|
||||
// tenantA: balance_rub > 0 (проходит WHERE EXISTS-фильтр LeadRouter), но < tier_price (500 ₽).
|
||||
// Поэтому projectA попадает в matched, LedgerService падает с InsufficientBalanceException → auto-pause.
|
||||
$tenantA = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100.00']);
|
||||
$tenantB = Tenant::factory()->create(['balance_leads' => 5, 'balance_rub' => '0.00']);
|
||||
$tenantB = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$projectA = Project::factory()->create([
|
||||
'tenant_id' => $tenantA->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com',
|
||||
'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true,
|
||||
@@ -176,5 +174,5 @@ it('sharing-flow isolation: tenant A on zero paused, tenant B with balance recei
|
||||
|
||||
expect($projectA->fresh()->is_active)->toBeFalse();
|
||||
expect($projectB->fresh()->is_active)->toBeTrue();
|
||||
expect((int) $tenantB->fresh()->balance_leads)->toBe(4);
|
||||
expect((string) $tenantB->fresh()->balance_rub)->toBe('99500.00');
|
||||
});
|
||||
|
||||
@@ -257,3 +257,80 @@ it('SupplierTransientException — status=failed, error recorded, rethrown', fun
|
||||
expect($log->status)->toBe('failed');
|
||||
expect($log->error_message)->toContain('500');
|
||||
});
|
||||
|
||||
it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows → status=ok, unparseable_count=10', function (): void {
|
||||
// 100 нормальных webhook-лидов.
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
|
||||
'vid' => 840000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
}
|
||||
|
||||
// CSV: те же 100 (matched) + 10 строк с мусорным project (extractPlatform = null).
|
||||
// Это реальный паттерн поставщика — телефон в поле «Name» вместо проекта (см. 22.05 в ПИЛОТ).
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 10; $j++) {
|
||||
$rows[] = ['project' => '79135551234', 'phone' => '7999500000'.$j];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect((int) $log->total_csv_rows)->toBe(110);
|
||||
expect((int) $log->matched_count)->toBe(100);
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
expect((int) $log->unparseable_count)->toBe(10);
|
||||
// Реального missing'а нет — только junk; drift должен быть 0, не 10/110.
|
||||
expect((float) $log->drift_ratio)->toBe(0.0);
|
||||
expect($log->status)->toBe('ok');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void {
|
||||
for ($i = 0; $i < 95; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
|
||||
'vid' => 850000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 95; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 5; $j++) {
|
||||
$rows[] = ['project' => 'https://junk.example/'.$j, 'phone' => '7999600000'.$j];
|
||||
}
|
||||
for ($k = 0; $k < 3; $k++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect((int) $log->total_csv_rows)->toBe(103);
|
||||
expect((int) $log->matched_count)->toBe(95);
|
||||
expect((int) $log->recovered_count)->toBe(3);
|
||||
expect((int) $log->unparseable_count)->toBe(5);
|
||||
// real_missing = (103 - 95) - 5 = 3; parseable_total = 103 - 5 = 98; drift = 3/98 ≈ 0.0306 < 5% → ok.
|
||||
expect((float) $log->drift_ratio)->toBeLessThan(0.05);
|
||||
expect((float) $log->drift_ratio)->toBeGreaterThan(0.0);
|
||||
expect($log->status)->toBe('ok');
|
||||
});
|
||||
|
||||
@@ -12,7 +12,6 @@ use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
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;
|
||||
@@ -90,7 +89,6 @@ function dispatchJob(int $supplierLeadId): void
|
||||
(new RouteSupplierLeadJob($supplierLeadId))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(DuplicateDetector::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
@@ -98,27 +96,6 @@ function dispatchJob(int $supplierLeadId): void
|
||||
);
|
||||
}
|
||||
|
||||
it('charges prepaid for tenant with balance_leads > 0 + writes BalanceTransaction', function (): void {
|
||||
$ctx = prepareSharingFlow(1, [['balance_leads' => 5, 'balance_rub' => '0.00', 'delivered_in_month' => 0]]);
|
||||
|
||||
dispatchJob($ctx['lead']->id);
|
||||
|
||||
$tenant = $ctx['tenants'][0]->fresh();
|
||||
expect((int) $tenant->balance_leads)->toBe(4);
|
||||
expect($tenant->delivered_in_month)->toBe(1);
|
||||
|
||||
$charge = LeadCharge::first();
|
||||
expect($charge)->not->toBeNull();
|
||||
expect($charge->charge_source)->toBe('prepaid');
|
||||
expect($charge->price_per_lead_kopecks)->toBe(0);
|
||||
|
||||
// BalanceTransaction (carry-over M-2 assertion)
|
||||
$tx = BalanceTransaction::where('type', BalanceTransaction::TYPE_LEAD_CHARGE)->first();
|
||||
expect($tx)->not->toBeNull();
|
||||
expect((int) $tx->amount_leads)->toBe(-1);
|
||||
expect((int) $tx->balance_leads_after)->toBe(4);
|
||||
});
|
||||
|
||||
it('charges rub for tenant with balance_leads=0 and balance_rub >= price + writes BalanceTransaction', function (): void {
|
||||
$ctx = prepareSharingFlow(1, [['balance_leads' => 0, 'balance_rub' => '1000.00', 'delivered_in_month' => 0]]);
|
||||
|
||||
@@ -156,13 +133,15 @@ it('writes supplier_lead_costs for each delivered deal copy (gap-fix)', function
|
||||
});
|
||||
|
||||
it('retry idempotency: повторный run не дублирует lead_charges', function (): void {
|
||||
$ctx = prepareSharingFlow(1, [['balance_leads' => 5, 'balance_rub' => '0.00']]);
|
||||
$ctx = prepareSharingFlow(1, [['balance_rub' => '100000.00']]);
|
||||
$leadId = $ctx['lead']->id;
|
||||
$tenantId = $ctx['tenants'][0]->id;
|
||||
|
||||
dispatchJob($leadId);
|
||||
dispatchJob($leadId); // повторный — processed_at guard защищает
|
||||
|
||||
expect(LeadCharge::count())->toBe(1);
|
||||
expect(Deal::count())->toBe(1);
|
||||
expect((int) $ctx['tenants'][0]->fresh()->balance_leads)->toBe(4);
|
||||
expect(LeadCharge::where('tenant_id', $tenantId)->count())->toBe(1);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenantId}'");
|
||||
expect(Deal::where('tenant_id', $tenantId)->count())->toBe(1);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Random\Engine\Mt19937;
|
||||
use Random\Randomizer;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
function runRouteJobB(int $id): void
|
||||
{
|
||||
(new RouteSupplierLeadJob($id))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
it('supplier_lead_deliveries table exists with PK (supplier_lead_id, tenant_id) and RLS', function (): void {
|
||||
$cols = collect(DB::select(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_lead_deliveries'"
|
||||
))->pluck('column_name')->all();
|
||||
expect($cols)->toContain('supplier_lead_id')
|
||||
->toContain('tenant_id')
|
||||
->toContain('deal_id')
|
||||
->toContain('created_at');
|
||||
|
||||
$pk = collect(DB::select(
|
||||
"SELECT a.attname FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = 'supplier_lead_deliveries'::regclass AND i.indisprimary"
|
||||
))->pluck('attname')->sort()->values()->all();
|
||||
expect($pk)->toBe(['supplier_lead_id', 'tenant_id']);
|
||||
|
||||
$rls = DB::selectOne(
|
||||
"SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_lead_deliveries'"
|
||||
);
|
||||
expect($rls->relrowsecurity)->toBeTrue();
|
||||
});
|
||||
|
||||
it('one delivery to a tenant with 2 eligible projects → exactly 1 deal + 1 charge (max-remaining-limit tie-break)', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twoproj.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
|
||||
// Two eligible projects for the SAME tenant, different remaining limit.
|
||||
$pLow = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'signal_type' => 'site', 'signal_identifier' => 'twoproj.ru',
|
||||
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
||||
'delivered_today' => 9, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
||||
]);
|
||||
$pHigh = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'signal_type' => 'site', 'signal_identifier' => 'twoproj.ru',
|
||||
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
||||
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
||||
]);
|
||||
linkProjectToSupplier($pLow, $sp);
|
||||
linkProjectToSupplier($pHigh, $sp);
|
||||
|
||||
$vid = 600001;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twoproj.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
|
||||
runRouteJobB($lead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
|
||||
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
// The project with most remaining limit was chosen.
|
||||
expect($pHigh->fresh()->delivered_today)->toBe(1);
|
||||
expect($pLow->fresh()->delivered_today)->toBe(9);
|
||||
});
|
||||
|
||||
it('lock: re-running same delivery to same tenant does not double-charge (Spec B)', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'lock.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'signal_type' => 'site', 'signal_identifier' => 'lock.ru',
|
||||
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
||||
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
||||
]);
|
||||
linkProjectToSupplier($p, $sp);
|
||||
|
||||
$vid = 610001;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_lock.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
|
||||
runRouteJobB($lead->id);
|
||||
|
||||
// Reset processed_at to force a SECOND pass (bypass the existing $lead->processed_at idempotency
|
||||
// guard so we are testing the DB-level lock specifically).
|
||||
$lead->update(['processed_at' => null]);
|
||||
runRouteJobB($lead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
|
||||
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
expect(DB::table('supplier_lead_deliveries')
|
||||
->where('supplier_lead_id', $lead->id)->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
// Balance debited exactly once.
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('99500.00');
|
||||
});
|
||||
|
||||
it('same phone, two different deliveries to one tenant → both charged (no phone dedup, Spec B)', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twohit.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'signal_type' => 'site', 'signal_identifier' => 'twohit.ru',
|
||||
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
||||
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
||||
]);
|
||||
linkProjectToSupplier($p, $sp);
|
||||
|
||||
foreach ([700001, 700002] as $vid) {
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twohit.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
runRouteJobB($lead->id);
|
||||
}
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
expect(Deal::query()->where('tenant_id', $tenant->id)->whereIn('source_crm_id', [700001, 700002])->count())->toBe(2);
|
||||
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('99000.00');
|
||||
});
|
||||
|
||||
it('cap = 3 distinct tenants: 5 eligible tenants → exactly 3 charged (Spec B)', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
app()->bind(LeadDistributor::class, fn () => new LeadDistributor(new Randomizer(new Mt19937(7))));
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap3.ru',
|
||||
]);
|
||||
foreach (range(1, 5) as $i) {
|
||||
$t = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $t->id, 'is_active' => true,
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'signal_type' => 'site', 'signal_identifier' => 'cap3.ru',
|
||||
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
||||
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
||||
]);
|
||||
linkProjectToSupplier($p, $sp);
|
||||
}
|
||||
|
||||
$vid = 710001;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_cap3.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
|
||||
runRouteJobB($lead->id);
|
||||
|
||||
$lead->refresh();
|
||||
expect($lead->deals_created_count)->toBe(3);
|
||||
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->count())->toBe(3);
|
||||
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->distinct()->count('tenant_id'))->toBe(3);
|
||||
});
|
||||
@@ -1,286 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\ProcessWebhookJob;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'webhook_token' => 'whk_test_'.bin2hex(random_bytes(8)),
|
||||
'balance_leads' => 100,
|
||||
]);
|
||||
// Чистим RateLimiter между тестами — иначе lockout из одного теста
|
||||
// загрязняет следующий.
|
||||
RateLimiter::clear("webhook:{$this->tenant->id}");
|
||||
|
||||
// Audit-fix B3: дефолт isHmacRequired() изменён на true. Тесты, проверяющие
|
||||
// НЕ-HMAC аспекты (payload-валидация, rate-limit, CSRF), явно ставят флаг в
|
||||
// false — иначе запрос без подписи получит 401 ещё до этих проверок.
|
||||
SystemSetting::firstOrCreate(
|
||||
['key' => 'webhook_hmac_required'],
|
||||
['value' => 'false', 'type' => 'bool', 'description' => 'test default', 'updated_at' => now()],
|
||||
);
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']);
|
||||
});
|
||||
|
||||
test('POST /api/webhook/{token} с валидным payload возвращает 202 + dispatch ProcessWebhookJob', function () {
|
||||
Bus::fake();
|
||||
|
||||
$payload = [
|
||||
'vid' => 12345,
|
||||
'project' => 'Натяжные потолки',
|
||||
'phone' => '+7 (999) 123-45-67',
|
||||
'time' => time(),
|
||||
'tag' => 'ya_direct',
|
||||
];
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
|
||||
|
||||
$r->assertStatus(202);
|
||||
expect($r->json('status'))->toBe('accepted');
|
||||
expect($r->json('tenant_id'))->toBe($this->tenant->id);
|
||||
|
||||
Bus::assertDispatched(ProcessWebhookJob::class, function ($job) use ($payload) {
|
||||
return $job->tenantId === $this->tenant->id
|
||||
&& $job->data['vid'] === $payload['vid']
|
||||
&& $job->data['phone'] === $payload['phone'];
|
||||
});
|
||||
});
|
||||
|
||||
test('POST с unknown token → 404', function () {
|
||||
Bus::fake();
|
||||
$r = $this->postJson('/api/webhook/whk_nonexistent_token_12345', [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(404);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('POST без обязательных полей → 422', function () {
|
||||
Bus::fake();
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
// Нет vid/project/phone/time
|
||||
]);
|
||||
|
||||
$r->assertStatus(422);
|
||||
$errors = $r->json('errors');
|
||||
expect($errors)->toHaveKeys(['vid', 'project', 'phone', 'time']);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('POST с вредной структурой (vid=строка, time=отрицательный) → 422', function () {
|
||||
Bus::fake();
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 'не-число',
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => -1,
|
||||
]);
|
||||
|
||||
$r->assertStatus(422);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('POST к webhook НЕ требует CSRF (внешний клиент)', function () {
|
||||
Bus::fake();
|
||||
// Симулируем запрос БЕЗ X-XSRF-TOKEN — CSRF middleware не должен проверять
|
||||
// /api/webhook/* (см. bootstrap/app.php validateCsrfTokens except).
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(202);
|
||||
});
|
||||
|
||||
test('POST с `phones` array (multi-phone payload) принимается', function () {
|
||||
Bus::fake();
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 1,
|
||||
'project' => 'Окна',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'phones' => ['+7 (999) 000-00-01', '+7 (999) 000-00-02'],
|
||||
'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(202);
|
||||
Bus::assertDispatched(ProcessWebhookJob::class, function ($job) {
|
||||
return is_array($job->data['phones']) && count($job->data['phones']) === 2;
|
||||
});
|
||||
});
|
||||
|
||||
test('HMAC: валидная подпись sha256=hex(hmac_sha256(body, token)) проходит', function () {
|
||||
Bus::fake();
|
||||
$payload = [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
];
|
||||
$rawBody = json_encode($payload);
|
||||
$signature = 'sha256='.hash_hmac('sha256', $rawBody, $this->tenant->webhook_token);
|
||||
|
||||
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_ACCEPT' => 'application/json',
|
||||
'HTTP_X_WEBHOOK_SIGNATURE' => $signature,
|
||||
], $rawBody);
|
||||
|
||||
$r->assertStatus(202);
|
||||
Bus::assertDispatched(ProcessWebhookJob::class);
|
||||
});
|
||||
|
||||
test('HMAC: невалидная подпись → 401, dispatch НЕ происходит', function () {
|
||||
Bus::fake();
|
||||
$payload = [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
];
|
||||
$rawBody = json_encode($payload);
|
||||
|
||||
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_ACCEPT' => 'application/json',
|
||||
'HTTP_X_WEBHOOK_SIGNATURE' => 'sha256=deadbeef'.str_repeat('0', 56),
|
||||
], $rawBody);
|
||||
|
||||
$r->assertStatus(401);
|
||||
expect($r->json('message'))->toContain('HMAC');
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('HMAC: настройка отсутствует → HMAC обязателен по умолчанию (B3) → 401', function () {
|
||||
Bus::fake();
|
||||
// Audit-fix B3: code-default isHmacRequired() = true. Удаляем настройку,
|
||||
// чтобы проверить именно отсутствие ключа в system_settings.
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->delete();
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(401);
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('rate-limit: системный лимит RPS×60 в минуту, 429 + Retry-After на превышении', function () {
|
||||
Bus::fake();
|
||||
// Устанавливаем низкий лимит через system_settings — иначе тест слишком долгий
|
||||
// (default 100 RPS = 6000/мин). Подменяем через update.
|
||||
SystemSetting::where('key', 'webhook_rate_limit_rps')->update(['value' => '1']);
|
||||
|
||||
$payload = [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
];
|
||||
|
||||
// 1 RPS × 60 = 60 запросов/мин. Делаем 60 успешных.
|
||||
for ($i = 0; $i < 60; $i++) {
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
|
||||
$r->assertStatus(202);
|
||||
}
|
||||
|
||||
// 61-й — превышение.
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload);
|
||||
$r->assertStatus(429);
|
||||
expect($r->json('retry_after'))->toBeInt()->toBeGreaterThan(0);
|
||||
expect($r->headers->get('Retry-After'))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('webhook_hmac_required=true: запрос без X-Webhook-Signature → 401', function () {
|
||||
Bus::fake();
|
||||
SystemSetting::firstOrCreate(
|
||||
['key' => 'webhook_hmac_required'],
|
||||
['value' => 'true', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
|
||||
);
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'true']);
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 1,
|
||||
'project' => 'X',
|
||||
'phone' => '+7 (999) 000-00-00',
|
||||
'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(401);
|
||||
expect($r->json('message'))->toContain('требуется');
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
test('webhook_hmac_required=true: с валидной HMAC-подписью → 202', function () {
|
||||
Bus::fake();
|
||||
SystemSetting::firstOrCreate(
|
||||
['key' => 'webhook_hmac_required'],
|
||||
['value' => 'true', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
|
||||
);
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'true']);
|
||||
|
||||
$payload = [
|
||||
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
|
||||
];
|
||||
$rawBody = json_encode($payload);
|
||||
$signature = 'sha256='.hash_hmac('sha256', $rawBody, $this->tenant->webhook_token);
|
||||
|
||||
$r = $this->call('POST', "/api/webhook/{$this->tenant->webhook_token}", [], [], [], [
|
||||
'CONTENT_TYPE' => 'application/json',
|
||||
'HTTP_ACCEPT' => 'application/json',
|
||||
'HTTP_X_WEBHOOK_SIGNATURE' => $signature,
|
||||
], $rawBody);
|
||||
|
||||
$r->assertStatus(202);
|
||||
});
|
||||
|
||||
test('webhook_hmac_required=false: header опционален → 202 без подписи', function () {
|
||||
Bus::fake();
|
||||
SystemSetting::firstOrCreate(
|
||||
['key' => 'webhook_hmac_required'],
|
||||
['value' => 'false', 'type' => 'bool', 'description' => 'тест', 'updated_at' => now()],
|
||||
);
|
||||
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']);
|
||||
|
||||
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
|
||||
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
|
||||
]);
|
||||
$r->assertStatus(202);
|
||||
});
|
||||
|
||||
test('rate-limit: ключ изолирован per-token (другой tenant не блокирует)', function () {
|
||||
Bus::fake();
|
||||
SystemSetting::where('key', 'webhook_rate_limit_rps')->update(['value' => '1']);
|
||||
|
||||
$tenantOther = Tenant::factory()->create([
|
||||
'webhook_token' => 'whk_other_'.bin2hex(random_bytes(8)),
|
||||
]);
|
||||
RateLimiter::clear("webhook:{$tenantOther->id}");
|
||||
|
||||
$payload = [
|
||||
'vid' => 1, 'project' => 'X', 'phone' => '+7 (999) 000-00-00', 'time' => time(),
|
||||
];
|
||||
|
||||
// Заполняем лимит первого tenant'а
|
||||
for ($i = 0; $i < 60; $i++) {
|
||||
$this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload)->assertStatus(202);
|
||||
}
|
||||
$this->postJson("/api/webhook/{$this->tenant->webhook_token}", $payload)->assertStatus(429);
|
||||
|
||||
// Второй tenant — без проблем.
|
||||
$r = $this->postJson("/api/webhook/{$tenantOther->webhook_token}", $payload);
|
||||
$r->assertStatus(202);
|
||||
});
|
||||
@@ -41,8 +41,8 @@ describe('AdminTenantsView.vue', () => {
|
||||
const wrapper = mount(AdminTenantsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
// ImpersonationDialog stubим — внутри использует api/admin axios.
|
||||
stubs: { ImpersonationDialog: true },
|
||||
// ImpersonationDialog + TenantBalanceDialog stubим — внутри используют api/admin axios.
|
||||
stubs: { ImpersonationDialog: true, TenantBalanceDialog: true },
|
||||
},
|
||||
});
|
||||
await flushPromises();
|
||||
|
||||
@@ -56,7 +56,7 @@ const mountView = async () => {
|
||||
return mount(AdminTenantsView, {
|
||||
global: {
|
||||
plugins: [createVuetify(), router],
|
||||
stubs: { ImpersonationDialog: true },
|
||||
stubs: { ImpersonationDialog: true, TenantBalanceDialog: true },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
import TenantBalanceDialog from '../../resources/js/components/admin/TenantBalanceDialog.vue';
|
||||
import * as adminApi from '../../resources/js/api/admin';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function mountDialog(props: Record<string, unknown> = {}) {
|
||||
return mount(TenantBalanceDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
tenantId: 42,
|
||||
tenantName: 'Окна Москва ООО',
|
||||
currentBalanceRub: 1000,
|
||||
...props,
|
||||
},
|
||||
global: { plugins: [vuetify] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
describe('TenantBalanceDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('previews signed delta when new balance entered', async () => {
|
||||
const w = mountDialog();
|
||||
(w.vm as unknown as { newBalance: string }).newBalance = '2500';
|
||||
await w.vm.$nextTick();
|
||||
expect((w.vm as unknown as { delta: string }).delta).toBe('1500.00');
|
||||
});
|
||||
|
||||
it('disables save when balance empty or unchanged', async () => {
|
||||
const w = mountDialog();
|
||||
const vm = w.vm as unknown as { newBalance: string; canSave: boolean };
|
||||
vm.newBalance = '';
|
||||
await w.vm.$nextTick();
|
||||
expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(false);
|
||||
vm.newBalance = '1000';
|
||||
await w.vm.$nextTick();
|
||||
expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(false);
|
||||
vm.newBalance = '1500';
|
||||
await w.vm.$nextTick();
|
||||
expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(true);
|
||||
});
|
||||
|
||||
it('calls updateTenantBalance with normalized payload and emits saved', async () => {
|
||||
const spy = vi.spyOn(adminApi, 'updateTenantBalance').mockResolvedValue({
|
||||
id: 42,
|
||||
balance_rub: '2500.00',
|
||||
delta: '1500.00',
|
||||
transaction_id: 7,
|
||||
});
|
||||
const w = mountDialog();
|
||||
const vm = w.vm as unknown as { newBalance: string; reason: string; submit: () => Promise<void> };
|
||||
vm.newBalance = '2500';
|
||||
vm.reason = 'тест';
|
||||
await vm.submit();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(42, { balance_rub: '2500.00', reason: 'тест' });
|
||||
expect(w.emitted('saved')).toBeTruthy();
|
||||
expect(w.emitted('saved')![0][0]).toMatchObject({ balance_rub: '2500.00' });
|
||||
});
|
||||
});
|
||||
@@ -1710,3 +1710,48 @@ FNS
|
||||
# Hole #2 partitioning (23.05.2026)
|
||||
партиционировать
|
||||
дёшева
|
||||
|
||||
# Controller-offload agents spec (24.05.2026)
|
||||
синков
|
||||
эскалировать
|
||||
эскалирует
|
||||
митигации
|
||||
Версионная
|
||||
квирках
|
||||
NTFS
|
||||
маппинге
|
||||
dogfooded
|
||||
|
||||
# Controller-offload agents level 1+2 (24.05.2026)
|
||||
бинари
|
||||
ребейзе
|
||||
dok
|
||||
аддон
|
||||
синкни
|
||||
фейл
|
||||
пинуются
|
||||
маунт
|
||||
pgrep
|
||||
захардкоженной
|
||||
ребейза
|
||||
токену
|
||||
тултип
|
||||
|
||||
# Билинг v2 Спек C (25.05.2026)
|
||||
Atol
|
||||
uniqid
|
||||
ОФД
|
||||
брейнсторме
|
||||
префлайт
|
||||
Префлайт
|
||||
скоупа
|
||||
unreviewed
|
||||
|
||||
# admin-zone nginx-gate + drift-fix (25.05.2026 день+1)
|
||||
стопгэп
|
||||
досылает
|
||||
creds
|
||||
опкэш
|
||||
гэп
|
||||
misowned
|
||||
деплоями
|
||||
|
||||
@@ -140,3 +140,34 @@ ALTER DEFAULT PRIVILEGES IN SCHEMA public
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON TABLE supplier_csv_reconcile_log TO crm_supplier_worker;
|
||||
GRANT USAGE, SELECT ON SEQUENCE supplier_csv_reconcile_log_id_seq TO crm_supplier_worker;
|
||||
|
||||
-- =============================================================================
|
||||
-- 23.05.2026 (post hole #2): partition-maintenance privilege model
|
||||
-- =============================================================================
|
||||
-- `partitions:create-months` / `partitions:drop-expired` теперь идут через
|
||||
-- `pgsql_supplier` connection (MonthlyPartitionManager::DDL_CONNECTION). Чтобы
|
||||
-- `crm_supplier_worker` мог CREATE/DROP партиции партиционированных родителей,
|
||||
-- нужны два условия:
|
||||
-- 1. Единый владелец всех 9 партиционированных родителей — `crm_migrator`.
|
||||
-- По умолчанию `schema.sql` создаёт audit-таблицы под `postgres` (load
|
||||
-- выполняется суперпользователем при `migrate:fresh`); явный ALTER OWNER
|
||||
-- ниже выравнивает прод (где это уже сделано вживую 23.05.2026) и
|
||||
-- гарантирует, что fresh-deploy не разойдётся.
|
||||
-- 2. `crm_supplier_worker` — член `crm_migrator` (INHERIT TRUE), чтобы
|
||||
-- ownership-операции (CREATE TABLE ... PARTITION OF, DROP TABLE) проходили
|
||||
-- проверку владельца. Web-роль `crm_app_user` остаётся least-privilege —
|
||||
-- она НЕ получает crm_migrator-членство и НЕ может делать partition DDL.
|
||||
--
|
||||
-- Идемпотентно — повторный запуск 02_grants.sql после первого применения
|
||||
-- безопасен.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE auth_log OWNER TO crm_migrator;
|
||||
ALTER TABLE activity_log OWNER TO crm_migrator;
|
||||
ALTER TABLE tenant_operations_log OWNER TO crm_migrator;
|
||||
ALTER TABLE webhook_log OWNER TO crm_migrator;
|
||||
ALTER TABLE balance_transactions OWNER TO crm_migrator;
|
||||
ALTER TABLE pd_processing_log OWNER TO crm_migrator;
|
||||
ALTER TABLE saas_admin_audit_log OWNER TO crm_migrator;
|
||||
|
||||
GRANT crm_migrator TO crm_supplier_worker WITH INHERIT TRUE;
|
||||
|
||||
+71
-2
@@ -1,8 +1,77 @@
|
||||
# CHANGELOG schema.sql — Лидерра
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать девять записей в обратном хронологическом порядке (v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.32, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.36, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
## v8.36 (2026-05-25) — supplier_csv_reconcile_log.unparseable_count: drift-формула без junk-строк
|
||||
|
||||
Поставщик `crm.bp-gr.ru` периодически кладёт телефон/URL в поле «project» CSV-выгрузки
|
||||
«Запрос номеров». Парсер `CsvReconcileJob` корректно их скипает (`extractPlatform()` → `null`),
|
||||
но раньше эти строки попадали и в числитель `count($missing)`, и в знаменатель `total_csv_rows`
|
||||
формулы drift'а → стабильный false-positive `drift_alert` ~40-50% при каждом hourly-запуске
|
||||
(на проде 10 запусков подряд → admin-блок «Здоровье резервного канала» показывал «down»).
|
||||
|
||||
**Добавлено:**
|
||||
|
||||
- **Колонка `supplier_csv_reconcile_log.unparseable_count` INTEGER NOT NULL DEFAULT 0** — кол-во
|
||||
CSV-строк за окно, у которых `project` не парсится в платформу B1/B2/B3.
|
||||
|
||||
**Изменено:**
|
||||
|
||||
- `CsvReconcileJob`: считает `$unparseableCount` отдельно, новая формула
|
||||
`drift_ratio = max(0, missing − unparseable) / max(1, total − unparseable)` —
|
||||
только «реальные» пропуски от parseable-строк, без вклада junk'а.
|
||||
|
||||
**Метрики:** +1 колонка. (Сверять с header `db/schema.sql`.) Таблиц / индексов / RLS — без изменений.
|
||||
|
||||
**Миграция:** `2026_05_25_100000_add_unparseable_count_to_supplier_csv_reconcile_log` (idempotent
|
||||
`ADD COLUMN IF NOT EXISTS` на `pgsql_supplier` connection — Спек B pattern).
|
||||
|
||||
**Тесты:** `app/tests/Feature/Supplier/CsvReconcileJobTest.php` — +2 кейса (100 matched +
|
||||
10 junk → status=ok / mixed 95+5junk+3real → drift по реальным). Существующие 7 кейсов — без изменений (drift при unparseable=0 идентичен старой формуле).
|
||||
|
||||
## v8.35 (2026-05-24) — legacy direct webhook removal
|
||||
|
||||
Финальная уборка прямого webhook-канала (тенант → Лидерра). Вся инфраструктура канала
|
||||
упразднена; CSV-канал (поставщик → Лидерра) сохранён полностью.
|
||||
|
||||
**Удалено:**
|
||||
|
||||
- **Таблица `webhook_log`** (partitioned RANGE по `received_at`) + все дочерние партиции (DROP CASCADE).
|
||||
Хранила payload входящих webhook от тенантов. Канал прямого приёма упразднён.
|
||||
- **Таблица `rejected_deals_log`** (регулярная) — журнал отвергнутых лидов прямого webhook-канала.
|
||||
- **Колонки `tenants.webhook_token` + `tenants.webhook_token_rotated_at`** — токен аутентификации
|
||||
прямого webhook. Индекс `idx_tenants_webhook_token` удалён вместе с колонкой.
|
||||
- **Seed-строка `low_balance_threshold_leads`** в `system_settings` — использовалась только
|
||||
удалённым `LowBalanceNotification` mailable'ом.
|
||||
- **Seed-строки `webhook_log_retention_days` + `webhook_log_retention_months`** в `system_settings`.
|
||||
|
||||
**Оставлено (НЕ удалено):**
|
||||
|
||||
- **`webhook_dedup_keys`** — используется CSV-каналом (`HistoricalImportService`) для идемпотентности.
|
||||
- **`failed_webhook_jobs.webhook_log_id`** — orphan BIGINT (без FK с v8.31/W1); оставлен.
|
||||
- **`outbound_webhook_subscriptions` + `outbound_webhook_deliveries`** — исходящий webhook (тенант → внешний URL); не затронут.
|
||||
|
||||
**Метрики:** −2 таблицы / −5 индексов / −2 RLS-политики.
|
||||
66 base tables (65 regular + 8 partitioned parents) / 120 indexes / 40 RLS policies.
|
||||
|
||||
**Миграция:** `2026_05_24_140000_drop_legacy_webhook_artefacts`
|
||||
|
||||
**Связанные изменения кода:**
|
||||
|
||||
- `MonthlyPartitionManager::PARTITIONED_TABLES` — убрана строка `webhook_log`
|
||||
- `PdErasureService::eraseSubject()` — убрана секция erasure по `webhook_log`
|
||||
|
||||
## v8.34 (2026-05-23) — Billing v2 Spec B: drop deals(duplicate_of_id) index
|
||||
|
||||
- **−индекс `deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL`** — телефонный дедуп удалён (Spec B), индекс больше не используется. Колонка `deals.duplicate_of_id` оставлена спящей (drop отдельной задачей).
|
||||
- Метрики: −1 индекс. (Сверять с header `db/schema.sql`.)
|
||||
|
||||
## v8.33 (2026-05-23) — Billing v2 Spec B: политика дублей (Phase 1)
|
||||
|
||||
- **+таблица `supplier_lead_deliveries`** (PK `supplier_lead_id`+`tenant_id`, FK на `supplier_leads` ON DELETE CASCADE, `deal_id` без FK — `deals` партиционирована, RLS `tenant_isolation`). Замок «одна поставка одному клиенту = один оплаченный лид» для шеринг-пути (`RouteSupplierLeadJob`). INSERT-логика будет добавлена в следующем коммите.
|
||||
- Метрики: +1 таблица, +1 RLS-политика. (Сверять с header `db/schema.sql`.)
|
||||
|
||||
**История записей:**
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- =============================================================================
|
||||
-- supplier_lead_deliveries — замок «одна поставка одному клиенту = один раз»
|
||||
-- (Billing v2 Spec B). Ключ по поставке (supplier_lead_id), НЕ по телефону —
|
||||
-- разные поставки с одним телефоном остаются отдельными платными лидами.
|
||||
-- Защищает шеринг-путь (RouteSupplierLeadJob) от наших собственных дублей
|
||||
-- при гонках / перезапусках задачи / CSV-восстановлении.
|
||||
-- =============================================================================
|
||||
CREATE TABLE supplier_lead_deliveries (
|
||||
supplier_lead_id BIGINT NOT NULL REFERENCES supplier_leads(id) ON DELETE CASCADE,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
deal_id BIGINT, -- созданная сделка; без FK (deals партиционирована)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (supplier_lead_id, tenant_id)
|
||||
);
|
||||
|
||||
ALTER TABLE supplier_lead_deliveries ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON supplier_lead_deliveries
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
|
||||
-- Явные GRANT'ы для 4 ролей: на prod таблица создаётся crm_supplier_worker
|
||||
-- (default privileges от postgres-superuser не наследуются на чужие creator-role).
|
||||
-- Mirror webhook_dedup_keys grant pattern. DO block — idempotent + dev-safe
|
||||
-- (на dev ролей нет → silent skip).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_app_user;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_admin_user;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_supplier_worker;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
||||
GRANT ALL PRIVILEGES ON supplier_lead_deliveries TO crm_migrator;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Индекс по deals(duplicate_of_id) больше не нужен — телефонный дедуп удалён (Billing v2 Spec B).
|
||||
-- Колонка `deals.duplicate_of_id` оставлена спящей (drop отдельной задачей, mirrors Spec A balance_leads two-phase).
|
||||
DROP INDEX IF EXISTS deals_duplicate_of_id_idx;
|
||||
@@ -0,0 +1,748 @@
|
||||
-- =============================================================================
|
||||
-- Hole #2: Partition 7 audit tables by RANGE month
|
||||
-- Source of truth for columns/triggers/indexes: /tmp/prod-audit-ddl.sql (pg_dump --schema-only from prod)
|
||||
-- Tested on: liderra_rehearsal (restored from prod.dump)
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Strategy per table:
|
||||
-- 1. RENAME old → _old
|
||||
-- 2. DISABLE TRIGGER ALL on _old
|
||||
-- 3. RENAME old indexes (free prod index names)
|
||||
-- 4. CREATE TABLE partitioned (exact prod columns, no FK refs to avoid cascade issues)
|
||||
-- 5. 6 month partitions 2026_02..2026_07 + DEFAULT
|
||||
-- 6. CREATE indexes (prod-exact names and definitions)
|
||||
-- 7. RLS (exact prod policies named tenant_isolation; saas_admin_audit_log: no RLS)
|
||||
-- 8. chain_hash trigger BEFORE INSERT (6 tables; webhook_log: no triggers)
|
||||
-- 9. INSERT SELECT ORDER BY id (trigger recalculates log_hash per-partition)
|
||||
-- 10. New sequence OWNED BY new table; setval to MAX(id)
|
||||
-- 11. GRANTs (prod-exact)
|
||||
-- 12. block_mutation trigger BEFORE DELETE/UPDATE (after data copy)
|
||||
-- 13. DROP _old
|
||||
--
|
||||
-- Retention seeds + migration row.
|
||||
-- =============================================================================
|
||||
|
||||
BEGIN;
|
||||
SET LOCAL timezone = 'UTC';
|
||||
SET LOCAL statement_timeout = '0';
|
||||
-- session_replication_role=replica отключает user-триггеры (chain_hash) на время
|
||||
-- копирования. Иначе при INSERT под postgres (BYPASSRLS) триггер пересчитал бы
|
||||
-- log_hash как GLOBAL-within-partition, что НЕ совпадает с per-tenant chain,
|
||||
-- построенным на проде под crm_app_user (RLS). Сохраняем исходные log_hash as-is:
|
||||
-- вся история каждого tenant сейчас в партиции 2026_05, поэтому per-partition
|
||||
-- validator увидит исходную цепочку без разрывов. Сбрасывается в конце транзакции.
|
||||
SET LOCAL session_replication_role = replica;
|
||||
|
||||
-- =============================================================================
|
||||
-- DROP FK constraints pointing at webhook_log (decision W1)
|
||||
-- =============================================================================
|
||||
ALTER TABLE public.failed_webhook_jobs
|
||||
DROP CONSTRAINT IF EXISTS failed_webhook_jobs_webhook_log_id_fkey;
|
||||
ALTER TABLE public.rejected_deals_log
|
||||
DROP CONSTRAINT IF EXISTS rejected_deals_log_webhook_log_id_fkey;
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. activity_log
|
||||
-- Prod columns: id/tenant_id(NOT NULL)/user_id/deal_id(NOT NULL)/event(varchar100)/
|
||||
-- old_value/new_value/context(jsonb)/ip_address/user_agent/log_hash/created_at
|
||||
-- Triggers: trg_audit_chain_hash_activity_log + trg_audit_block_mut_activity_log
|
||||
-- RLS: tenant_isolation (simple tenant_id)
|
||||
-- Indexes: idx_activity_tenant_deal_created / idx_activity_tenant_user_created
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.activity_log RENAME TO activity_log_old;
|
||||
ALTER TABLE public.activity_log_old RENAME CONSTRAINT activity_log_pkey TO activity_log_pkey_old;
|
||||
ALTER SEQUENCE public.activity_log_id_seq RENAME TO activity_log_id_seq_old;
|
||||
ALTER TABLE public.activity_log_old DISABLE TRIGGER ALL;
|
||||
ALTER INDEX public.idx_activity_tenant_deal_created RENAME TO idx_activity_tenant_deal_created_old;
|
||||
ALTER INDEX public.idx_activity_tenant_user_created RENAME TO idx_activity_tenant_user_created_old;
|
||||
|
||||
CREATE TABLE public.activity_log (
|
||||
id bigint NOT NULL,
|
||||
tenant_id bigint NOT NULL,
|
||||
user_id bigint,
|
||||
deal_id bigint NOT NULL,
|
||||
event character varying(100) NOT NULL,
|
||||
old_value text,
|
||||
new_value text,
|
||||
context jsonb,
|
||||
ip_address inet,
|
||||
user_agent text,
|
||||
log_hash bytea,
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE public.activity_log_y2026_m02 PARTITION OF public.activity_log
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
CREATE TABLE public.activity_log_y2026_m03 PARTITION OF public.activity_log
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
CREATE TABLE public.activity_log_y2026_m04 PARTITION OF public.activity_log
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE public.activity_log_y2026_m05 PARTITION OF public.activity_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE public.activity_log_y2026_m06 PARTITION OF public.activity_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE public.activity_log_y2026_m07 PARTITION OF public.activity_log
|
||||
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||||
CREATE TABLE public.activity_log_default PARTITION OF public.activity_log DEFAULT;
|
||||
|
||||
ALTER TABLE public.activity_log ADD CONSTRAINT activity_log_pkey PRIMARY KEY (id, created_at);
|
||||
|
||||
CREATE INDEX idx_activity_tenant_deal_created ON public.activity_log
|
||||
USING btree (tenant_id, deal_id, created_at DESC);
|
||||
CREATE INDEX idx_activity_tenant_user_created ON public.activity_log
|
||||
USING btree (tenant_id, user_id, created_at DESC) WHERE (user_id IS NOT NULL);
|
||||
|
||||
ALTER TABLE public.activity_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON public.activity_log
|
||||
USING ((tenant_id = (current_setting('app.current_tenant_id'::text))::bigint));
|
||||
|
||||
-- chain_hash trigger first, then copy data
|
||||
CREATE TRIGGER trg_audit_chain_hash_activity_log
|
||||
BEFORE INSERT ON public.activity_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_chain_hash();
|
||||
|
||||
INSERT INTO public.activity_log
|
||||
(id, tenant_id, user_id, deal_id, event, old_value, new_value, context,
|
||||
ip_address, user_agent, log_hash, created_at)
|
||||
SELECT id, tenant_id, user_id, deal_id, event, old_value, new_value, context,
|
||||
ip_address, user_agent, log_hash, created_at
|
||||
FROM public.activity_log_old
|
||||
ORDER BY id;
|
||||
|
||||
-- New sequence owned by new table
|
||||
CREATE SEQUENCE public.activity_log_id_seq
|
||||
AS bigint START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
|
||||
ALTER SEQUENCE public.activity_log_id_seq OWNED BY public.activity_log.id;
|
||||
SELECT setval('public.activity_log_id_seq',
|
||||
COALESCE((SELECT MAX(id) FROM public.activity_log), 1));
|
||||
ALTER TABLE public.activity_log ALTER COLUMN id
|
||||
SET DEFAULT nextval('public.activity_log_id_seq'::regclass);
|
||||
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.activity_log TO crm_app_user;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.activity_log TO crm_admin_user;
|
||||
GRANT INSERT ON TABLE public.activity_log TO crm_audit_writer;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.activity_log TO crm_supplier_worker;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.activity_log_id_seq TO crm_app_user;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.activity_log_id_seq TO crm_admin_user;
|
||||
GRANT USAGE ON SEQUENCE public.activity_log_id_seq TO crm_audit_writer;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.activity_log_id_seq TO crm_supplier_worker;
|
||||
|
||||
-- block_mut trigger AFTER data copy
|
||||
CREATE TRIGGER trg_audit_block_mut_activity_log
|
||||
BEFORE DELETE OR UPDATE ON public.activity_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_block_mutation();
|
||||
|
||||
DROP TABLE public.activity_log_old;
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. auth_log
|
||||
-- Prod columns: id/actor_type(varchar20,default tenant_user)/tenant_id/user_id/
|
||||
-- saas_admin_user_id/email/event(varchar50)/ip_address/user_agent/
|
||||
-- failure_reason/log_hash/created_at
|
||||
-- + CHECK auth_log_actor_type_check + CHECK chk_auth_log_actor
|
||||
-- Triggers: trg_audit_chain_hash_auth_log + trg_audit_block_mut_auth_log
|
||||
-- RLS: tenant_isolation (special: NULL/empty current_setting = pass-through)
|
||||
-- Indexes: idx_auth_log_admin/email/ip_failed/tenant_user
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.auth_log RENAME TO auth_log_old;
|
||||
ALTER TABLE public.auth_log_old RENAME CONSTRAINT auth_log_pkey TO auth_log_pkey_old;
|
||||
ALTER TABLE public.auth_log_old RENAME CONSTRAINT auth_log_actor_type_check TO auth_log_actor_type_check_old;
|
||||
ALTER TABLE public.auth_log_old RENAME CONSTRAINT chk_auth_log_actor TO chk_auth_log_actor_old;
|
||||
ALTER SEQUENCE public.auth_log_id_seq RENAME TO auth_log_id_seq_old;
|
||||
ALTER TABLE public.auth_log_old DISABLE TRIGGER ALL;
|
||||
ALTER INDEX public.idx_auth_log_admin RENAME TO idx_auth_log_admin_old;
|
||||
ALTER INDEX public.idx_auth_log_email RENAME TO idx_auth_log_email_old;
|
||||
ALTER INDEX public.idx_auth_log_ip_failed RENAME TO idx_auth_log_ip_failed_old;
|
||||
ALTER INDEX public.idx_auth_log_tenant_user RENAME TO idx_auth_log_tenant_user_old;
|
||||
|
||||
CREATE TABLE public.auth_log (
|
||||
id bigint NOT NULL,
|
||||
actor_type character varying(20) DEFAULT 'tenant_user'::character varying NOT NULL,
|
||||
tenant_id bigint,
|
||||
user_id bigint,
|
||||
saas_admin_user_id bigint,
|
||||
email character varying(255),
|
||||
event character varying(50) NOT NULL,
|
||||
ip_address inet,
|
||||
user_agent text,
|
||||
failure_reason character varying(100),
|
||||
log_hash bytea,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT auth_log_actor_type_check CHECK (((actor_type)::text = ANY
|
||||
((ARRAY['tenant_user'::character varying, 'saas_admin'::character varying])::text[]))),
|
||||
CONSTRAINT chk_auth_log_actor CHECK (
|
||||
((((actor_type)::text = 'tenant_user'::text) AND (user_id IS NOT NULL) AND (saas_admin_user_id IS NULL))
|
||||
OR (((actor_type)::text = 'saas_admin'::text) AND (saas_admin_user_id IS NOT NULL) AND (user_id IS NULL))
|
||||
OR (((actor_type)::text = 'tenant_user'::text) AND (user_id IS NULL) AND (saas_admin_user_id IS NULL) AND (email IS NOT NULL))))
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE public.auth_log_y2026_m02 PARTITION OF public.auth_log
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
CREATE TABLE public.auth_log_y2026_m03 PARTITION OF public.auth_log
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
CREATE TABLE public.auth_log_y2026_m04 PARTITION OF public.auth_log
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE public.auth_log_y2026_m05 PARTITION OF public.auth_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE public.auth_log_y2026_m06 PARTITION OF public.auth_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE public.auth_log_y2026_m07 PARTITION OF public.auth_log
|
||||
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||||
CREATE TABLE public.auth_log_default PARTITION OF public.auth_log DEFAULT;
|
||||
|
||||
ALTER TABLE public.auth_log ADD CONSTRAINT auth_log_pkey PRIMARY KEY (id, created_at);
|
||||
|
||||
CREATE INDEX idx_auth_log_admin ON public.auth_log
|
||||
USING btree (saas_admin_user_id, created_at DESC) WHERE (saas_admin_user_id IS NOT NULL);
|
||||
CREATE INDEX idx_auth_log_email ON public.auth_log
|
||||
USING btree (email, created_at DESC);
|
||||
CREATE INDEX idx_auth_log_ip_failed ON public.auth_log
|
||||
USING btree (ip_address, created_at DESC) WHERE ((event)::text = 'login_failed'::text);
|
||||
CREATE INDEX idx_auth_log_tenant_user ON public.auth_log
|
||||
USING btree (tenant_id, user_id, created_at DESC);
|
||||
|
||||
ALTER TABLE public.auth_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON public.auth_log
|
||||
USING (((current_setting('app.current_tenant_id'::text, true) IS NULL)
|
||||
OR (current_setting('app.current_tenant_id'::text, true) = ''::text)
|
||||
OR (((actor_type)::text = 'tenant_user'::text)
|
||||
AND (tenant_id = (current_setting('app.current_tenant_id'::text, true))::bigint))));
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_auth_log
|
||||
BEFORE INSERT ON public.auth_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_chain_hash();
|
||||
|
||||
INSERT INTO public.auth_log
|
||||
(id, actor_type, tenant_id, user_id, saas_admin_user_id, email, event,
|
||||
ip_address, user_agent, failure_reason, log_hash, created_at)
|
||||
SELECT id, actor_type, tenant_id, user_id, saas_admin_user_id, email, event,
|
||||
ip_address, user_agent, failure_reason, log_hash, created_at
|
||||
FROM public.auth_log_old
|
||||
ORDER BY id;
|
||||
|
||||
CREATE SEQUENCE public.auth_log_id_seq
|
||||
AS bigint START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
|
||||
ALTER SEQUENCE public.auth_log_id_seq OWNED BY public.auth_log.id;
|
||||
SELECT setval('public.auth_log_id_seq',
|
||||
COALESCE((SELECT MAX(id) FROM public.auth_log), 1));
|
||||
ALTER TABLE public.auth_log ALTER COLUMN id
|
||||
SET DEFAULT nextval('public.auth_log_id_seq'::regclass);
|
||||
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.auth_log TO crm_app_user;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.auth_log TO crm_admin_user;
|
||||
GRANT INSERT ON TABLE public.auth_log TO crm_audit_writer;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.auth_log TO crm_supplier_worker;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.auth_log_id_seq TO crm_app_user;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.auth_log_id_seq TO crm_admin_user;
|
||||
GRANT USAGE ON SEQUENCE public.auth_log_id_seq TO crm_audit_writer;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.auth_log_id_seq TO crm_supplier_worker;
|
||||
|
||||
CREATE TRIGGER trg_audit_block_mut_auth_log
|
||||
BEFORE DELETE OR UPDATE ON public.auth_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_block_mutation();
|
||||
|
||||
DROP TABLE public.auth_log_old;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. balance_transactions
|
||||
-- Prod columns: id/tenant_id(NOT NULL)/type(varchar50)/amount_rub(numeric12.2 default 0)/
|
||||
-- amount_leads(int default 0)/balance_rub_after/balance_leads_after/
|
||||
-- description/related_type(varchar100)/related_id/user_id/admin_user_id/
|
||||
-- log_hash/created_at + CHECK balance_transactions_type_check
|
||||
-- Triggers: trg_audit_chain_hash_balance_tx + trg_audit_block_mut_balance_tx
|
||||
-- RLS: tenant_isolation (simple)
|
||||
-- Indexes: idx_balance_tenant_created / idx_balance_tenant_type
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.balance_transactions RENAME TO balance_transactions_old;
|
||||
ALTER TABLE public.balance_transactions_old RENAME CONSTRAINT balance_transactions_pkey TO balance_transactions_pkey_old;
|
||||
ALTER TABLE public.balance_transactions_old RENAME CONSTRAINT balance_transactions_type_check TO balance_transactions_type_check_old;
|
||||
ALTER SEQUENCE public.balance_transactions_id_seq RENAME TO balance_transactions_id_seq_old;
|
||||
ALTER TABLE public.balance_transactions_old DISABLE TRIGGER ALL;
|
||||
ALTER INDEX public.idx_balance_tenant_created RENAME TO idx_balance_tenant_created_old;
|
||||
ALTER INDEX public.idx_balance_tenant_type RENAME TO idx_balance_tenant_type_old;
|
||||
|
||||
CREATE TABLE public.balance_transactions (
|
||||
id bigint NOT NULL,
|
||||
tenant_id bigint NOT NULL,
|
||||
type character varying(50) NOT NULL,
|
||||
amount_rub numeric(12,2) DEFAULT 0,
|
||||
amount_leads integer DEFAULT 0,
|
||||
balance_rub_after numeric(12,2),
|
||||
balance_leads_after integer,
|
||||
description text,
|
||||
related_type character varying(100),
|
||||
related_id bigint,
|
||||
user_id bigint,
|
||||
admin_user_id bigint,
|
||||
log_hash bytea,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT balance_transactions_type_check CHECK (((type)::text = ANY
|
||||
((ARRAY['trial_bonus'::character varying, 'topup'::character varying,
|
||||
'lead_charge'::character varying, 'refund'::character varying,
|
||||
'manual_adjustment'::character varying, 'historical_import'::character varying,
|
||||
'chargeback_writedown'::character varying,
|
||||
'chargeback_repayment'::character varying])::text[])))
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE public.balance_transactions_y2026_m02 PARTITION OF public.balance_transactions
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
CREATE TABLE public.balance_transactions_y2026_m03 PARTITION OF public.balance_transactions
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
CREATE TABLE public.balance_transactions_y2026_m04 PARTITION OF public.balance_transactions
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE public.balance_transactions_y2026_m05 PARTITION OF public.balance_transactions
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE public.balance_transactions_y2026_m06 PARTITION OF public.balance_transactions
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE public.balance_transactions_y2026_m07 PARTITION OF public.balance_transactions
|
||||
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||||
CREATE TABLE public.balance_transactions_default PARTITION OF public.balance_transactions DEFAULT;
|
||||
|
||||
ALTER TABLE public.balance_transactions ADD CONSTRAINT balance_transactions_pkey PRIMARY KEY (id, created_at);
|
||||
|
||||
CREATE INDEX idx_balance_tenant_created ON public.balance_transactions
|
||||
USING btree (tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_balance_tenant_type ON public.balance_transactions
|
||||
USING btree (tenant_id, type);
|
||||
|
||||
ALTER TABLE public.balance_transactions ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON public.balance_transactions
|
||||
USING ((tenant_id = (current_setting('app.current_tenant_id'::text))::bigint));
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_balance_tx
|
||||
BEFORE INSERT ON public.balance_transactions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_chain_hash();
|
||||
|
||||
INSERT INTO public.balance_transactions
|
||||
(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, created_at)
|
||||
SELECT 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, created_at
|
||||
FROM public.balance_transactions_old
|
||||
ORDER BY id;
|
||||
|
||||
CREATE SEQUENCE public.balance_transactions_id_seq
|
||||
AS bigint START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
|
||||
ALTER SEQUENCE public.balance_transactions_id_seq OWNED BY public.balance_transactions.id;
|
||||
SELECT setval('public.balance_transactions_id_seq',
|
||||
COALESCE((SELECT MAX(id) FROM public.balance_transactions), 1));
|
||||
ALTER TABLE public.balance_transactions ALTER COLUMN id
|
||||
SET DEFAULT nextval('public.balance_transactions_id_seq'::regclass);
|
||||
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.balance_transactions TO crm_app_user;
|
||||
GRANT SELECT, INSERT, UPDATE ON TABLE public.balance_transactions TO crm_admin_user;
|
||||
GRANT INSERT ON TABLE public.balance_transactions TO crm_audit_writer;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.balance_transactions TO crm_supplier_worker;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.balance_transactions_id_seq TO crm_app_user;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.balance_transactions_id_seq TO crm_admin_user;
|
||||
GRANT USAGE ON SEQUENCE public.balance_transactions_id_seq TO crm_audit_writer;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.balance_transactions_id_seq TO crm_supplier_worker;
|
||||
|
||||
CREATE TRIGGER trg_audit_block_mut_balance_tx
|
||||
BEFORE DELETE OR UPDATE ON public.balance_transactions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_block_mutation();
|
||||
|
||||
DROP TABLE public.balance_transactions_old;
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. tenant_operations_log
|
||||
-- Prod columns: id/tenant_id(NOT NULL)/user_id/entity_type(varchar50 NOT NULL)/
|
||||
-- entity_id/event(varchar100 NOT NULL)/payload_before/payload_after/
|
||||
-- ip_address/user_agent/log_hash/created_at
|
||||
-- Triggers: trg_audit_chain_hash_tenant_ops + trg_audit_block_mut_tenant_ops
|
||||
-- RLS: tenant_isolation (simple)
|
||||
-- Indexes: idx_tenant_ops_entity / idx_tenant_ops_tenant_created
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.tenant_operations_log RENAME TO tenant_operations_log_old;
|
||||
ALTER TABLE public.tenant_operations_log_old RENAME CONSTRAINT tenant_operations_log_pkey TO tenant_operations_log_pkey_old;
|
||||
ALTER SEQUENCE public.tenant_operations_log_id_seq RENAME TO tenant_operations_log_id_seq_old;
|
||||
ALTER TABLE public.tenant_operations_log_old DISABLE TRIGGER ALL;
|
||||
ALTER INDEX public.idx_tenant_ops_entity RENAME TO idx_tenant_ops_entity_old;
|
||||
ALTER INDEX public.idx_tenant_ops_tenant_created RENAME TO idx_tenant_ops_tenant_created_old;
|
||||
|
||||
CREATE TABLE public.tenant_operations_log (
|
||||
id bigint NOT NULL,
|
||||
tenant_id bigint NOT NULL,
|
||||
user_id bigint,
|
||||
entity_type character varying(50) NOT NULL,
|
||||
entity_id bigint,
|
||||
event character varying(100) NOT NULL,
|
||||
payload_before jsonb,
|
||||
payload_after jsonb,
|
||||
ip_address inet,
|
||||
user_agent text,
|
||||
log_hash bytea,
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE public.tenant_operations_log_y2026_m02 PARTITION OF public.tenant_operations_log
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
CREATE TABLE public.tenant_operations_log_y2026_m03 PARTITION OF public.tenant_operations_log
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
CREATE TABLE public.tenant_operations_log_y2026_m04 PARTITION OF public.tenant_operations_log
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE public.tenant_operations_log_y2026_m05 PARTITION OF public.tenant_operations_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE public.tenant_operations_log_y2026_m06 PARTITION OF public.tenant_operations_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE public.tenant_operations_log_y2026_m07 PARTITION OF public.tenant_operations_log
|
||||
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||||
CREATE TABLE public.tenant_operations_log_default PARTITION OF public.tenant_operations_log DEFAULT;
|
||||
|
||||
ALTER TABLE public.tenant_operations_log ADD CONSTRAINT tenant_operations_log_pkey PRIMARY KEY (id, created_at);
|
||||
|
||||
CREATE INDEX idx_tenant_ops_entity ON public.tenant_operations_log
|
||||
USING btree (tenant_id, entity_type, entity_id, created_at DESC) WHERE (entity_id IS NOT NULL);
|
||||
CREATE INDEX idx_tenant_ops_tenant_created ON public.tenant_operations_log
|
||||
USING btree (tenant_id, created_at DESC);
|
||||
|
||||
ALTER TABLE public.tenant_operations_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON public.tenant_operations_log
|
||||
USING ((tenant_id = (current_setting('app.current_tenant_id'::text))::bigint));
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_tenant_ops
|
||||
BEFORE INSERT ON public.tenant_operations_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_chain_hash();
|
||||
|
||||
INSERT INTO public.tenant_operations_log
|
||||
(id, tenant_id, user_id, entity_type, entity_id, event, payload_before,
|
||||
payload_after, ip_address, user_agent, log_hash, created_at)
|
||||
SELECT id, tenant_id, user_id, entity_type, entity_id, event, payload_before,
|
||||
payload_after, ip_address, user_agent, log_hash, created_at
|
||||
FROM public.tenant_operations_log_old
|
||||
ORDER BY id;
|
||||
|
||||
CREATE SEQUENCE public.tenant_operations_log_id_seq
|
||||
AS bigint START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
|
||||
ALTER SEQUENCE public.tenant_operations_log_id_seq OWNED BY public.tenant_operations_log.id;
|
||||
SELECT setval('public.tenant_operations_log_id_seq',
|
||||
COALESCE((SELECT MAX(id) FROM public.tenant_operations_log), 1));
|
||||
ALTER TABLE public.tenant_operations_log ALTER COLUMN id
|
||||
SET DEFAULT nextval('public.tenant_operations_log_id_seq'::regclass);
|
||||
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.tenant_operations_log TO crm_app_user;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.tenant_operations_log TO crm_admin_user;
|
||||
GRANT ALL ON TABLE public.tenant_operations_log TO crm_migrator;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.tenant_operations_log TO crm_supplier_worker;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.tenant_operations_log_id_seq TO crm_app_user;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.tenant_operations_log_id_seq TO crm_admin_user;
|
||||
GRANT ALL ON SEQUENCE public.tenant_operations_log_id_seq TO crm_migrator;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.tenant_operations_log_id_seq TO crm_supplier_worker;
|
||||
|
||||
CREATE TRIGGER trg_audit_block_mut_tenant_ops
|
||||
BEFORE DELETE OR UPDATE ON public.tenant_operations_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_block_mutation();
|
||||
|
||||
DROP TABLE public.tenant_operations_log_old;
|
||||
|
||||
-- =============================================================================
|
||||
-- 5. pd_processing_log
|
||||
-- Prod columns: id/tenant_id/subject_type(varchar50)/subject_id/action(varchar50)/
|
||||
-- purpose(varchar255)/actor_tenant_user_id/actor_admin_user_id/
|
||||
-- ip_address/log_hash/created_at + CHECK chk_pd_actor
|
||||
-- Triggers: trg_audit_chain_hash_pd_log + trg_audit_block_mut_pd_log
|
||||
-- RLS: tenant_isolation (simple)
|
||||
-- Indexes: idx_pd_log_admin_actor / idx_pd_log_tenant
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.pd_processing_log RENAME TO pd_processing_log_old;
|
||||
ALTER TABLE public.pd_processing_log_old RENAME CONSTRAINT pd_processing_log_pkey TO pd_processing_log_pkey_old;
|
||||
ALTER TABLE public.pd_processing_log_old RENAME CONSTRAINT chk_pd_actor TO chk_pd_actor_old;
|
||||
ALTER SEQUENCE public.pd_processing_log_id_seq RENAME TO pd_processing_log_id_seq_old;
|
||||
ALTER TABLE public.pd_processing_log_old DISABLE TRIGGER ALL;
|
||||
ALTER INDEX public.idx_pd_log_admin_actor RENAME TO idx_pd_log_admin_actor_old;
|
||||
ALTER INDEX public.idx_pd_log_tenant RENAME TO idx_pd_log_tenant_old;
|
||||
|
||||
CREATE TABLE public.pd_processing_log (
|
||||
id bigint NOT NULL,
|
||||
tenant_id bigint,
|
||||
subject_type character varying(50),
|
||||
subject_id bigint,
|
||||
action character varying(50),
|
||||
purpose character varying(255),
|
||||
actor_tenant_user_id bigint,
|
||||
actor_admin_user_id bigint,
|
||||
ip_address inet,
|
||||
log_hash bytea,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT chk_pd_actor CHECK (
|
||||
(((actor_tenant_user_id IS NOT NULL) AND (actor_admin_user_id IS NULL))
|
||||
OR ((actor_tenant_user_id IS NULL) AND (actor_admin_user_id IS NOT NULL))
|
||||
OR ((actor_tenant_user_id IS NULL) AND (actor_admin_user_id IS NULL))))
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE public.pd_processing_log_y2026_m02 PARTITION OF public.pd_processing_log
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
CREATE TABLE public.pd_processing_log_y2026_m03 PARTITION OF public.pd_processing_log
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
CREATE TABLE public.pd_processing_log_y2026_m04 PARTITION OF public.pd_processing_log
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE public.pd_processing_log_y2026_m05 PARTITION OF public.pd_processing_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE public.pd_processing_log_y2026_m06 PARTITION OF public.pd_processing_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE public.pd_processing_log_y2026_m07 PARTITION OF public.pd_processing_log
|
||||
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||||
CREATE TABLE public.pd_processing_log_default PARTITION OF public.pd_processing_log DEFAULT;
|
||||
|
||||
ALTER TABLE public.pd_processing_log ADD CONSTRAINT pd_processing_log_pkey PRIMARY KEY (id, created_at);
|
||||
|
||||
CREATE INDEX idx_pd_log_admin_actor ON public.pd_processing_log
|
||||
USING btree (actor_admin_user_id, created_at DESC) WHERE (actor_admin_user_id IS NOT NULL);
|
||||
CREATE INDEX idx_pd_log_tenant ON public.pd_processing_log
|
||||
USING btree (tenant_id, created_at DESC);
|
||||
|
||||
ALTER TABLE public.pd_processing_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON public.pd_processing_log
|
||||
USING ((tenant_id = (current_setting('app.current_tenant_id'::text))::bigint));
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_pd_log
|
||||
BEFORE INSERT ON public.pd_processing_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_chain_hash();
|
||||
|
||||
INSERT INTO public.pd_processing_log
|
||||
(id, tenant_id, subject_type, subject_id, action, purpose,
|
||||
actor_tenant_user_id, actor_admin_user_id, ip_address, log_hash, created_at)
|
||||
SELECT id, tenant_id, subject_type, subject_id, action, purpose,
|
||||
actor_tenant_user_id, actor_admin_user_id, ip_address, log_hash, created_at
|
||||
FROM public.pd_processing_log_old
|
||||
ORDER BY id;
|
||||
|
||||
CREATE SEQUENCE public.pd_processing_log_id_seq
|
||||
AS bigint START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
|
||||
ALTER SEQUENCE public.pd_processing_log_id_seq OWNED BY public.pd_processing_log.id;
|
||||
SELECT setval('public.pd_processing_log_id_seq',
|
||||
COALESCE((SELECT MAX(id) FROM public.pd_processing_log), 1));
|
||||
ALTER TABLE public.pd_processing_log ALTER COLUMN id
|
||||
SET DEFAULT nextval('public.pd_processing_log_id_seq'::regclass);
|
||||
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.pd_processing_log TO crm_app_user;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.pd_processing_log TO crm_admin_user;
|
||||
GRANT INSERT ON TABLE public.pd_processing_log TO crm_audit_writer;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.pd_processing_log TO crm_supplier_worker;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.pd_processing_log_id_seq TO crm_app_user;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.pd_processing_log_id_seq TO crm_admin_user;
|
||||
GRANT USAGE ON SEQUENCE public.pd_processing_log_id_seq TO crm_audit_writer;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.pd_processing_log_id_seq TO crm_supplier_worker;
|
||||
|
||||
CREATE TRIGGER trg_audit_block_mut_pd_log
|
||||
BEFORE DELETE OR UPDATE ON public.pd_processing_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_block_mutation();
|
||||
|
||||
DROP TABLE public.pd_processing_log_old;
|
||||
|
||||
-- =============================================================================
|
||||
-- 6. webhook_log (partition by received_at; NO hash-chain triggers; FK to it dropped above)
|
||||
-- Prod columns: id/tenant_id/raw_payload(jsonb NOT NULL)/received_at/processed_at/
|
||||
-- deal_id/error/source(varchar50)/status(varchar50)/lead_id/ip_address/created_at
|
||||
-- NO triggers at all (no chain_hash, no block_mut)
|
||||
-- RLS: tenant_isolation (simple)
|
||||
-- Indexes: idx_webhook_log_status / idx_webhook_log_tenant_received
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.webhook_log RENAME TO webhook_log_old;
|
||||
ALTER TABLE public.webhook_log_old RENAME CONSTRAINT webhook_log_pkey TO webhook_log_pkey_old;
|
||||
ALTER SEQUENCE public.webhook_log_id_seq RENAME TO webhook_log_id_seq_old;
|
||||
ALTER TABLE public.webhook_log_old DISABLE TRIGGER ALL;
|
||||
ALTER INDEX public.idx_webhook_log_status RENAME TO idx_webhook_log_status_old;
|
||||
ALTER INDEX public.idx_webhook_log_tenant_received RENAME TO idx_webhook_log_tenant_received_old;
|
||||
|
||||
CREATE TABLE public.webhook_log (
|
||||
id bigint NOT NULL,
|
||||
tenant_id bigint,
|
||||
raw_payload jsonb NOT NULL,
|
||||
received_at timestamp with time zone DEFAULT now(),
|
||||
processed_at timestamp with time zone,
|
||||
deal_id bigint,
|
||||
error text,
|
||||
source character varying(50),
|
||||
status character varying(50),
|
||||
lead_id bigint,
|
||||
ip_address inet,
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
CREATE TABLE public.webhook_log_y2026_m02 PARTITION OF public.webhook_log
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
CREATE TABLE public.webhook_log_y2026_m03 PARTITION OF public.webhook_log
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
CREATE TABLE public.webhook_log_y2026_m04 PARTITION OF public.webhook_log
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE public.webhook_log_y2026_m05 PARTITION OF public.webhook_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE public.webhook_log_y2026_m06 PARTITION OF public.webhook_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE public.webhook_log_y2026_m07 PARTITION OF public.webhook_log
|
||||
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||||
CREATE TABLE public.webhook_log_default PARTITION OF public.webhook_log DEFAULT;
|
||||
|
||||
ALTER TABLE public.webhook_log ADD CONSTRAINT webhook_log_pkey PRIMARY KEY (id, received_at);
|
||||
|
||||
CREATE INDEX idx_webhook_log_status ON public.webhook_log
|
||||
USING btree (status, created_at DESC);
|
||||
CREATE INDEX idx_webhook_log_tenant_received ON public.webhook_log
|
||||
USING btree (tenant_id, received_at DESC);
|
||||
|
||||
ALTER TABLE public.webhook_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON public.webhook_log
|
||||
USING ((tenant_id = (current_setting('app.current_tenant_id'::text))::bigint));
|
||||
|
||||
-- No chain_hash trigger on webhook_log
|
||||
INSERT INTO public.webhook_log
|
||||
(id, tenant_id, raw_payload, received_at, processed_at, deal_id, error,
|
||||
source, status, lead_id, ip_address, created_at)
|
||||
SELECT id, tenant_id, raw_payload, received_at, processed_at, deal_id, error,
|
||||
source, status, lead_id, ip_address, created_at
|
||||
FROM public.webhook_log_old
|
||||
ORDER BY id;
|
||||
|
||||
CREATE SEQUENCE public.webhook_log_id_seq
|
||||
AS bigint START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
|
||||
ALTER SEQUENCE public.webhook_log_id_seq OWNED BY public.webhook_log.id;
|
||||
SELECT setval('public.webhook_log_id_seq',
|
||||
COALESCE((SELECT MAX(id) FROM public.webhook_log), 1));
|
||||
ALTER TABLE public.webhook_log ALTER COLUMN id
|
||||
SET DEFAULT nextval('public.webhook_log_id_seq'::regclass);
|
||||
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.webhook_log TO crm_app_user;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.webhook_log TO crm_admin_user;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.webhook_log TO crm_supplier_worker;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.webhook_log_id_seq TO crm_app_user;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.webhook_log_id_seq TO crm_admin_user;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.webhook_log_id_seq TO crm_supplier_worker;
|
||||
|
||||
-- No block_mut trigger on webhook_log
|
||||
DROP TABLE public.webhook_log_old;
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. saas_admin_audit_log (NO RLS; SaaS admin table)
|
||||
-- Prod columns: id/admin_user_id(NOT NULL)/action(varchar100 NOT NULL)/target_type(varchar50)/
|
||||
-- target_id/target_tenant_id/payload_before/payload_after/reason/
|
||||
-- ip_address(NOT NULL inet)/user_agent/requires_approval(bool default false)/
|
||||
-- approved_by/approved_at/log_hash/created_at
|
||||
-- Triggers: trg_audit_chain_hash_saas_admin_audit + trg_audit_block_mut_saas_admin_audit
|
||||
-- NO RLS
|
||||
-- Indexes: idx_admin_audit_action/admin/pending/tenant
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE public.saas_admin_audit_log RENAME TO saas_admin_audit_log_old;
|
||||
ALTER TABLE public.saas_admin_audit_log_old RENAME CONSTRAINT saas_admin_audit_log_pkey TO saas_admin_audit_log_pkey_old;
|
||||
ALTER SEQUENCE public.saas_admin_audit_log_id_seq RENAME TO saas_admin_audit_log_id_seq_old;
|
||||
ALTER TABLE public.saas_admin_audit_log_old DISABLE TRIGGER ALL;
|
||||
ALTER INDEX public.idx_admin_audit_action RENAME TO idx_admin_audit_action_old;
|
||||
ALTER INDEX public.idx_admin_audit_admin RENAME TO idx_admin_audit_admin_old;
|
||||
ALTER INDEX public.idx_admin_audit_pending RENAME TO idx_admin_audit_pending_old;
|
||||
ALTER INDEX public.idx_admin_audit_tenant RENAME TO idx_admin_audit_tenant_old;
|
||||
|
||||
CREATE TABLE public.saas_admin_audit_log (
|
||||
id bigint NOT NULL,
|
||||
admin_user_id bigint NOT NULL,
|
||||
action character varying(100) NOT NULL,
|
||||
target_type character varying(50),
|
||||
target_id bigint,
|
||||
target_tenant_id bigint,
|
||||
payload_before jsonb,
|
||||
payload_after jsonb,
|
||||
reason text,
|
||||
ip_address inet NOT NULL,
|
||||
user_agent text,
|
||||
requires_approval boolean DEFAULT false,
|
||||
approved_by bigint,
|
||||
approved_at timestamp with time zone,
|
||||
log_hash bytea,
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
) PARTITION BY RANGE (created_at);
|
||||
|
||||
CREATE TABLE public.saas_admin_audit_log_y2026_m02 PARTITION OF public.saas_admin_audit_log
|
||||
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||
CREATE TABLE public.saas_admin_audit_log_y2026_m03 PARTITION OF public.saas_admin_audit_log
|
||||
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
|
||||
CREATE TABLE public.saas_admin_audit_log_y2026_m04 PARTITION OF public.saas_admin_audit_log
|
||||
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
|
||||
CREATE TABLE public.saas_admin_audit_log_y2026_m05 PARTITION OF public.saas_admin_audit_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE public.saas_admin_audit_log_y2026_m06 PARTITION OF public.saas_admin_audit_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
CREATE TABLE public.saas_admin_audit_log_y2026_m07 PARTITION OF public.saas_admin_audit_log
|
||||
FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');
|
||||
CREATE TABLE public.saas_admin_audit_log_default PARTITION OF public.saas_admin_audit_log DEFAULT;
|
||||
|
||||
ALTER TABLE public.saas_admin_audit_log ADD CONSTRAINT saas_admin_audit_log_pkey PRIMARY KEY (id, created_at);
|
||||
|
||||
CREATE INDEX idx_admin_audit_action ON public.saas_admin_audit_log
|
||||
USING btree (action, created_at DESC);
|
||||
CREATE INDEX idx_admin_audit_admin ON public.saas_admin_audit_log
|
||||
USING btree (admin_user_id, created_at DESC);
|
||||
CREATE INDEX idx_admin_audit_pending ON public.saas_admin_audit_log
|
||||
USING btree (approved_at) WHERE ((requires_approval = true) AND (approved_at IS NULL));
|
||||
CREATE INDEX idx_admin_audit_tenant ON public.saas_admin_audit_log
|
||||
USING btree (target_tenant_id, created_at DESC) WHERE (target_tenant_id IS NOT NULL);
|
||||
|
||||
-- No RLS on saas_admin_audit_log
|
||||
|
||||
CREATE TRIGGER trg_audit_chain_hash_saas_admin_audit
|
||||
BEFORE INSERT ON public.saas_admin_audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_chain_hash();
|
||||
|
||||
INSERT INTO public.saas_admin_audit_log
|
||||
(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, created_at)
|
||||
SELECT 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, created_at
|
||||
FROM public.saas_admin_audit_log_old
|
||||
ORDER BY id;
|
||||
|
||||
CREATE SEQUENCE public.saas_admin_audit_log_id_seq
|
||||
AS bigint START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE CACHE 1;
|
||||
ALTER SEQUENCE public.saas_admin_audit_log_id_seq OWNED BY public.saas_admin_audit_log.id;
|
||||
SELECT setval('public.saas_admin_audit_log_id_seq',
|
||||
COALESCE((SELECT MAX(id) FROM public.saas_admin_audit_log), 1));
|
||||
ALTER TABLE public.saas_admin_audit_log ALTER COLUMN id
|
||||
SET DEFAULT nextval('public.saas_admin_audit_log_id_seq'::regclass);
|
||||
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.saas_admin_audit_log TO crm_admin_user;
|
||||
GRANT INSERT ON TABLE public.saas_admin_audit_log TO crm_audit_writer;
|
||||
GRANT SELECT, INSERT, DELETE, UPDATE ON TABLE public.saas_admin_audit_log TO crm_supplier_worker;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.saas_admin_audit_log_id_seq TO crm_app_user;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.saas_admin_audit_log_id_seq TO crm_admin_user;
|
||||
GRANT USAGE ON SEQUENCE public.saas_admin_audit_log_id_seq TO crm_audit_writer;
|
||||
GRANT SELECT, USAGE ON SEQUENCE public.saas_admin_audit_log_id_seq TO crm_supplier_worker;
|
||||
|
||||
CREATE TRIGGER trg_audit_block_mut_saas_admin_audit
|
||||
BEFORE DELETE OR UPDATE ON public.saas_admin_audit_log
|
||||
FOR EACH ROW EXECUTE FUNCTION public.audit_block_mutation();
|
||||
|
||||
DROP TABLE public.saas_admin_audit_log_old;
|
||||
|
||||
-- =============================================================================
|
||||
-- Retention seeds in system_settings
|
||||
-- Key format MUST match PartitionsDropExpired::resolveRetention():
|
||||
-- partition_retention_months_<table>
|
||||
-- =============================================================================
|
||||
INSERT INTO public.system_settings (key, value, type, description)
|
||||
VALUES
|
||||
('partition_retention_months_auth_log', '24', 'int', 'Months to retain auth_log partitions (hole #2)'),
|
||||
('partition_retention_months_activity_log', '36', 'int', 'Months to retain activity_log partitions (hole #2)'),
|
||||
('partition_retention_months_tenant_operations_log', '24', 'int', 'Months to retain tenant_operations_log partitions (hole #2)'),
|
||||
('partition_retention_months_webhook_log', '3', 'int', 'Months to retain webhook_log partitions (hole #2)'),
|
||||
('partition_retention_months_balance_transactions', '84', 'int', 'Months to retain balance_transactions partitions, 7y NK RF (hole #2)'),
|
||||
('partition_retention_months_pd_processing_log', '36', 'int', 'Months to retain pd_processing_log partitions, 152-FZ (hole #2)'),
|
||||
('partition_retention_months_saas_admin_audit_log', '84', 'int', 'Months to retain saas_admin_audit_log partitions, 7y (hole #2)')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
-- =============================================================================
|
||||
-- Migration row
|
||||
-- =============================================================================
|
||||
INSERT INTO public.migrations (migration, batch)
|
||||
VALUES ('2026_05_23_000002_hole2_partition_audit_tables', 3)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
COMMIT;
|
||||
+63
-59
@@ -1,12 +1,14 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.32 (23.05.2026 — balance_transactions.type +'migration' для Billing v2 Spec A конвертации balance_leads → balance_rub)
|
||||
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / webhook_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); FK на webhook_log удалены (W1); retention defaults в system_settings)
|
||||
-- Версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
|
||||
-- Базовая версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал))
|
||||
-- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
|
||||
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings)
|
||||
-- Базовая версия: v8.30 (23.05.2026 — scheduler_heartbeats: пульс планировщика, SaaS-level без RLS, 11 cron-задач, hole #6)
|
||||
-- Базовая версия: v8.29 (22.05.2026 — webhook_log: supplier audit columns)
|
||||
-- Базовая версия: v8.28 (22.05.2026 — tenant_operations_log: журнал тенант-уровневых операций вне сделок (проекты, API-ключи, webhook URL), append-only hash-chain, P2 operational journaling closure)
|
||||
-- Базовая версия: v8.27 (21.05.2026 — drop projects.archived_at: feature архива заменена настоящим удалением с защитой по сделкам (ProjectService::delete()))
|
||||
-- Метрики: 74 базовые таблицы (65 regular + 9 partitioned parents: deals + supplier_lead_costs + 7 audit) + 12 партиций / 125 индексов / 41 RLS-политика / 5 функций / 15 триггеров
|
||||
-- Метрики: 73 базовые таблицы (65 regular + 8 partitioned parents: deals + supplier_lead_costs + 6 audit) + 12 партиций / 120 индексов / 40 RLS-политик / 5 функций / 15 триггеров
|
||||
-- Базовая версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
|
||||
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
|
||||
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
|
||||
@@ -630,8 +632,7 @@ CREATE TABLE tenants (
|
||||
contact_email VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'active'
|
||||
CHECK (status IN ('active','suspended','pending_email_confirm','deleted')),
|
||||
webhook_token VARCHAR(64) UNIQUE NOT NULL,
|
||||
webhook_token_rotated_at TIMESTAMPTZ,
|
||||
-- webhook_token / webhook_token_rotated_at удалены в v8.35 (legacy direct webhook removal)
|
||||
timezone VARCHAR(50) DEFAULT 'Europe/Moscow',
|
||||
locale VARCHAR(10) DEFAULT 'ru',
|
||||
-- Биллинг
|
||||
@@ -678,7 +679,7 @@ CREATE TABLE tenants (
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL;
|
||||
CREATE INDEX idx_tenants_webhook_token ON tenants(webhook_token) WHERE deleted_at IS NULL AND status = 'active';
|
||||
-- idx_tenants_webhook_token удалён в v8.35 (legacy direct webhook removal)
|
||||
CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL;
|
||||
|
||||
-- Forward FK на tenants для SaaS-админских таблиц, объявленных выше
|
||||
@@ -1137,6 +1138,11 @@ CREATE TABLE supplier_csv_reconcile_log (
|
||||
total_csv_rows INTEGER,
|
||||
matched_count INTEGER,
|
||||
recovered_count INTEGER,
|
||||
-- Кол-во CSV-строк, у которых поле «project» не парсится в платформу B1/B2/B3
|
||||
-- (поставщик иногда кладёт телефон/URL в «Name» вместо названия проекта).
|
||||
-- Используется CsvReconcileJob для корректного расчёта drift'а — без вычитания
|
||||
-- этих строк формула стабильно даёт false-positive drift_alert ~40-50%.
|
||||
unparseable_count INTEGER NOT NULL DEFAULT 0,
|
||||
drift_ratio NUMERIC(5,4),
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'running'
|
||||
CHECK (status IN ('running','ok','drift_alert','failed')),
|
||||
@@ -1415,7 +1421,7 @@ CREATE INDEX idx_outbound_subs_secret_prefix ON outbound_webhook_subscriptions
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- outbound_webhook_deliveries — журнал попыток доставки (v8.4)
|
||||
-- Retention 90 дней (как webhook_log). См. §19.10.6 (retry-логика 7 попыток
|
||||
-- Retention 90 дней. См. §19.10.6 (retry-логика 7 попыток
|
||||
-- от 30 секунд до 24 часов).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE outbound_webhook_deliveries (
|
||||
@@ -1683,9 +1689,6 @@ CREATE INDEX ON deals (tenant_id, source_crm_id) WHERE source_crm_id IS N
|
||||
CREATE INDEX ON deals (tenant_id, utm_source) WHERE utm_source IS NOT NULL;
|
||||
-- (Биз-23) гео-фильтр в §10.3 + аналитика по регионам.
|
||||
CREATE INDEX ON deals (tenant_id, region_code) WHERE region_code IS NOT NULL;
|
||||
-- (Биз-19) lookup дублей master'а через duplicate_of_id для UI (показать
|
||||
-- цепочку дублей) и для cleanup при удалении master'а.
|
||||
CREATE INDEX ON deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL;
|
||||
-- (OPEN-И-25) cron leads:escalate-stale — выбирает unclosed deals по assigned_at.
|
||||
CREATE INDEX ON deals (tenant_id, assigned_at) WHERE status NOT IN ('closed','rejected');
|
||||
-- v8.9: фильтр soft-deleted в DealController::index/show/transition. Partial index
|
||||
@@ -1922,32 +1925,9 @@ COMMENT ON TABLE in_app_notifications IS
|
||||
'read_at при клике. RLS: tenant isolation.';
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- webhook_log — лог принятых webhook (раздел 5.7)
|
||||
-- РЕТЕНШН: system_settings.webhook_log_retention_days (по умолчанию 90 дней)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- v8.31: партиционирована помесячно по received_at (hole #2). PK → (id, received_at).
|
||||
-- FK из failed_webhook_jobs/rejected_deals_log удалены (W1 — невозможны на составном PK
|
||||
-- партиционированной таблицы с единичным FK-столбцом).
|
||||
CREATE TABLE webhook_log (
|
||||
id BIGSERIAL,
|
||||
tenant_id BIGINT REFERENCES tenants(id) ON DELETE CASCADE, -- NULL для platform-level событий (supplier webhook)
|
||||
raw_payload JSONB NOT NULL, -- содержит ПДн → удаляется при анонимизации
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- v8.31: NOT NULL (partition key)
|
||||
processed_at TIMESTAMPTZ,
|
||||
deal_id BIGINT, -- БЕЗ FK (deals партиционирована)
|
||||
error TEXT,
|
||||
-- v8.29: supplier webhook audit columns
|
||||
source VARCHAR(50), -- 'supplier' | 'tenant'
|
||||
status VARCHAR(50), -- 'received' | 'rejected_secret' | 'rejected_ip' | 'rate_limited'
|
||||
lead_id BIGINT, -- supplier_leads.id при статусе 'received'
|
||||
ip_address INET, -- клиентский IP
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (id, received_at) -- v8.31: composite PK
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
CREATE INDEX idx_webhook_log_tenant_received ON webhook_log(tenant_id, received_at DESC);
|
||||
CREATE INDEX idx_webhook_log_status ON webhook_log(status, created_at DESC);
|
||||
-- webhook_log удалена в v8.35 (legacy direct webhook removal).
|
||||
-- Канал входящих webhook (прямой приём от тенантов) упразднён.
|
||||
-- webhook_dedup_keys сохранена — используется CSV-каналом (HistoricalImportService).
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
@@ -1972,21 +1952,8 @@ CREATE INDEX idx_failed_webhook_unresolved ON failed_webhook_jobs(failed_at DESC
|
||||
CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id); -- v8.11 (audit O-perf-02)
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- rejected_deals_log — лог отвергнутых лидов при balance=0 (раздел 5.7)
|
||||
-- РЕТЕНШН: бессрочно (опционально 12 месяцев)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE rejected_deals_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
webhook_log_id BIGINT, -- v8.31: FK удалён (W1 — webhook_log партиционирована, composite PK несовместим с одиночным FK)
|
||||
reason VARCHAR(50) NOT NULL, -- zero_balance, validation_failed, ...
|
||||
payload JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_rejected_tenant_created ON rejected_deals_log(tenant_id, created_at DESC);
|
||||
CREATE INDEX idx_rejected_deals_log_webhook ON rejected_deals_log(webhook_log_id); -- v8.11 (audit O-perf-03)
|
||||
-- rejected_deals_log удалена в v8.35 (legacy direct webhook removal).
|
||||
-- Rejection-журнал для прямого webhook-канала упразднён вместе с каналом.
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
@@ -2046,6 +2013,45 @@ CREATE INDEX supplier_leads_recovered_from_csv_partial
|
||||
--
|
||||
-- REVOKE ALL ON supplier_leads FROM crm_app_user;
|
||||
|
||||
-- =============================================================================
|
||||
-- supplier_lead_deliveries — замок «одна поставка одному клиенту = один раз»
|
||||
-- (Billing v2 Spec B). Ключ по поставке (supplier_lead_id), НЕ по телефону —
|
||||
-- разные поставки с одним телефоном остаются отдельными платными лидами.
|
||||
-- Защищает шеринг-путь (RouteSupplierLeadJob) от наших собственных дублей
|
||||
-- при гонках / перезапусках задачи / CSV-восстановлении.
|
||||
-- =============================================================================
|
||||
CREATE TABLE supplier_lead_deliveries (
|
||||
supplier_lead_id BIGINT NOT NULL REFERENCES supplier_leads(id) ON DELETE CASCADE,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
deal_id BIGINT, -- созданная сделка; без FK (deals партиционирована)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (supplier_lead_id, tenant_id)
|
||||
);
|
||||
|
||||
ALTER TABLE supplier_lead_deliveries ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON supplier_lead_deliveries
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
|
||||
-- Явные GRANT'ы для 4 ролей (mirror webhook_dedup_keys): на prod таблица
|
||||
-- создаётся crm_supplier_worker, default privileges не наследуются от
|
||||
-- postgres-superuser на чужие creator-role. DO block — idempotent + dev-safe
|
||||
-- (на dev ролей нет → silent skip).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_app_user;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_admin_user;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON supplier_lead_deliveries TO crm_supplier_worker;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
||||
GRANT ALL PRIVILEGES ON supplier_lead_deliveries TO crm_migrator;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- 7. БИЛЛИНГ (SAAS-уровень)
|
||||
@@ -2767,7 +2773,7 @@ VALUES
|
||||
INSERT INTO system_settings (key, value, type, description) VALUES
|
||||
('schema_version', '8.3', 'string', 'Текущая версия схемы БД'),
|
||||
('trial_bonus_leads', '50', 'int', 'Стартовый бонус лидов для нового тенанта (fallback для tariff_plans.trial_bonus_leads)'),
|
||||
('low_balance_threshold_leads', '10', 'int', 'Порог email-предупреждения о низком балансе'),
|
||||
-- low_balance_threshold_leads удалён в v8.35 (использовался только LowBalanceNotification — удалена вместе с webhook-каналом)
|
||||
('inactive_warn_months', '11', 'int', 'Через сколько месяцев простоя слать предупреждение'),
|
||||
('inactive_delete_months', '12', 'int', 'Через сколько месяцев простоя удалять данные'),
|
||||
('webhook_rate_limit_rps', '100', 'int', 'Лимит запросов в секунду на токен Webhook'),
|
||||
@@ -2775,7 +2781,7 @@ INSERT INTO system_settings (key, value, type, description) VALUES
|
||||
('api_rate_limit_per_minute', '60', 'int', 'Лимит запросов API на ключ в минуту'),
|
||||
('login_max_attempts', '5', 'int', 'Макс. неудачных попыток входа в окне 15 минут'),
|
||||
('password_min_length', '10', 'int', 'Минимальная длина пароля'),
|
||||
('webhook_log_retention_days', '90', 'int', 'Сколько дней хранить raw_payload Webhook'),
|
||||
-- webhook_log_retention_days удалён в v8.35 (webhook_log таблица удалена)
|
||||
-- VAPID (Web Push, раздел 17.4)
|
||||
('vapid_public_key', '', 'string', 'VAPID public key (для подписки)'),
|
||||
('vapid_private_key', '', 'string', 'VAPID private key (ШИФРОВАН) — заполнить при инсталляции'),
|
||||
@@ -2803,11 +2809,11 @@ INSERT INTO system_settings (key, value, type, description) VALUES
|
||||
-- v8.18 (Plan 2/5): supplier-webhook secret + IP allowlist для defense-in-depth.
|
||||
('supplier_webhook_secret', '__SET_ON_DEPLOY__', 'string', 'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'),
|
||||
('supplier_ip_allowlist', '[]', 'json', 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'),
|
||||
-- v8.31: retention для 7 audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК).
|
||||
-- v8.31: retention для audit-таблиц после partitioning (hole #2). Используется PartitionsDropExpired (cron Sundays 03:00 МСК).
|
||||
-- webhook_log_retention_months удалён в v8.35 (webhook_log таблица удалена).
|
||||
('auth_log_retention_months', '24', 'int', 'Retention auth_log в месяцах (hole #2)'),
|
||||
('activity_log_retention_months', '36', 'int', 'Retention activity_log (hole #2)'),
|
||||
('tenant_operations_log_retention_months', '24', 'int', 'Retention tenant_operations_log (hole #2)'),
|
||||
('webhook_log_retention_months', '3', 'int', 'Retention webhook_log (hole #2)'),
|
||||
('balance_transactions_retention_months', '84', 'int', 'Retention balance_transactions, 7л НК РФ (hole #2)'),
|
||||
('pd_processing_log_retention_months', '36', 'int', 'Retention pd_processing_log, 152-ФЗ 3 года (hole #2)'),
|
||||
('saas_admin_audit_log_retention_months', '84', 'int', 'Retention saas_admin_audit_log, 7л (hole #2)');
|
||||
@@ -2880,9 +2886,8 @@ ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE activity_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tenant_operations_log ENABLE ROW LEVEL SECURITY; -- v8.31: перенесено сюда (была inline)
|
||||
ALTER TABLE reminders ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE webhook_log ENABLE ROW LEVEL SECURITY;
|
||||
-- webhook_log / rejected_deals_log: таблицы удалены в v8.35
|
||||
ALTER TABLE failed_webhook_jobs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE rejected_deals_log ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE tariff_subscriptions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE saas_invoices ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE saas_invoice_items ENABLE ROW LEVEL SECURITY; -- через invoice_id косвенно (см. политику ниже)
|
||||
@@ -2922,9 +2927,8 @@ CREATE POLICY tenant_isolation ON import_unknown_statuses USING (tenant_id = cur
|
||||
CREATE POLICY tenant_isolation ON activity_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON tenant_operations_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint); -- v8.31: перенесено из inline
|
||||
CREATE POLICY tenant_isolation ON reminders USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON webhook_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
-- webhook_log / rejected_deals_log policies удалены в v8.35 (таблицы удалены)
|
||||
CREATE POLICY tenant_isolation ON failed_webhook_jobs USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON rejected_deals_log USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON tariff_subscriptions USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON saas_invoices USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
CREATE POLICY tenant_isolation ON saas_upd_documents USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Правила работы Claude в проекте «Лидерра»
|
||||
|
||||
**Версия:** v1.39 (22.05.2026)
|
||||
**Дата:** 22.05.2026
|
||||
**Версия:** v1.40 (24.05.2026)
|
||||
**Дата:** 24.05.2026
|
||||
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
|
||||
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
|
||||
|
||||
**Что изменилось в v1.40 относительно v1.39:** Делегирование проектным AI-агентам — §2.4 (новая подсекция) описывает обязанность контроллера передавать класс задач 4 узко-специализированным агентам в `.claude/agents/`: `normative-sync` (#84, синк 4 нормативных файлов после крупной задачи), `prod-deploy-validator` (#85, 8 SSH pre-flight перед выкатом на liderra.ru), плюс прежние `pest-parallel-debugger` и `rls-reviewer`. Project-агенты регистрируются в `docs/registry/nodes.yaml` (subcategory `project-agent`) для missed-activation детектора, но **не входят в Tooling канон счётчиков** #1-#83 (footer-числа не двигаются). Архитектурных изменений §§1, §3–§16: 0. Связано: CLAUDE.md v2.28+ (§3.9), spec `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md`, agent files `.claude/agents/{normative-sync,prod-deploy-validator}.md`.
|
||||
|
||||
**Что изменилось в v1.39 относительно v1.38:** C1 marketing-tooling — §13.2 +абзац «Off-phase marketing-tooling»: #74 marketing (Anthropic, первичный решатель C1), #75 marketingskills (вендорен MIT, материал/резерв), #76 brand-voice (Anthropic, вербальный бренд), #77 marketing-ru (self-authored project-скил, РФ-специфика + 152-ФЗ маркетинг), #78 Яндекс.Метрика MCP (READ-ONLY), #79 Яндекс.Директ+Wordstat MCP (**Wordstat-only**, Direct-мутации отключены per IS9), #80 Telegram MCP, #81 Postiz (self-host, AGPL-3.0 internal), #82 DataForSEO (**DEFERRED**, pending Б-1/бюджет), #83 Unisender Go (**DEFERRED**, pending согласования + 152-ФЗ). 18-я off-phase подкатегория, раздел C1. Не UI → вне R6.0/R6.1/R14. Границы — ADR-015. Счётчики — канон Tooling §0. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.23+, PSR_v1 v3.22+, CLAUDE.md v2.27+; план `docs/superpowers/plans/2026-05-22-c1-marketing-tooling.md`.
|
||||
|
||||
**Что изменилось в v1.38 относительно v1.37:** A8 infosec install-sync — ZAP #68 + Ward #70 установлены портативно 21.05.2026 (без choco, по выбору заказчика «оба портативно») → в §13.2 абзаце «Off-phase infosec-tooling» статус **PENDING INSTALL снят** для обоих (ZAP: ZAP 2.17.0 + MCP-аддон на portable Temurin JRE 17; Ward: собран portable Go → `bin/ward.exe` v0.4.1); setup-доки `docs/security/{zap,ward}-setup.md`. Архитектурных изменений §§1–16: 0. Связано: Tooling v2.21, PSR_v1 v3.21, CLAUDE.md v2.25; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
@@ -238,6 +240,27 @@ Claude — **системный архитектор-документалист*
|
||||
- Не продолжает работу при обнаружении противоречия между файлами архива «молча» — всегда сообщает заказчику.
|
||||
- Не загружает в контекст всю документацию v8.0+ целиком — обращается по разделам через `project_knowledge_search`.
|
||||
|
||||
### 2.4. Делегирование проектным AI-агентам (v1.40)
|
||||
|
||||
В `.claude/agents/` лежат проектные AI-агенты (Sonnet 4.6, узко-специализированные). Контроллер ОБЯЗАН делегировать им следующие классы задач:
|
||||
|
||||
| Условие | Агент | Brief-фраза заказчика-триггер |
|
||||
|---------|-------|-------------------------------|
|
||||
| Закрытие крупной off-phase интеграции / brain governance артефакта / принятого ADR — синк 4 нормативных файлов (Pravila / PSR_v1 / Tooling / CLAUDE.md) | `normative-sync` (#84, [agent file](../.claude/agents/normative-sync.md), [spec §3](superpowers/specs/2026-05-24-controller-offload-agents-design.md)) | «синкни нормативку», «закрыли X — нужен синк» |
|
||||
| Любой выкат на боевой `liderra.ru` (push с миграциями, scp-патч, ручной деплой) — 8 pre-flight SSH проверок | `prod-deploy-validator` (#85, [agent file](../.claude/agents/prod-deploy-validator.md), [spec §4](superpowers/specs/2026-05-24-controller-offload-agents-design.md)) | «проверь готовность боевого», «ready to deploy» |
|
||||
| Диагностика Pest 4 `--parallel` failures (квирки 72/73/77) | `pest-parallel-debugger` ([agent file](../.claude/agents/pest-parallel-debugger.md)) | «pest упал на parallel», «классифицируй фейл» |
|
||||
| RLS-compliance review при правке `db/schema.sql` или `db/migrations/` | `rls-reviewer` ([agent file](../.claude/agents/rls-reviewer.md)) | «проверь RLS на миграции», «RLS review» |
|
||||
|
||||
**Дисциплина делегирования:**
|
||||
|
||||
- Контроллер вызывает агента **по триггеру**, не дожидаясь явного запроса заказчика — для `normative-sync` сразу после крупной задачи; для `prod-deploy-validator` обязательно ДО любых SSH-команд на боевой.
|
||||
- Заказчик может явно отменить вызов («не зови сейчас X»). Live-отмена — только на текущее действие.
|
||||
- Если агент возвращает эскалацию — контроллер передаёт её заказчику без угадывания, не пытается обойти.
|
||||
- Project-агенты **не входят в Tooling Прил. Н канон счётчиков** (#1-#83 — реестр инструментов; project-агенты идут #84+ в `docs/registry/nodes.yaml` с подкатегорией `project-agent`). Footer-счётчики «N формализованных» изменяются только при добавлении в Tooling-канон, не при добавлении project-агента.
|
||||
- Полный perimeter / триггеры / границы — в `description`-frontmatter каждого агент-файла. Это первичный источник «когда звать»; настоящая таблица — индекс контракта, не дублирующая декларация.
|
||||
|
||||
**Naming-convention:** `agent_file: ".claude/agents/<slug>.md"` атрибут в `docs/registry/nodes.yaml` маркирует узел как project-агент (отличает от Tooling-инструментов, у которых `tooling_section` атрибут).
|
||||
|
||||
---
|
||||
|
||||
## 3. Формат ответов и работы с файлами
|
||||
|
||||
@@ -368,9 +368,89 @@ Existing prose follows the table.
|
||||
|
||||
| ID | Узел | Категория | Статус |
|
||||
|---|---|---|---|
|
||||
| #2 | Playwright MCP | phase-0 | active |
|
||||
| #3 | GitHub MCP | phase-0 | active |
|
||||
| #4 | markdownlint-cli2 | phase-0 | active |
|
||||
| #5 | cspell | phase-0 | active |
|
||||
| #6 | lychee | phase-0 | active |
|
||||
| #7 | Stylelint | phase-0 | active |
|
||||
| #8 | gitleaks | phase-0 | active |
|
||||
| #9 | Pa11y | phase-0 | active |
|
||||
| #10 | Laravel Boost | phase-1 | active |
|
||||
| #11 | Laravel Pint | phase-1 | active |
|
||||
| #12 | Larastan | phase-1 | active |
|
||||
| #13 | Roave/SecurityAdvisories | phase-1 | active |
|
||||
| #14 | Laravel IDE Helper | phase-1 | active |
|
||||
| #15 | squawk | phase-1 | active |
|
||||
| #16 | pgFormatter | phase-1 | active |
|
||||
| #17 | pg_partman | phase-1 | dormant |
|
||||
| #19 | Superpowers v5.1.0 | phase-2 | active |
|
||||
| #18 | Pest 4 | phase-1 | active |
|
||||
| #1 | PostgreSQL MCP | phase-0 | historic |
|
||||
| #20 | Volar | phase-2 | active |
|
||||
| #21 | vue-tsc | phase-2 | active |
|
||||
| #22 | ESLint + Prettier + plugin-vue + config-prettier | phase-2 | active |
|
||||
| #23 | Vitest | phase-2 | active |
|
||||
| #24 | Histoire | phase-2 | active |
|
||||
| #25 | Semgrep + Semgrep MCP | phase-3 | active |
|
||||
| #26 | Trivy | phase-3 | active |
|
||||
| #27 | GitHub Dependabot | phase-3 | active |
|
||||
| #28 | pg_audit | phase-3 | active |
|
||||
| #29 | pg_anonymizer | phase-3 | active |
|
||||
| #30 | Frontend Design plugin | phase-2 | active |
|
||||
| #31 | UI UX Pro Max | off-phase | active |
|
||||
| #32 | 21st.dev Magic MCP | off-phase | active |
|
||||
| #33 | claude-md-management | off-phase | active |
|
||||
| #34 | Sentry MCP | off-phase | active |
|
||||
| #35 | Redis MCP | off-phase | active |
|
||||
| #36 | adr-kit | off-phase | active |
|
||||
| #37 | mermaid-skill | off-phase | active |
|
||||
| #38 | architecture-patterns | off-phase | active |
|
||||
| #39 | Trail of Bits Skills | off-phase | active |
|
||||
| #40 | Security Guidance | off-phase | active |
|
||||
| #41 | CCPM | off-phase | active |
|
||||
| #42 | product-management | off-phase | active |
|
||||
| #43 | deptrac | off-phase | active |
|
||||
| #44 | Figma MCP | off-phase | deferred |
|
||||
| #45 | Universal Icons MCP | off-phase | active |
|
||||
| #46 | Design plugin | off-phase | active |
|
||||
| #47 | openapi-mcp-server | off-phase | active |
|
||||
| #48 | promptfoo | off-phase | active |
|
||||
| #49 | Data Scientist skill | off-phase | active |
|
||||
| #50 | Jupyter MCP | off-phase | deferred |
|
||||
| #51 | operations | off-phase | active |
|
||||
| #52 | process-modeling | off-phase | active |
|
||||
| #53 | process-analysis | off-phase | active |
|
||||
| #54 | n8n-mcp | off-phase | deferred |
|
||||
| #55 | discovery-interview | off-phase | active |
|
||||
| #56 | skill-creator | off-phase | active |
|
||||
| #57 | plugin-dev | off-phase | active |
|
||||
| #58 | hookify | off-phase | active |
|
||||
| #59 | claude-code-setup | off-phase | active |
|
||||
| #60 | context7 | off-phase | active |
|
||||
| #61 | finance plugin | off-phase | active |
|
||||
| #62 | billing-audit | off-phase | active |
|
||||
| #63 | ru-tax-accounting | off-phase | active |
|
||||
| #64 | Rector | off-phase | active |
|
||||
| #65 | PHP Insights | off-phase | active |
|
||||
| #66 | laravel-backend-patterns | off-phase | active |
|
||||
| #67 | NightOwl | off-phase | deferred |
|
||||
| #68 | OWASP ZAP | off-phase | active |
|
||||
| #69 | Nuclei | off-phase | active |
|
||||
| #70 | Ward | off-phase | active |
|
||||
| #71 | pdn-152fz-audit | off-phase | active |
|
||||
| #72 | threat-model | off-phase | active |
|
||||
| #73 | security-go-live | off-phase | active |
|
||||
| #74 | marketing | off-phase | active |
|
||||
| #75 | marketingskills | off-phase | active |
|
||||
| #76 | brand-voice | off-phase | active |
|
||||
| #77 | marketing-ru | off-phase | active |
|
||||
| #78 | Яндекс.Метрика MCP | off-phase | active |
|
||||
| #79 | Яндекс.Директ+Wordstat MCP | off-phase | active |
|
||||
| #80 | Telegram MCP | off-phase | active |
|
||||
| #81 | Postiz | off-phase | active |
|
||||
| #82 | DataForSEO MCP | off-phase | deferred |
|
||||
| #83 | Unisender Go MCP | off-phase | deferred |
|
||||
|
||||
<!-- auto:tooling-registry-summary:end -->
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"2026-05": {
|
||||
"WIN_USER_PATH": 6
|
||||
}
|
||||
}
|
||||
+31
-5
@@ -1,23 +1,49 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-23T11:29:38.516Z
|
||||
Last updated: 2026-05-25T04:31:41.337Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
|
||||
| C2 Cross-ref consistency | 🔴 | Update cross-refs in offending files. |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ⚠️ | 155 episode(s) this month · Stop-hook + post-commit OK · 21 missed activation(s) — see /brain-retro |
|
||||
| C5 Observer-coverage | ⚠️ | 341 episode(s) this month · Stop-hook + post-commit OK · 21 missed activation(s) — see /brain-retro |
|
||||
| C6 Chain map sync | ✅ | [chain-map-checker] OK — 16 chains in sync |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 155 episodes this month, 0 observer_error markers, 74 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 16
|
||||
- Observer evidence: 341 episodes this month, 0 observer_error markers, 31 PII matches before filter
|
||||
- Legacy v1 episodes (not in factor analysis): 202
|
||||
- Last /brain-retro: 0 day(s) ago
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). missed_activations: 21. **Неиспользованные узлы — не алерт, если профильной задачи не было** (Pravila §16.4 v1.36; capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Метрики дисциплины
|
||||
|
||||
Baseline дисциплины роутера (этап 2 router discipline overhaul, spec 2026-05-23). Цель — увидеть «точку До» перед enforcement-хуком этапа 3.
|
||||
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
| analysis | 15 | 46.7% | 26.7% |
|
||||
| monitoring | 12 | 0.0% | 0.0% |
|
||||
| bugfix | 10 | 40.0% | 40.0% |
|
||||
| planning | 9 | 11.1% | 22.2% |
|
||||
| feature | 9 | 22.2% | 0.0% |
|
||||
| refactor | 1 | 0.0% | 0.0% |
|
||||
| cleanup | 1 | 0.0% | 0.0% |
|
||||
|
||||
Router step distribution: 1: 139, 2: 118, 3: 37, 5: 42
|
||||
|
||||
Boundaries applied (ADR / границы): 47 of 336 эпизодов (14.0%).
|
||||
|
||||
## Активные многоэтапные проекты
|
||||
|
||||
- **Router discipline overhaul** ([spec](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md))
|
||||
- Этап 1 (машиночитаемый реестр) ✅ закрыт 2026-05-23 — `docs/registry/nodes.yaml` (83 узла + 16 chains L1-L16), `tools/registry-load.mjs` + `tools/registry-render.mjs` (16 тестов), auto-render Tooling §4.0 + routing-off-phase, lefthook job 17 (warn-only).
|
||||
- Этап 2 (измерения + классификатор-парсер) ✅ закрыт 2026-05-24 + влит в main 2026-05-24 — discipline-metrics (3 среза), brain-retro-analyzer переключён на реестр, STATUS.md блок «Метрики дисциплины», baseline snapshot `docs/observer/baselines/2026-05-24-pre-enforcement.md`. Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`.
|
||||
- Этап 3 (принуждение — хук на routing) — Phase A+B (классификатор + 3 хука: router-prehook/tool-gate/stop-gate в `.claude/settings.json`) ✅ + влит в main 2026-05-24. Гейт работает в режиме **`warn-only`** (только stderr-предупреждения, никакой блокировки). Bug-fix `bec69aa5`: `deriveRouterStep` в `tools/discipline-metrics.mjs` — шаг роутера теперь выводится из наблюдаемых признаков (был захардкоженной константой 1). **Follow-up 3 fixes 2026-05-24** (после ANTHROPIC_API_KEY + рестарта CC выявлены при инспекции state): (a) UTF-8 stdin helper `tools/router-stdin-helper.mjs` через `StringDecoder` + подключение к 3 хукам (русский в state-файл и Anthropic API без mojibake); (b) `tools/observer-state-enricher.mjs` — pure helper для чтения `router-state-<session>.json`; (c) `parseTranscript` обогащение `primary_rationale` 4 полями (`recommended_node` override + `recommended_chain` + `chain_progress` + `chain_completed`). 538 tools-тестов GREEN. Plan: `docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md`. CHECKPOINT B: дать warn-only накопить реальные наблюдения с **починенным** сторожем (план говорит «минимум 24 часа»), затем Task 9 — переключение в `enforce` + 2 новых метрики (domain-hit-rate / chain-completion). Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`.
|
||||
- Этап 4 (уборка устаревших правил, deprecation `observer-classification-map.json` → удаление) — не начат.
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
✅ — норма ・ ⚠️ — внимание ・ 🔴 — действие требуется ・ ⚪ — не запускалось
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
- **Router discipline overhaul** ([spec](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md))
|
||||
- Этап 1 (машиночитаемый реестр) ✅ закрыт 2026-05-23 — `docs/registry/nodes.yaml` (83 узла + 16 chains L1-L16), `tools/registry-load.mjs` + `tools/registry-render.mjs` (16 тестов), auto-render Tooling §4.0 + routing-off-phase, lefthook job 17 (warn-only).
|
||||
- Этап 2 (измерения + классификатор-парсер) ✅ закрыт 2026-05-24 + влит в main 2026-05-24 — discipline-metrics (3 среза), brain-retro-analyzer переключён на реестр, STATUS.md блок «Метрики дисциплины», baseline snapshot `docs/observer/baselines/2026-05-24-pre-enforcement.md`. Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`.
|
||||
- Этап 3 (принуждение — хук на routing) — Phase A+B (классификатор + 3 хука: router-prehook/tool-gate/stop-gate в `.claude/settings.json`) ✅ + влит в main 2026-05-24. Гейт работает в режиме **`warn-only`** (только stderr-предупреждения, никакой блокировки). Bug-fix `bec69aa5`: `deriveRouterStep` в `tools/discipline-metrics.mjs` — шаг роутера теперь выводится из наблюдаемых признаков (был захардкоженной константой 1). **Follow-up 3 fixes 2026-05-24** (после ANTHROPIC_API_KEY + рестарта CC выявлены при инспекции state): (a) UTF-8 stdin helper `tools/router-stdin-helper.mjs` через `StringDecoder` + подключение к 3 хукам (русский в state-файл и Anthropic API без mojibake); (b) `tools/observer-state-enricher.mjs` — pure helper для чтения `router-state-<session>.json`; (c) `parseTranscript` обогащение `primary_rationale` 4 полями (`recommended_node` override + `recommended_chain` + `chain_progress` + `chain_completed`). 538 tools-тестов GREEN. Plan: `docs/superpowers/plans/2026-05-24-router-stage3-three-fixes.md`. CHECKPOINT B: дать warn-only накопить реальные наблюдения с **починенным** сторожем (план говорит «минимум 24 часа»), затем Task 9 — переключение в `enforce` + 2 новых метрики (domain-hit-rate / chain-completion). Plan: `docs/superpowers/plans/2026-05-24-router-overhaul-stage-3-enforcement.md`.
|
||||
- Этап 4 (уборка устаревших правил, deprecation `observer-classification-map.json` → удаление) — не начат.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Baseline дисциплины роутера — pre-enforcement snapshot
|
||||
|
||||
**Дата:** 2026-05-24
|
||||
**Источник данных:** `docs/observer/episodes-2026-05.jsonl`
|
||||
**Этап:** Router discipline overhaul, Stage 2 (Measurements). Зафиксирован для сравнения с пост-enforcement цифрами этапа 3.
|
||||
**Spec:** `docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md`
|
||||
**Plan:** `docs/superpowers/plans/2026-05-24-router-overhaul-stage-2-measurements.md`
|
||||
**Commit:** e239160a (snapshot creation) → 436284c5 (F1 top-5 nodes fix)
|
||||
|
||||
## Объём данных
|
||||
|
||||
- Эпизодов всего: 129 (124 v2+ + 5 v1)
|
||||
- v2+ эпизодов (анализируется): 124
|
||||
- v1 эпизодов пропущено: 5
|
||||
- Observer-error маркеров: 0
|
||||
|
||||
## Цифры
|
||||
|
||||
### Дисциплина по типам задач
|
||||
|
||||
| Тип задачи | Эпизодов | % с триггер-матчем | % через скил |
|
||||
|---|---|---|---|
|
||||
| bugfix | 6 | 33.3% | 33.3% |
|
||||
| analysis | 4 | 0% | 25.0% |
|
||||
| feature | 5 | 0% | 0% |
|
||||
| planning | 2 | 0% | 0% |
|
||||
| refactor | 1 | 0% | 0% |
|
||||
| cleanup | 1 | 0% | 0% |
|
||||
| monitoring | 1 | 0% | 0% |
|
||||
|
||||
### Распределение по шагам роутера
|
||||
|
||||
- distribution: `{"1": 124}`
|
||||
- total: 124
|
||||
- **suspicious: true** — >90% эпизодов остановились на step=1; sentinel-bug парсера, требует исследования в этапе 3
|
||||
|
||||
### Применение границ (ADR)
|
||||
|
||||
- Total: 124
|
||||
- With boundaries: 13
|
||||
- Rate: 10.5%
|
||||
- By path_type:
|
||||
- `improvised`: 112 эпизодов, 11 с boundaries, 9.8%
|
||||
- `regulated`: 12 эпизодов, 2 с boundaries, 16.7%
|
||||
|
||||
### Missed activations
|
||||
|
||||
- Total: 17
|
||||
|
||||
By classification:
|
||||
|
||||
```json
|
||||
{
|
||||
"bugfix": 4,
|
||||
"feature": 5,
|
||||
"refactor": 1,
|
||||
"planning": 2,
|
||||
"cleanup": 1,
|
||||
"monitoring": 1,
|
||||
"analysis": 3
|
||||
}
|
||||
```
|
||||
|
||||
By node (top 5 по количеству):
|
||||
|
||||
```json
|
||||
{
|
||||
"#19": 12,
|
||||
"#34": 5,
|
||||
"#18": 4,
|
||||
"#25": 3,
|
||||
"#39": 3
|
||||
}
|
||||
```
|
||||
|
||||
*(#53 также имеет count 3, следующие: #11:#12:#41:#42 = 2)*
|
||||
|
||||
## Контекст
|
||||
|
||||
Это «точка До» перед включением enforcement-хука этапа 3. После недели работы хука повторно снимем эти цифры и сравним.
|
||||
|
||||
**Цели overhaul'а (из spec'а §acceptance criteria):**
|
||||
|
||||
- Дисциплина (% эпизодов с матченным триггером на классифицированных задачах): **≥75%** (baseline зафиксирован выше — сейчас 33.3% лишь у bugfix, остальные 0%).
|
||||
- Missed activations: **≤5/неделю** (baseline: 17 за месяц).
|
||||
- % feature/planning без skill: **≤10%** (baseline: feature 0%, planning 0% — обе категории нарушают цель).
|
||||
|
||||
## Заметка о suspicious-флаге
|
||||
|
||||
`suspicious: true` в `routerStep` указывает, что **все 124 v2+ эпизода имеют `step=1`**. Это означает, что парсер `tools/observer-transcript-parser.mjs` пока не enrich'ит фактический шаг роутера — поле `primary_rationale.step` сейчас постоянно `1` (sentinel default). Этот пропуск самой инструментовки наблюдателя — отдельный задел для этапа 3 (нужно либо расширить парсер, чтобы он различал шаги, либо явно вычислять step из контекста). До этого срез по router_step **не информативен**.
|
||||
|
||||
## Воспроизводимость
|
||||
|
||||
```bash
|
||||
node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl
|
||||
```
|
||||
|
||||
Источник classificationMap + dormancy — `docs/registry/nodes.yaml` (через `tools/registry-to-classification-map.mjs`).
|
||||
File diff suppressed because one or more lines are too long
+102
-2
@@ -1,5 +1,105 @@
|
||||
# Node Registry
|
||||
|
||||
Машиночитаемый реестр узлов тулчейна Лидерры. Single source of truth для router-procedure.md и хуков enforcement'а.
|
||||
Машиночитаемый реестр узлов тулчейна Лидерры — single source of truth для `router-procedure.md`, хуков enforcement'а (этапы 2-3 router discipline overhaul) и auto-rendered секций в нормативке.
|
||||
|
||||
**Полная документация:** TBD (закроется в Task 13).
|
||||
## Файлы
|
||||
|
||||
- **`nodes.yaml`** — реестр 83 узлов + 16 цепочек L1-L16. Источник истины.
|
||||
- **`schema.json`** — JSON Schema, валидация `nodes.yaml` при загрузке.
|
||||
- **`README.md`** — этот файл.
|
||||
|
||||
## Как читать узел
|
||||
|
||||
```yaml
|
||||
- id: "#19" # уникальный идентификатор из Tooling Прил. Н §0
|
||||
name: "Superpowers v5.1.0"
|
||||
slug: "superpowers" # каноническое имя для invocation (kebab-ASCII)
|
||||
category: "phase-2" # phase-0 / phase-1 / phase-2 / phase-3 / off-phase
|
||||
subcategory: null # либо строка (architecture-tooling, debug-runtime, ...)
|
||||
status: "active" # active | dormant | deferred | historic
|
||||
dormancy_reason: null # null если active, иначе текст причины
|
||||
triggers: # как роутер выбирает узел
|
||||
- {classification: "feature", weight: 1.0}
|
||||
- {keyword: "tdd", weight: 1.0}
|
||||
- {file_pattern: "tests/**/*.php", weight: 1.0}
|
||||
boundaries: # связи с другими узлами (ADR, paired stack, replaces)
|
||||
- {adr: "ADR-011", role: "hard-floor source"}
|
||||
- {pair: "#30", relation: "paired stack"}
|
||||
chain_membership: ["L1", "L8"] # в каких L-цепочках участвует (sorted)
|
||||
attributes: # свободная map для прочих метаданных
|
||||
tooling_section: "§3.3 #19"
|
||||
install: "marketplace plugin"
|
||||
```
|
||||
|
||||
### Status маппинг
|
||||
|
||||
| Status | Что значит |
|
||||
|---|---|
|
||||
| `active` | Узел активно используется. |
|
||||
| `dormant` | Узел отключён/заменён без эквивалента. Артефакт реестра сохраняется (#17 pg_partman — заменён ручным cron'ом). |
|
||||
| `deferred` | Узел запланирован, но pending Б-1 / undeployed dependencies (#34 Sentry, #44 Figma, #67 NightOwl, #82 DataForSEO, #83 Unisender Go). |
|
||||
| `historic` | Узел заменён другим узлом реестра (`{pair: "#N", relation: "replaced by"}`). #1 PG MCP заменён #10 Boost. |
|
||||
|
||||
### Trigger типы
|
||||
|
||||
- `{keyword: "<lowercase trimmed>", weight}` — exact-match по фразе.
|
||||
- `{classification: "<class>", weight}` — соответствие классу задачи (feature/planning/bugfix/refactor/...).
|
||||
- `{file_pattern: "<glob>", weight}` — соответствие пути файла (`tests/**/*.php`).
|
||||
|
||||
Weight — number ∈ `[0, 1]`. По умолчанию 1.0.
|
||||
|
||||
### Boundaries
|
||||
|
||||
- `{adr: "ADR-XXX", role: "<role>"}` — узел связан с ADR-решением.
|
||||
- `{pair: "#N", relation: "<rel>"}` — узел связан с другим узлом реестра (`replaces`, `replaced by`, `paired stack`).
|
||||
- `{relation: "<text>"}` — свободная связь (правила PSR_v1, описательная роль).
|
||||
|
||||
## Как добавить новый узел
|
||||
|
||||
1. Получить новый `#N` из [Tooling Прил. Н §0](../Tooling_v8_3.md) (канон счётчика).
|
||||
2. Открыть `nodes.yaml`, добавить блок в массив `nodes:` (в правильное место по числовой сортировке).
|
||||
3. **Триггеры:** что должен сказать заказчик / какой класс задач включает узел. Lowercase, trimmed, без двоеточий.
|
||||
4. **Границы:** какие ADR разделяют узел от соседей, есть ли paired stack.
|
||||
5. Прогнать рендер: `node tools/registry-render.mjs` — должно перерендерить `Tooling §4.0` + `routing-off-phase` routing-table.
|
||||
6. Запустить тесты: `cd app && npx vitest --config vitest.config.tools.mjs run ../tools/registry-load.test.mjs`. Все должны быть GREEN.
|
||||
7. Закоммитить YAML + Tooling/routing-off-phase одним коммитом.
|
||||
|
||||
## Auto-render
|
||||
|
||||
`tools/registry-render.mjs` пишет в auto-region маркеры:
|
||||
|
||||
- `<!-- auto:tooling-registry-summary:begin -->` в `docs/Tooling_v8_3.md` §4.0 (краткая сводка 83 узлов).
|
||||
- `<!-- auto:routing-table:begin -->` в `docs/routing-off-phase.md` (routing-table по classifications).
|
||||
|
||||
**Не правьте содержимое между маркерами вручную** — оно перезатрётся при следующем рендере. Для изменения структуры таблицы — правьте `tools/registry-render.mjs` renderer functions.
|
||||
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
node tools/registry-render.mjs # переписать файлы
|
||||
node tools/registry-render.mjs --check # exit 1 если drift (для lefthook)
|
||||
```
|
||||
|
||||
## Lefthook gate
|
||||
|
||||
`registry-render-check` — pre-commit job 17 в `lefthook.yml`. Триггерится на изменения `docs/registry/nodes.yaml` / `docs/Tooling_v8_3.md` / `docs/routing-off-phase.md`. **Warn-only первую неделю** (`if/then/fi` block, exit 0 даже при drift). Если видишь WARN — запусти:
|
||||
|
||||
```bash
|
||||
node tools/registry-render.mjs && git add docs/Tooling_v8_3.md docs/routing-off-phase.md
|
||||
```
|
||||
|
||||
После стабилизации (когда команда привыкнет к workflow) — убрать warn-fallback и сделать blocking.
|
||||
|
||||
## Цепочки L1-L16
|
||||
|
||||
16 канонических связок 2+ узлов (см. `chains:` секцию в `nodes.yaml`). Источник истины — [`docs/routing-off-phase.md`](../routing-off-phase.md) §4 (таблица L1-L16). При изменении routing-off-phase — обновляйте chains в `nodes.yaml` синхронно.
|
||||
|
||||
## Связано
|
||||
|
||||
- Spec: [`docs/superpowers/specs/2026-05-23-router-discipline-overhaul-design.md`](../superpowers/specs/2026-05-23-router-discipline-overhaul-design.md)
|
||||
- Plan этап 1: [`docs/superpowers/plans/2026-05-23-router-overhaul-stage-1-registry.md`](../superpowers/plans/2026-05-23-router-overhaul-stage-1-registry.md)
|
||||
- Router procedure: [`docs/router-procedure.md`](../router-procedure.md) (5-шаговая процедура «task → node»)
|
||||
- Routing-off-phase: [`docs/routing-off-phase.md`](../routing-off-phase.md) (триггеры + L-цепочки)
|
||||
- ADR-011 — brain governance.
|
||||
- Pravila §15.2 — pre-flight sync для нормативных файлов.
|
||||
- Pure modules: `tools/registry-load.mjs` + `tools/registry-render.mjs` + tests `tools/registry-*.test.mjs`.
|
||||
|
||||
+1151
-17
File diff suppressed because it is too large
Load Diff
@@ -26,11 +26,40 @@
|
||||
|
||||
| Классификация | Рекомендуемый узел | Вес |
|
||||
|---|---|---|
|
||||
| `analysis` | #25 Semgrep + Semgrep MCP | 1 |
|
||||
| `analysis` | #39 Trail of Bits Skills | 1 |
|
||||
| `analysis` | #53 process-analysis | 1 |
|
||||
| `bugfix` | #18 Pest 4 | 1 |
|
||||
| `bugfix` | #34 Sentry MCP | 1 |
|
||||
| `bugfix` | #19 Superpowers v5.1.0 | 0.8 |
|
||||
| `cleanup` | #11 Laravel Pint | 1 |
|
||||
| `cleanup` | #12 Larastan | 1 |
|
||||
| `feature` | #19 Superpowers v5.1.0 | 1 |
|
||||
| `marketing` | #74 marketing | 1 |
|
||||
| `marketing` | #75 marketingskills | 1 |
|
||||
| `marketing` | #76 brand-voice | 1 |
|
||||
| `marketing` | #77 marketing-ru | 1 |
|
||||
| `marketing` | #78 Яндекс.Метрика MCP | 1 |
|
||||
| `marketing` | #79 Яндекс.Директ+Wordstat MCP | 1 |
|
||||
| `marketing` | #80 Telegram MCP | 1 |
|
||||
| `marketing` | #81 Postiz | 1 |
|
||||
| `monitoring` | #34 Sentry MCP | 1 |
|
||||
| `monitoring` | #35 Redis MCP | 1 |
|
||||
| `planning` | #19 Superpowers v5.1.0 | 1 |
|
||||
| `planning` | #41 CCPM | 1 |
|
||||
| `planning` | #42 product-management | 1 |
|
||||
| `refactor` | #11 Laravel Pint | 1 |
|
||||
| `refactor` | #12 Larastan | 1 |
|
||||
| `refactor` | #43 deptrac | 1 |
|
||||
| `refactor` | #64 Rector | 1 |
|
||||
| `refactor` | #65 PHP Insights | 1 |
|
||||
| `refactor` | #19 Superpowers v5.1.0 | 0.8 |
|
||||
| `security` | #68 OWASP ZAP | 1 |
|
||||
| `security` | #69 Nuclei | 1 |
|
||||
| `security` | #70 Ward | 1 |
|
||||
| `security` | #71 pdn-152fz-audit | 1 |
|
||||
| `security` | #72 threat-model | 1 |
|
||||
| `security` | #73 security-go-live | 1 |
|
||||
|
||||
<!-- auto:routing-table:end -->
|
||||
|
||||
|
||||
@@ -27,13 +27,15 @@
|
||||
|
||||
- [x] **#7 RLS-аудит** ✅ DONE+прод+smoke (push `fb4e711b`, 23.05 утром)
|
||||
- [x] **#1 hash-chain validator** ✅ DONE+прод+smoke (push `a195611d`, 23.05 утром; per-RLS-scope находка)
|
||||
- [ ] **#2 partitioning** ⏸ сознательно отложено — большая миграция боевой БД, отдельная сессия (заказчик 23.05 вечером)
|
||||
- [x] **#2 partitioning** ✅ DONE+прод+smoke (23.05 поздний вечер) — 7 audit-таблиц RANGE-партиционированы помесячно через rewrite SQL `db/migrations/2026_05_23_hole2_partition_audit_tables.sql`; репетиция на копии прод-данных (liderra_rehearsal) ДО боя; chains intact, lkomega(t2 info@lkomega.ru) цел 414/275, retention cron Sundays 03:00
|
||||
- [x] **#3+#5 watcher coverage** ✅ DONE+прод+smoke (push `527f628a`, 23.05 вечером; +failed_jobs + 3 правила: spike/daily-total/persistent)
|
||||
- [x] **#6 heartbeat** ✅ DONE+прод+smoke (push `c76038d0`+hotfix `33462bf5`, 23.05 вечером; schema v8.30, 12 baseline rows)
|
||||
- [x] **#4 152-ФЗ minimum** ✅ DONE+прод+smoke (push `77e98afa`+Eloquent fix `f5482f4`, 23.05 вечером; backend + frontend build deploy)
|
||||
- [x] **Финал:** ПИЛОТ.md / memory sync ✅ — этот документ (UI-приёмка #4 в админке — за заказчиком)
|
||||
|
||||
**Итог:** 6 из 7 дыр закрыты на боевой liderra.ru за 23.05.2026. #2 — единственная оставшаяся, отдельная сессия (миграция БД).
|
||||
**Итог:** ВСЕ 7 дыр закрыты на боевой liderra.ru за 23.05.2026. Эпик завершён.
|
||||
|
||||
**NB (находка при #2):** `db/schema.sql` разошёлся с реальной прод-структурой для 4 таблиц (`activity_log`/`webhook_log`/`balance_transactions`/`pd_processing_log` — разные колонки). Phase A строила schema.sql v8.31 по schema.sql-колонкам; прод-rewrite (`db/migrations/2026_05_23_hole2_partition_audit_tables.sql`) построен из реального `pg_dump` прода (источник истины). Ресинхронизация schema.sql↔prod — отдельная задача вне scope #2.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,898 @@
|
||||
# Admin Tenant Balance Edit Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Дать SaaS-админу установить точный рублёвый баланс тенанта из админки (карточка тенанта + инлайн в таблице списка), с записью в ledger + audit-log.
|
||||
|
||||
**Architecture:** Новый эндпоинт `PATCH /api/admin/tenants/{id}/balance` (метод `AdminTenantsController::updateBalance`) под `saas-admin` middleware. Семантика «установить точную сумму»: сервер считает знаковую дельту `target − current`, применяет под `lockForUpdate` через bcmath, пишет `balance_transactions(type='manual_adjustment')` + `saas_admin_audit_log`. Frontend — общий `TenantBalanceDialog.vue`, открывается из `TenantDetailHeader` (карточка «Баланс») и из строки `TenantsTable`.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / Vue 3.5 / Vuetify 3.12 / Vitest 4 / bcmath / PostgreSQL 16.
|
||||
|
||||
**Spec:** [../specs/2026-05-23-admin-tenant-balance-edit-design.md](../specs/2026-05-23-admin-tenant-balance-edit-design.md)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Modify** `app/app/Http/Controllers/Api/AdminTenantsController.php` — добавить `use` трейта/моделей + метод `updateBalance`.
|
||||
- **Modify** `app/routes/web.php` — 1 строка маршрута в группе `saas-admin` (рядом с tenants lookup).
|
||||
- **Create** `app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php` — Pest feature-тест.
|
||||
- **Modify** `app/resources/js/api/admin.ts` — функция `updateTenantBalance`.
|
||||
- **Create** `app/resources/js/components/admin/TenantBalanceDialog.vue` — общий диалог.
|
||||
- **Create** `app/tests/Frontend/TenantBalanceDialog.spec.ts` — Vitest.
|
||||
- **Modify** `app/resources/js/components/admin/tenant-detail/TenantDetailHeader.vue` — кнопка в карточке «Баланс» + emit.
|
||||
- **Modify** `app/resources/js/views/admin/AdminTenantDetailView.vue` — монтаж диалога + reload по `saved`.
|
||||
- **Modify** `app/resources/js/components/admin/tenants/TenantsTable.vue` — действие в строке + emit.
|
||||
- **Modify** `app/resources/js/views/admin/AdminTenantsView.vue` — монтаж диалога + обновление строки по `saved`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Backend `updateBalance` endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Http/Controllers/Api/AdminTenantsController.php`
|
||||
- Modify: `app/routes/web.php` (~line 100, после tenants `show`)
|
||||
- Test: `app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function makeBalanceTenant(string $balanceRub): Tenant
|
||||
{
|
||||
return Tenant::factory()->create(['balance_rub' => $balanceRub]);
|
||||
}
|
||||
|
||||
it('sets exact balance and records signed manual_adjustment delta', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '2500.00',
|
||||
'reason' => 'Коррекция тестового баланса',
|
||||
]);
|
||||
|
||||
$resp->assertOk()
|
||||
->assertJsonPath('balance_rub', '2500.00')
|
||||
->assertJsonPath('delta', '1500.00');
|
||||
|
||||
$tenant->refresh();
|
||||
expect((string) $tenant->balance_rub)->toBe('2500.00');
|
||||
|
||||
$tx = BalanceTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
|
||||
->latest('id')->first();
|
||||
expect($tx)->not->toBeNull();
|
||||
expect((string) $tx->amount_rub)->toBe('1500.00');
|
||||
expect((string) $tx->balance_rub_after)->toBe('2500.00');
|
||||
expect($tx->amount_leads)->toBeNull();
|
||||
expect($tx->description)->toBe('Коррекция тестового баланса');
|
||||
});
|
||||
|
||||
it('records negative delta when lowering balance', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$resp = $this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '300.00',
|
||||
]);
|
||||
|
||||
$resp->assertOk()->assertJsonPath('delta', '-700.00');
|
||||
|
||||
$tx = BalanceTransaction::where('tenant_id', $tenant->id)
|
||||
->where('type', BalanceTransaction::TYPE_MANUAL_ADJUSTMENT)
|
||||
->latest('id')->first();
|
||||
expect((string) $tx->amount_rub)->toBe('-700.00');
|
||||
// Default description когда reason не передан.
|
||||
expect($tx->description)->toBe('Ручная корректировка баланса (админ)');
|
||||
});
|
||||
|
||||
it('accepts negative target balance (debt correction)', function () {
|
||||
$tenant = makeBalanceTenant('0.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '-500.00',
|
||||
])->assertOk()->assertJsonPath('balance_rub', '-500.00');
|
||||
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('-500.00');
|
||||
});
|
||||
|
||||
it('rejects no-op (target equals current) with 422', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '1000.00',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('rejects malformed balance_rub with 422', function () {
|
||||
$tenant = makeBalanceTenant('1000.00');
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '10.123',
|
||||
])->assertStatus(422);
|
||||
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => 'abc',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
it('returns 404 for missing or soft-deleted tenant', function () {
|
||||
$this->patchJson('/api/admin/tenants/99999999/balance', [
|
||||
'balance_rub' => '100.00',
|
||||
])->assertStatus(404);
|
||||
|
||||
$tenant = makeBalanceTenant('100.00');
|
||||
$tenant->delete();
|
||||
$this->patchJson("/api/admin/tenants/{$tenant->id}/balance", [
|
||||
'balance_rub' => '200.00',
|
||||
])->assertStatus(404);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run from `app/`: `./vendor/bin/pest tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`
|
||||
Expected: FAIL — route `PATCH /api/admin/tenants/{id}/balance` does not exist (404 on all, or method-not-allowed).
|
||||
|
||||
If the testing DB lacks the `manual_adjustment` value in `balance_transactions_type_check` or the May-2026 partition — note from prior Billing v2 work: run `php artisan migrate --env=testing` and `php artisan partitions:create-months` if a CHECK/partition error appears. `manual_adjustment` is an existing CHECK value (predates this work), so it should already be valid.
|
||||
|
||||
- [ ] **Step 3: Add imports + trait to the controller**
|
||||
|
||||
In `app/app/Http/Controllers/Api/AdminTenantsController.php`, the current `use` block is:
|
||||
|
||||
```php
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
```
|
||||
|
||||
Add these imports (alphabetical placement):
|
||||
|
||||
```php
|
||||
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\SaasAdminAuditLog;
|
||||
```
|
||||
|
||||
Add the trait inside the class (right after the class opening brace):
|
||||
|
||||
```php
|
||||
class AdminTenantsController extends Controller
|
||||
{
|
||||
use ResolvesAdminUserId;
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the `updateBalance` method**
|
||||
|
||||
Append this method to `AdminTenantsController` (after `show()`, before the private helpers):
|
||||
|
||||
```php
|
||||
/**
|
||||
* 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); // нормализуем scale 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'],
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Verify `BalanceTransaction::TYPE_MANUAL_ADJUSTMENT` constant exists (it does — `= 'manual_adjustment'`, seen in the model). If the constant name differs, grep `app/app/Models/BalanceTransaction.php` for `MANUAL_ADJUSTMENT` and use the actual name.
|
||||
|
||||
- [ ] **Step 5: Add the route**
|
||||
|
||||
In `app/routes/web.php`, inside the `Route::middleware('saas-admin')->group(...)` block, right after the tenants `show` route (line ~100):
|
||||
|
||||
```php
|
||||
Route::patch('/api/admin/tenants/{id}/balance', 'App\Http\Controllers\Api\AdminTenantsController@updateBalance')
|
||||
->where('id', '[0-9]+');
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run test to verify it passes**
|
||||
|
||||
Run: `./vendor/bin/pest tests/Feature/Admin/AdminTenantBalanceUpdateTest.php`
|
||||
Expected: PASS (6 tests).
|
||||
|
||||
If `saas_admin_audit_log` insert fails on a missing partition for the current month — create it (`php artisan partitions:create-months`) and re-run; this is the known testing-DB partition quirk, not a code bug.
|
||||
|
||||
- [ ] **Step 7: Run adjacent admin suite for regressions**
|
||||
|
||||
Run: `./vendor/bin/pest tests/Feature/Admin`
|
||||
Expected: no NEW failures (pre-existing partition-gap failures, if any, are unrelated).
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/.claude/worktrees/admin-tenant-balance-edit"
|
||||
git add app/app/Http/Controllers/Api/AdminTenantsController.php \
|
||||
app/routes/web.php \
|
||||
app/tests/Feature/Admin/AdminTenantBalanceUpdateTest.php
|
||||
git commit -m "feat(admin): PATCH tenants/{id}/balance — set exact rub balance + ledger + audit"
|
||||
```
|
||||
|
||||
Use `LEFTHOOK=0 git commit ...` if pre-commit fails on missing worktree binaries (gitleaks.exe/squawk.exe) — known worktree quirk.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Frontend API client `updateTenantBalance`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/resources/js/api/admin.ts`
|
||||
|
||||
- [ ] **Step 1: Add the function**
|
||||
|
||||
In `app/resources/js/api/admin.ts`, after the `refundTenant` function (~line 372), add:
|
||||
|
||||
```typescript
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Type-check**
|
||||
|
||||
Run from `app/`: `npm run type-check 2>&1 | grep -E "admin.ts" | head`
|
||||
Expected: no errors on `admin.ts`.
|
||||
|
||||
- [ ] **Step 3: No commit yet** — rides with Task 3 (its first consumer, the dialog).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `TenantBalanceDialog.vue` + Vitest
|
||||
|
||||
**Files:**
|
||||
- Create: `app/resources/js/components/admin/TenantBalanceDialog.vue`
|
||||
- Create: `app/tests/Frontend/TenantBalanceDialog.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Write the Vitest spec (TDD)**
|
||||
|
||||
Create `app/tests/Frontend/TenantBalanceDialog.spec.ts`:
|
||||
|
||||
```typescript
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
import TenantBalanceDialog from '../../resources/js/components/admin/TenantBalanceDialog.vue';
|
||||
import * as adminApi from '../../resources/js/api/admin';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function mountDialog(props: Record<string, unknown> = {}) {
|
||||
return mount(TenantBalanceDialog, {
|
||||
props: {
|
||||
modelValue: true,
|
||||
tenantId: 42,
|
||||
tenantName: 'Окна Москва ООО',
|
||||
currentBalanceRub: 1000,
|
||||
...props,
|
||||
},
|
||||
global: { plugins: [vuetify] },
|
||||
attachTo: document.body,
|
||||
});
|
||||
}
|
||||
|
||||
describe('TenantBalanceDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('previews signed delta when new balance entered', async () => {
|
||||
const w = mountDialog();
|
||||
const vm = w.vm as unknown as { newBalance: string; delta: string };
|
||||
vm.newBalance = '2500';
|
||||
await w.vm.$nextTick();
|
||||
// delta = 2500 − 1000 = +1500
|
||||
expect((w.vm as unknown as { delta: string }).delta).toBe('1500.00');
|
||||
});
|
||||
|
||||
it('disables save when balance empty or unchanged', async () => {
|
||||
const w = mountDialog();
|
||||
const vm = w.vm as unknown as { newBalance: string; canSave: boolean };
|
||||
vm.newBalance = '';
|
||||
await w.vm.$nextTick();
|
||||
expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(false);
|
||||
vm.newBalance = '1000'; // равно текущему
|
||||
await w.vm.$nextTick();
|
||||
expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(false);
|
||||
vm.newBalance = '1500';
|
||||
await w.vm.$nextTick();
|
||||
expect((w.vm as unknown as { canSave: boolean }).canSave).toBe(true);
|
||||
});
|
||||
|
||||
it('calls updateTenantBalance with normalized payload and emits saved', async () => {
|
||||
const spy = vi.spyOn(adminApi, 'updateTenantBalance').mockResolvedValue({
|
||||
id: 42,
|
||||
balance_rub: '2500.00',
|
||||
delta: '1500.00',
|
||||
transaction_id: 7,
|
||||
});
|
||||
const w = mountDialog();
|
||||
const vm = w.vm as unknown as { newBalance: string; reason: string; submit: () => Promise<void> };
|
||||
vm.newBalance = '2500';
|
||||
vm.reason = 'тест';
|
||||
await vm.submit();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(42, { balance_rub: '2500.00', reason: 'тест' });
|
||||
expect(w.emitted('saved')).toBeTruthy();
|
||||
expect(w.emitted('saved')![0][0]).toMatchObject({ balance_rub: '2500.00' });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run from `app/`: `npm run test:vue -- TenantBalanceDialog.spec.ts 2>&1 | tail -20`
|
||||
Expected: FAIL — component does not exist.
|
||||
|
||||
- [ ] **Step 3: Create the component**
|
||||
|
||||
Create `app/resources/js/components/admin/TenantBalanceDialog.vue`:
|
||||
|
||||
```vue
|
||||
<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);
|
||||
|
||||
// Нормализованная целевая сумма (scale 2) — '' если ввод невалиден.
|
||||
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>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run Vitest**
|
||||
|
||||
Run from `app/`: `npm run test:vue -- TenantBalanceDialog.spec.ts 2>&1 | tail -20`
|
||||
Expected: PASS (3 tests). If `extractErrorMessage` signature differs (e.g. single-arg), check `app/resources/js/api/client.ts` and adapt the call.
|
||||
|
||||
- [ ] **Step 5: vue-tsc**
|
||||
|
||||
Run: `npm run type-check 2>&1 | grep -E "TenantBalanceDialog|admin.ts" | head`
|
||||
Expected: 0 errors.
|
||||
|
||||
- [ ] **Step 6: Commit (Task 2 api + Task 3 dialog together)**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/api/admin.ts \
|
||||
app/resources/js/components/admin/TenantBalanceDialog.vue \
|
||||
app/tests/Frontend/TenantBalanceDialog.spec.ts
|
||||
git commit -m "feat(admin): TenantBalanceDialog + updateTenantBalance api client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Wire dialog into tenant detail card
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/resources/js/components/admin/tenant-detail/TenantDetailHeader.vue`
|
||||
- Modify: `app/resources/js/views/admin/AdminTenantDetailView.vue`
|
||||
|
||||
- [ ] **Step 1: Add «Изменить баланс» button to the balance KPI card**
|
||||
|
||||
In `TenantDetailHeader.vue`, the `defineEmits` is currently:
|
||||
|
||||
```typescript
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
impersonate: [];
|
||||
}>();
|
||||
```
|
||||
|
||||
Change to add `editBalance`:
|
||||
|
||||
```typescript
|
||||
const emit = defineEmits<{
|
||||
back: [];
|
||||
impersonate: [];
|
||||
editBalance: [];
|
||||
}>();
|
||||
```
|
||||
|
||||
In the template, the balance KPI card is:
|
||||
|
||||
```vue
|
||||
<v-card variant="outlined" class="kpi-card pa-4" data-testid="kpi-balance">
|
||||
<div class="kpi-label text-caption text-medium-emphasis">Баланс</div>
|
||||
<div class="kpi-value num" :class="{ 'text-error': tenant.balanceRub < 0 }">
|
||||
{{ formatRub(tenant.balanceRub) }}
|
||||
</div>
|
||||
<div class="kpi-sub text-caption text-medium-emphasis">runway ~{{ tenant.runwayDays }} дн</div>
|
||||
</v-card>
|
||||
```
|
||||
|
||||
Add an «Изменить» button after the `kpi-sub` div, inside the card:
|
||||
|
||||
```vue
|
||||
<v-card variant="outlined" class="kpi-card pa-4" data-testid="kpi-balance">
|
||||
<div class="kpi-label text-caption text-medium-emphasis">Баланс</div>
|
||||
<div class="kpi-value num" :class="{ 'text-error': tenant.balanceRub < 0 }">
|
||||
{{ 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>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Mount the dialog in the detail view**
|
||||
|
||||
In `AdminTenantDetailView.vue`:
|
||||
|
||||
Add import after the existing component imports (~line 24):
|
||||
|
||||
```typescript
|
||||
import TenantBalanceDialog from '../../components/admin/TenantBalanceDialog.vue';
|
||||
```
|
||||
|
||||
Add state near `impersonationOpen` (~line 66):
|
||||
|
||||
```typescript
|
||||
const balanceDialogOpen = ref(false);
|
||||
```
|
||||
|
||||
In the template, inside the `v-container v-if="tenant"` block, after `<ImpersonationDialog ... />`, add:
|
||||
|
||||
```vue
|
||||
<TenantBalanceDialog
|
||||
v-model="balanceDialogOpen"
|
||||
:tenant-id="tenant.id"
|
||||
:tenant-name="tenant.name"
|
||||
:current-balance-rub="tenant.balanceRub"
|
||||
@saved="onBalanceSaved"
|
||||
/>
|
||||
```
|
||||
|
||||
Wire the header emit on the `<TenantDetailHeader>` element:
|
||||
|
||||
```vue
|
||||
<TenantDetailHeader
|
||||
:tenant="tenant"
|
||||
@back="goBack"
|
||||
@impersonate="impersonationOpen = true"
|
||||
@edit-balance="balanceDialogOpen = true"
|
||||
/>
|
||||
```
|
||||
|
||||
Add the `onBalanceSaved` handler (after `goBack`):
|
||||
|
||||
```typescript
|
||||
async function onBalanceSaved(): Promise<void> {
|
||||
await loadTenant();
|
||||
}
|
||||
```
|
||||
|
||||
Add `balanceDialogOpen` to `defineExpose` so Vitest can drive it:
|
||||
|
||||
```typescript
|
||||
defineExpose({ tenant, activeTab, impersonationOpen, balanceDialogOpen, loadTenant });
|
||||
```
|
||||
|
||||
Confirm `AdminTenantDetail` mock type has a numeric `id` field (it does — `mockTenantDetail.ts:11 id: number`) and `balanceRub: number` (used by header). If `tenant.id` is absent on the mapped type, check `adminTenantDetailMapper.ts` maps `tenant.id` from the API response and add it.
|
||||
|
||||
- [ ] **Step 3: Run frontend checks**
|
||||
|
||||
Run from `app/`:
|
||||
```bash
|
||||
npm run test:vue -- AdminTenantDetailView 2>&1 | tail -20
|
||||
npm run type-check 2>&1 | grep -E "AdminTenantDetailView|TenantDetailHeader" | head
|
||||
```
|
||||
Expected: existing detail-view tests still pass; vue-tsc clean. If an existing test mounts `TenantDetailHeader` and asserts emitted events, it remains valid (we only added an emit).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/admin/tenant-detail/TenantDetailHeader.vue \
|
||||
app/resources/js/views/admin/AdminTenantDetailView.vue
|
||||
git commit -m "feat(admin): wire balance dialog into tenant detail card"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Wire dialog into tenant list table
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/resources/js/components/admin/tenants/TenantsTable.vue`
|
||||
- Modify: `app/resources/js/views/admin/AdminTenantsView.vue`
|
||||
|
||||
- [ ] **Step 1: Add row action + emit in TenantsTable**
|
||||
|
||||
In `TenantsTable.vue`, `defineEmits` is:
|
||||
|
||||
```typescript
|
||||
const emit = defineEmits<{
|
||||
rowClick: [tenant: AdminTenant];
|
||||
impersonate: [tenant: AdminTenant];
|
||||
}>();
|
||||
```
|
||||
|
||||
Add `editBalance`:
|
||||
|
||||
```typescript
|
||||
const emit = defineEmits<{
|
||||
rowClick: [tenant: AdminTenant];
|
||||
impersonate: [tenant: AdminTenant];
|
||||
editBalance: [tenant: AdminTenant];
|
||||
}>();
|
||||
```
|
||||
|
||||
In the `#[`item.actions`]` slot, add a balance-edit icon button before the impersonate tooltip (inside the same slot):
|
||||
|
||||
```vue
|
||||
<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"
|
||||
aria-label="Войти как клиент (impersonation)"
|
||||
>
|
||||
<template #activator="{ props: tipProps }">
|
||||
<v-btn
|
||||
v-bind="tipProps"
|
||||
icon="mdi-account-switch"
|
||||
variant="text"
|
||||
size="small"
|
||||
density="comfortable"
|
||||
:aria-label="`Войти как клиент (impersonation) для ${item.name}`"
|
||||
:disabled="item.status === 'suspended'"
|
||||
:data-testid="`impersonate-btn-${item.id}`"
|
||||
@click.stop="emit('impersonate', item)"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
```
|
||||
|
||||
Widen the actions column so two icons fit — change the `actions` header `width: 56` to `width: 96` in the `:headers` array.
|
||||
|
||||
- [ ] **Step 2: Mount the dialog in the list view**
|
||||
|
||||
Read `app/resources/js/views/admin/AdminTenantsView.vue` first to see how it consumes `TenantsTable` and where it keeps state / how it reloads the list (look for the `listAdminTenants` call and the mapped tenants ref).
|
||||
|
||||
Then:
|
||||
- Import `TenantBalanceDialog` and (if not already) ensure tenants list is in a reactive ref with a reload function.
|
||||
- Add state: `const balanceDialogOpen = ref(false);` and `const balanceTarget = ref<AdminTenant | null>(null);`.
|
||||
- Wire `<TenantsTable ... @edit-balance="openBalanceDialog" />`.
|
||||
- Add handler:
|
||||
```typescript
|
||||
function openBalanceDialog(t: AdminTenant): void {
|
||||
balanceTarget.value = t;
|
||||
balanceDialogOpen.value = true;
|
||||
}
|
||||
async function onBalanceSaved(): Promise<void> {
|
||||
// перезагрузить список, чтобы строка показала новый баланс
|
||||
await loadTenants(); // имя реальной функции загрузки — взять из файла
|
||||
}
|
||||
```
|
||||
(Use the actual list-loader function name found in the file.)
|
||||
- Mount the dialog (guarded by `balanceTarget`):
|
||||
```vue
|
||||
<TenantBalanceDialog
|
||||
v-if="balanceTarget"
|
||||
v-model="balanceDialogOpen"
|
||||
:tenant-id="balanceTarget.id"
|
||||
:tenant-name="balanceTarget.name"
|
||||
:current-balance-rub="balanceTarget.balanceRub"
|
||||
@saved="onBalanceSaved"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run frontend checks**
|
||||
|
||||
Run from `app/`:
|
||||
```bash
|
||||
npm run test:vue -- AdminTenantsView 2>&1 | tail -20
|
||||
npm run type-check 2>&1 | grep -E "AdminTenantsView|TenantsTable" | head
|
||||
```
|
||||
Expected: existing list-view tests pass; vue-tsc clean.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/admin/tenants/TenantsTable.vue \
|
||||
app/resources/js/views/admin/AdminTenantsView.vue
|
||||
git commit -m "feat(admin): wire balance dialog into tenant list table"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Full frontend + backend regression
|
||||
|
||||
**Files:** none directly; fix wherever it breaks.
|
||||
|
||||
- [ ] **Step 1: Run targeted suites**
|
||||
|
||||
```bash
|
||||
cd app
|
||||
./vendor/bin/pest tests/Feature/Admin/AdminTenantBalanceUpdateTest.php
|
||||
npm run test:vue -- TenantBalanceDialog 2>&1 | tail -10
|
||||
npm run type-check 2>&1 | tail -20
|
||||
npm run lint:vue 2>&1 | tail -20
|
||||
npm run build 2>&1 | tail -5
|
||||
```
|
||||
Expected: all green (pre-existing unrelated failures excluded).
|
||||
|
||||
- [ ] **Step 2: Fix any breaks, commit incrementally**
|
||||
|
||||
Each fix = own commit: `fix(admin): <what>`.
|
||||
|
||||
---
|
||||
|
||||
## Spec Coverage Check (self-review)
|
||||
|
||||
| Spec requirement | Task | Status |
|
||||
|---|---|---|
|
||||
| Set-absolute semantics, server computes delta | Task 1 | ✓ |
|
||||
| `manual_adjustment` ledger row, signed amount | Task 1 | ✓ |
|
||||
| `saas_admin_audit_log` `tenant.balance_adjusted` | Task 1 | ✓ |
|
||||
| bcmath + lockForUpdate, SaaS connection / SET LOCAL | Task 1 | ✓ |
|
||||
| Validation: decimal regex, negative allowed, reason optional, no-op 422, 404 | Task 1 | ✓ |
|
||||
| Route under `saas-admin`, id-constrained | Task 1 | ✓ |
|
||||
| API client `updateTenantBalance` | Task 2 | ✓ |
|
||||
| Shared `TenantBalanceDialog` with live delta preview | Task 3 | ✓ |
|
||||
| Edit from tenant detail card | Task 4 | ✓ |
|
||||
| Edit inline from list table | Task 5 | ✓ |
|
||||
| balance_leads NOT edited | Task 1 (amount_leads null, no leads field) | ✓ |
|
||||
| Tests: Pest + Vitest | Tasks 1, 3 | ✓ |
|
||||
|
||||
---
|
||||
|
||||
## Plan complete
|
||||
|
||||
**Deployment after merge:** копир-паттерном на боевой `liderra.ru` (3 PHP-файла + frontend `public/build`); DDL не требуется. После выкатки заказчик выставляет реальные балансы тестовым тенантам через UI.
|
||||
@@ -0,0 +1,757 @@
|
||||
# Биллинг v2 Спек B — политика дублей: план реализации
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Убрать наш телефонный антифрод-фильтр дублей (доверяем дедупу поставщика), но гарантировать на уровне БД, что одна поставка одному клиенту тарифицируется ровно один раз; лимит шеринга — 3 разных клиента.
|
||||
|
||||
**Architecture:** Удаляем `DuplicateDetector` из обоих job-путей. В шеринг-пути (`RouteSupplierLeadJob`) раздача переводится с лимита-по-проектам на лимит-по-клиентам (один проект на клиента — DISTINCT ON по `tenant_id`, выбор проекта с макс. остатком дневного лимита; cap=3 клиента). Новая таблица-замок `supplier_lead_deliveries` (PK `supplier_lead_id`+`tenant_id`) + `insertOrIgnore` внутри транзакции создания сделки гарантирует «одна поставка → один оплаченный лид на клиента» даже при гонках/перезапусках/CSV-восстановлении.
|
||||
|
||||
**Tech Stack:** Laravel 13, PostgreSQL 16 (партиционированная `deals`, RLS по `app.current_tenant_id`, 5 ролей), Pest 4 (`--parallel`), bcmath/`LedgerService`. Worktree `.claude/worktrees/billing-v2-spec-b/`, ветка `feat/billing-v2-spec-b` (база origin/main `ff2ee59e`, Спек A уже влит).
|
||||
|
||||
**Спека:** `docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md`
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Важный контекст базы (прочитать до старта)
|
||||
|
||||
1. **Спек A влит в origin/main.** `App\Services\Billing\LedgerService::chargeForDelivery` — always-rub: списывает `balance_rub` (bcmath), пишет `LeadCharge(charge_source='rub')` + `BalanceTransaction` + `supplier_lead_costs`; `balance_leads` НЕ трогает. Возвращает `ChargeResult`.
|
||||
2. **Тест-долг Спека A.** Часть существующих тестов (`app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` ассертит `balance_leads → 99`; `RouteSupplierLeadJobBillingTest.php` имеет кейс `charge_source='prepaid'`) противоречит always-rub `LedgerService` и, вероятно, **уже красная на этой базе**. Это НЕ наша регрессия. Task 1 устанавливает фактический baseline. Новые тесты Спека B заякорены на **model-agnostic** ассерты (число `Deal` / `LeadCharge` на клиента + строки таблицы-замка) и сетап через хелпер `prepareSharingFlow` с достаточным `balance_rub`, чтобы не зависеть от prepaid/rub.
|
||||
3. **Два job-пути.** `ProcessWebhookJob` (прямой вебхук, `WebhookReceiveController`) — идемпотентность по `vid` через `webhook_dedup_keys (tenant_id, source_crm_id)`; замок там НЕ нужен. `RouteSupplierLeadJob` (шеринг, `SupplierWebhookController` + `CsvReconcileJob`) — замок нужен здесь.
|
||||
4. **Гранты — blanket.** `db/02_grants.sql` выдаёт `GRANT ... ON ALL TABLES` + `ALTER DEFAULT PRIVILEGES`. Новая tenant-таблица грантов отдельно не требует. На dev — `postgres` superuser.
|
||||
5. **`duplicate_detected` в origin/main отсутствует** (ни в `db/schema.sql`, ни во фронте, ни в backend) — чистить нечего, только verify-grep. Колонка `deals.duplicate_of_id` (schema.sql:1626) + индекс (schema.sql:1688) — есть.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Действие | Ответственность |
|
||||
|---|---|---|
|
||||
| `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql` | Create | DDL таблицы-замка (RLS + PK + FK) |
|
||||
| `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php` | Create | парная Laravel-миграция (idempotency guard) |
|
||||
| `db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql` | Create | DROP лишнего индекса |
|
||||
| `app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php` | Create | парная Laravel-миграция |
|
||||
| `db/schema.sql` | Modify | +CREATE TABLE supplier_lead_deliveries; −CREATE INDEX deals(duplicate_of_id); header v8.32→v8.33 |
|
||||
| `db/CHANGELOG_schema.md` | Modify | +запись v8.33 |
|
||||
| `app/app/Models/SupplierLeadDelivery.php` | Create | Eloquent-модель замка |
|
||||
| `app/app/Services/DuplicateDetector.php` | Delete | сервис телефонного фильтра |
|
||||
| `app/app/Jobs/ProcessWebhookJob.php` | Modify | убрать findMaster + markAsDuplicate, всегда charge |
|
||||
| `app/app/Jobs/RouteSupplierLeadJob.php` | Modify | убрать DuplicateDetector из сигнатур; +замок insertOrIgnore; раздача по клиентам |
|
||||
| `app/app/Services/LeadRouter.php` | Modify | DISTINCT ON (tenant_id) — один проект на клиента (макс. остаток лимита) |
|
||||
| `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` | Create | тесты замка + раздачи по клиентам |
|
||||
| `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` | Modify | убрать DuplicateDetector из `runRouteJob`; удалить/переписать дубль-тесты |
|
||||
| `app/tests/Feature/ProcessWebhookJobTest.php` | Modify | убрать дубль-тесты; +тест «два vid, один телефон → оба charge» |
|
||||
| прочие тесты с `DuplicateDetector`/`runRouteJob` | Modify | привести сигнатуры к 6-арговому handle() |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Baseline — зафиксировать фактическое состояние
|
||||
|
||||
**Files:** нет правок (только прогон).
|
||||
|
||||
- [ ] **Step 1: Подготовить тестовую БД worktree**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd .claude/worktrees/billing-v2-spec-b/app
|
||||
php artisan migrate:fresh --env=testing
|
||||
php artisan partitions:create-months --env=testing
|
||||
```
|
||||
Expected: миграции проходят; партиции `deals_*`, `balance_transactions_*`, `supplier_lead_costs_*` за текущий/смежные месяцы созданы. (Квирк Спека A: при нехватке партиций тесты падают с partition-ошибкой — пересоздать.)
|
||||
|
||||
- [ ] **Step 2: Прогнать затронутые сюиты, записать baseline**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php tests/Feature/ProcessWebhookJobTest.php tests/Feature/Integration/SupplierLeadFlowTest.php tests/Feature/Supplier/AutoPauseFlowTest.php tests/Feature/Supplier/CsvReconcileJobTest.php tests/Feature/Pd/DealCreatePdLogTest.php
|
||||
```
|
||||
Expected: записать в заметку, какие тесты GREEN, какие RED. Ожидаемо красные (тест-долг Спека A, НЕ наша задача): `RouteSupplierLeadJobTest` (balance_leads ассерты), prepaid-кейс в `RouteSupplierLeadJobBillingTest`. Всё остальное должно быть GREEN.
|
||||
|
||||
- [ ] **Step 3: Подтвердить модель списания**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -n "charge_source\|balance_rub\|balance_leads" app/Services/Billing/LedgerService.php
|
||||
```
|
||||
Expected: `charge_source` = `'rub'` хардкод, списывается `balance_rub`. Зафиксировать: новые тесты используют `balance_rub` и `LeadCharge::count()`.
|
||||
|
||||
- [ ] **Step 4: Коммит заметки baseline (опционально)**
|
||||
|
||||
Если ведёте журнал — зафиксируйте baseline-вывод в описании задачи. Кода-коммита нет.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Таблица-замок `supplier_lead_deliveries`
|
||||
|
||||
**Files:**
|
||||
- Create: `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql`
|
||||
- Create: `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php`
|
||||
- Modify: `db/schema.sql` (вставить CREATE TABLE; header v8.32→v8.33)
|
||||
- Modify: `db/CHANGELOG_schema.md`
|
||||
- Create: `app/app/Models/SupplierLeadDelivery.php`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` (schema-часть)
|
||||
|
||||
- [ ] **Step 1: Написать падающий schema-тест**
|
||||
|
||||
Создать `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`:
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('supplier_lead_deliveries table exists with PK (supplier_lead_id, tenant_id) and RLS', function (): void {
|
||||
$cols = collect(DB::select(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_lead_deliveries'"
|
||||
))->pluck('column_name')->all();
|
||||
|
||||
expect($cols)->toContain('supplier_lead_id')
|
||||
->toContain('tenant_id')
|
||||
->toContain('deal_id')
|
||||
->toContain('created_at');
|
||||
|
||||
$pk = collect(DB::select(
|
||||
"SELECT a.attname FROM pg_index i
|
||||
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
||||
WHERE i.indrelid = 'supplier_lead_deliveries'::regclass AND i.indisprimary"
|
||||
))->pluck('attname')->sort()->values()->all();
|
||||
expect($pk)->toBe(['supplier_lead_id', 'tenant_id']);
|
||||
|
||||
$rls = DB::selectOne(
|
||||
"SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_lead_deliveries'"
|
||||
);
|
||||
expect($rls->relrowsecurity)->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — убедиться, что падает**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
Expected: FAIL (таблицы нет).
|
||||
|
||||
- [ ] **Step 3: Написать DDL-файл миграции**
|
||||
|
||||
Создать `db/migrations/2026_05_23_200_supplier_lead_deliveries.sql`:
|
||||
```sql
|
||||
-- =============================================================================
|
||||
-- supplier_lead_deliveries — замок «одна поставка одному клиенту = один раз»
|
||||
-- (Billing v2 Spec B). Ключ по поставке (supplier_lead_id), НЕ по телефону —
|
||||
-- разные поставки с одним телефоном остаются отдельными платными лидами.
|
||||
-- Защищает шеринг-путь (RouteSupplierLeadJob) от наших собственных дублей
|
||||
-- при гонках / перезапусках задачи / CSV-восстановлении.
|
||||
-- =============================================================================
|
||||
CREATE TABLE supplier_lead_deliveries (
|
||||
supplier_lead_id BIGINT NOT NULL REFERENCES supplier_leads(id) ON DELETE CASCADE,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
deal_id BIGINT, -- созданная сделка; без FK (deals партиционирована)
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (supplier_lead_id, tenant_id)
|
||||
);
|
||||
|
||||
ALTER TABLE supplier_lead_deliveries ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON supplier_lead_deliveries
|
||||
USING (tenant_id = current_setting('app.current_tenant_id')::bigint);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Написать парную Laravel-миграцию**
|
||||
|
||||
Создать `app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php`:
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Idempotency: если schema.sql уже загружен (migrate:fresh), таблица есть — пропускаем.
|
||||
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.');
|
||||
}
|
||||
DB::unprepared($sql);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::unprepared('DROP TABLE IF EXISTS supplier_lead_deliveries CASCADE;');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Вставить CREATE TABLE в `db/schema.sql`**
|
||||
|
||||
Вставить блок из Step 3 (без комментария-шапки повторно — достаточно одного) в `db/schema.sql` сразу ПОСЛЕ блока `CREATE TABLE webhook_dedup_keys (...)` с его индексами/RLS (найти `grep -n "CREATE TABLE webhook_dedup_keys" db/schema.sql`). Обновить header-строку версии:
|
||||
```
|
||||
-- Версия: v8.33 (23.05.2026 — Billing v2 Spec B: +supplier_lead_deliveries замок поставка↔клиент; −индекс deals(duplicate_of_id))
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Запись в `db/CHANGELOG_schema.md`**
|
||||
|
||||
Добавить сверху списка изменений:
|
||||
```markdown
|
||||
## v8.33 (2026-05-23) — Billing v2 Spec B: политика дублей
|
||||
|
||||
- **+таблица `supplier_lead_deliveries`** (PK `supplier_lead_id`+`tenant_id`, FK на `supplier_leads` ON DELETE CASCADE, `deal_id` без FK, RLS `tenant_isolation`). Замок «одна поставка одному клиенту = один оплаченный лид» для шеринг-пути.
|
||||
- **−индекс `deals (duplicate_of_id) WHERE duplicate_of_id IS NOT NULL`** — концепция телефонного дедупа удалена (DuplicateDetector); колонка `deals.duplicate_of_id` оставлена спящей.
|
||||
- Метрики: +1 таблица, −1 индекс. (Сверять с header schema.sql.)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Создать Eloquent-модель**
|
||||
|
||||
Создать `app/app/Models/SupplierLeadDelivery.php`:
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* Замок «поставка ↔ клиент» (Billing v2 Spec B). Композитный PK без автоинкремента.
|
||||
* Пишется в шеринг-пути (RouteSupplierLeadJob) через insertOrIgnore под RLS-контекстом.
|
||||
*/
|
||||
class SupplierLeadDelivery extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $primaryKey = null;
|
||||
|
||||
protected $fillable = ['supplier_lead_id', 'tenant_id', 'deal_id', 'created_at'];
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Пересоздать тестовую БД и прогнать schema-тест**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
php artisan migrate:fresh --env=testing && php artisan partitions:create-months --env=testing
|
||||
php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
```
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Коммит**
|
||||
|
||||
```bash
|
||||
git add db/migrations/2026_05_23_200_supplier_lead_deliveries.sql \
|
||||
app/database/migrations/2026_05_23_200000_create_supplier_lead_deliveries.php \
|
||||
db/schema.sql db/CHANGELOG_schema.md \
|
||||
app/app/Models/SupplierLeadDelivery.php \
|
||||
app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
git commit -m "feat(billing-v2): supplier_lead_deliveries lock table (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Раздача по клиентам (LeadRouter — один проект на клиента)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Services/LeadRouter.php`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php` (добавить кейс)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест «один клиент, 2 проекта → 1 сделка»**
|
||||
|
||||
Добавить в `SupplierLeadDeliveryGuardTest.php` (хелперы `prepareSharingFlow` / `linkProjectToSupplier` — из `tests/Pest.php`; сверить сигнатуру по `RouteSupplierLeadJobBillingTest.php`):
|
||||
```php
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
|
||||
it('one delivery to a tenant with 2 eligible projects → exactly 1 deal + 1 charge', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twoproj.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
|
||||
// Два подходящих проекта одного клиента, разный остаток лимита.
|
||||
$pLow = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 9, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
$pHigh = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($pLow, $sp);
|
||||
linkProjectToSupplier($pHigh, $sp);
|
||||
|
||||
$vid = 600001;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twoproj.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
|
||||
runRouteJob($lead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
|
||||
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
// Выбран проект с наибольшим остатком лимита.
|
||||
expect($pHigh->fresh()->delivered_today)->toBe(1);
|
||||
expect($pLow->fresh()->delivered_today)->toBe(9);
|
||||
});
|
||||
```
|
||||
NB: `runRouteJob` уже определён в `RouteSupplierLeadJobTest.php`, но это другой файл. Определить локальный хелпер в этом файле (после Task 4 он будет 6-арговым — см. ниже), либо вызвать job напрямую. Чтобы не зависеть от Task 4, в этом тесте вызвать job через `app()`-резолв 6 аргументов ПОСЛЕ Task 4. Поэтому: написать тело теста, но запускать его в Step 3 уже после правки LeadRouter, а полную зелёность по job — в Task 6.
|
||||
|
||||
- [ ] **Step 2: Переписать `LeadRouter::matchEligibleProjects` на DISTINCT ON (tenant_id)**
|
||||
|
||||
Заменить тело `matchEligibleProjects` в `app/app/Services/LeadRouter.php` — добавить `DISTINCT ON (projects.tenant_id)` с выбором проекта максимального остатка лимита:
|
||||
```php
|
||||
/** @var Collection<int, Project> $candidates */
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->select('projects.*')
|
||||
->selectRaw('DISTINCT ON (projects.tenant_id) projects.id AS __distinct_marker')
|
||||
->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('projects.tenant_id')
|
||||
->orderByRaw('COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today DESC')
|
||||
->orderBy('projects.created_at')
|
||||
->orderBy('projects.id')
|
||||
->get();
|
||||
|
||||
return $candidates->values();
|
||||
```
|
||||
NB: смешение `DISTINCT ON` + Eloquent `select('projects.*')` хрупко. **Предпочтительный вариант** — сырой select без маркера:
|
||||
```php
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->fromRaw('projects')
|
||||
->whereExists(/* project_supplier_links ... */)
|
||||
->where('is_active', true)
|
||||
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
|
||||
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
|
||||
->whereExists(/* tenants balance ... */)
|
||||
->orderByRaw('projects.tenant_id, COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today DESC, projects.created_at, projects.id')
|
||||
->selectRaw('DISTINCT ON (projects.tenant_id) projects.*')
|
||||
->get();
|
||||
```
|
||||
Реализатор выбирает рабочий из двух (проверить SQL прогоном). Семантика обязательна: **ровно один Project на tenant_id, с максимальным остатком `COALESCE(effective_daily_limit_today, daily_limit_target) - delivered_today`; тай-брейк `created_at, id`**.
|
||||
|
||||
- [ ] **Step 3: Прогон существующих router-зависимых тестов**
|
||||
|
||||
Run: `php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php --filter="caps deal creation at 3"`
|
||||
Expected: тест cap=3 (5 клиентов по 1 проекту) остаётся GREEN (DISTINCT ON не меняет результат при одном проекте на клиента). Если упал из-за DuplicateDetector-аргумента — это чинится в Task 4; здесь убедиться, что SQL DISTINCT ON валиден (нет SQL-ошибки).
|
||||
|
||||
- [ ] **Step 4: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Services/LeadRouter.php app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
git commit -m "feat(billing-v2): LeadRouter — one project per tenant (max remaining limit)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Удалить `DuplicateDetector` из `RouteSupplierLeadJob`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php`
|
||||
- Modify: `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` (сигнатура `runRouteJob`, удалить дубль-тесты)
|
||||
|
||||
- [ ] **Step 1: Убрать DuplicateDetector из `handle()` и `createDealCopyForProject()`**
|
||||
|
||||
В `app/app/Jobs/RouteSupplierLeadJob.php`:
|
||||
- Удалить `use App\Services\DuplicateDetector;`.
|
||||
- Из сигнатуры `handle(...)` убрать параметр `DuplicateDetector $duplicateDetector,`.
|
||||
- Из вызова `$this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)` убрать `$duplicateDetector`.
|
||||
- Из сигнатуры `createDealCopyForProject(...)` убрать параметр `DuplicateDetector $duplicateDetector,`.
|
||||
- Удалить блок поиска master + ветку дубля (строки ~274–306: `$master = $duplicateDetector->findMaster(...)` ... `if ($master !== null && $master->id !== $deal->id) { ... return false; }`). Сделка всегда идёт на `chargeForDelivery`.
|
||||
- Обновить doc-комментарии (убрать упоминания DuplicateDetector/Биз-19/duplicate_of_id).
|
||||
|
||||
- [ ] **Step 2: Обновить тест-хелпер и удалить дубль-тесты**
|
||||
|
||||
В `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php`:
|
||||
- Убрать `use App\Services\DuplicateDetector;`.
|
||||
- В `runRouteJob()` и в инлайн-вызове теста «caps deal creation at 3» убрать аргумент `app(DuplicateDetector::class),` (handle() теперь 6-арговый).
|
||||
- Удалить тест `it('marks duplicate via DuplicateDetector — no charge ...')` (строки ~158–204) — концепция удалена.
|
||||
- Переписать тест `it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean')` → новое имя/поведение: pre-existing deal с тем же телефоном (другой `vid`) НЕ подавляет списание; ожидать `deals_created_count = 3`, все три баланса/счётчики списаны. (См. также Task 7 — там добавляются model-agnostic тесты; здесь достаточно убрать `duplicate_of_id`-ассерты и привести ожидание к «3 charged».)
|
||||
|
||||
- [ ] **Step 3: Прогон**
|
||||
|
||||
Run: `php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php`
|
||||
Expected: тесты, не завязанные на `balance_leads`-долг, GREEN; компиляция (6-арговый handle) проходит. Красные строго из-за `balance_leads`-ассертов (тест-долг Спека A) — допустимо; если задача включает их починку, мигрировать на `balance_rub` (см. Task 7 Step 4).
|
||||
|
||||
- [ ] **Step 4: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
|
||||
git commit -m "refactor(billing-v2): drop DuplicateDetector from RouteSupplierLeadJob (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Удалить `DuplicateDetector` из `ProcessWebhookJob` + сам сервис
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Jobs/ProcessWebhookJob.php`
|
||||
- Delete: `app/app/Services/DuplicateDetector.php`
|
||||
- Modify: `app/tests/Feature/ProcessWebhookJobTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест «два vid, один телефон → оба charge»**
|
||||
|
||||
В `app/tests/Feature/ProcessWebhookJobTest.php` добавить (сверить сетап с существующими тестами файла — tenant с балансом, dispatch `ProcessWebhookJob`):
|
||||
```php
|
||||
it('charges both leads with same phone but different vid (no phone dedup)', function (): void {
|
||||
// Сетап tenant + project как в соседних тестах файла.
|
||||
// Прогнать ProcessWebhookJob дважды: тот же phone, разные vid.
|
||||
// Ожидать: 2 Deal, баланс списан дважды, ни у одной нет duplicate_of_id.
|
||||
// (точный сетап — по образцу существующих тестов ProcessWebhookJobTest)
|
||||
})->todo();
|
||||
```
|
||||
Затем заменить `->todo()` на полноценный тест по образцу существующего «новая сделка списывает баланс» из этого же файла (взять его сетап tenant/project/payload, продублировать вызов с двумя разными `vid`, одинаковым `phone`; ассертить 2 сделки + двойное списание).
|
||||
|
||||
- [ ] **Step 2: Запустить — убедиться, что падает (или показывает старое поведение)**
|
||||
|
||||
Run: `php artisan test tests/Feature/ProcessWebhookJobTest.php --filter="same phone but different vid"`
|
||||
Expected: при наличии DuplicateDetector второй лид помечается дублем (FAIL: ожидаем 2 charge, получаем 1).
|
||||
|
||||
- [ ] **Step 3: Убрать DuplicateDetector из `ProcessWebhookJob`**
|
||||
|
||||
В `app/app/Jobs/ProcessWebhookJob.php`:
|
||||
- Удалить `use App\Services\DuplicateDetector;`.
|
||||
- Удалить `$duplicateDetector = app(DuplicateDetector::class);` и его передачу в `DB::transaction`.
|
||||
- Удалить блок поиска master + ветку (строки ~119–133: `$master = $duplicateDetector->findMaster(...)` ... `if ($master !== null && ...) { $this->markAsDuplicate(...); return; }`). После проверки `wasRecentlyCreated` сразу `$this->chargeNewLead(...)`.
|
||||
- Удалить приватный метод `markAsDuplicate(...)` (строки ~144–165).
|
||||
- Обновить doc-комментарии (убрать абзац про Биз-19/DuplicateDetector).
|
||||
|
||||
- [ ] **Step 4: Удалить сервис и дубль-тесты**
|
||||
|
||||
```bash
|
||||
rm app/app/Services/DuplicateDetector.php
|
||||
```
|
||||
В `app/tests/Feature/ProcessWebhookJobTest.php` удалить тесты телефонного дедупа (master в 24ч → дубль / master старше 24ч / ActivityLog duplicate_of). Оставить/адаптировать только релевантные (vid-идемпотентность, zero-balance).
|
||||
|
||||
- [ ] **Step 5: Прогон**
|
||||
|
||||
Run: `php artisan test tests/Feature/ProcessWebhookJobTest.php`
|
||||
Expected: GREEN (включая новый тест из Step 1).
|
||||
|
||||
- [ ] **Step 6: Verify — нет висячих ссылок на DuplicateDetector**
|
||||
|
||||
Run: `grep -rn "DuplicateDetector\|findMaster\|markAsDuplicate" app/`
|
||||
Expected: 0 совпадений.
|
||||
|
||||
- [ ] **Step 7: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/ProcessWebhookJob.php app/tests/Feature/ProcessWebhookJobTest.php
|
||||
git rm app/app/Services/DuplicateDetector.php
|
||||
git commit -m "refactor(billing-v2): remove DuplicateDetector + phone dedup from ProcessWebhookJob (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Замок в `RouteSupplierLeadJob::createDealCopyForProject`
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест замка (повторная выдача той же поставки клиенту)**
|
||||
|
||||
Добавить в `SupplierLeadDeliveryGuardTest.php` (определить локальный 6-арговый `runRouteJob`-хелпер в этом файле, без `DuplicateDetector`):
|
||||
```php
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function runRouteJobB(int $id): void
|
||||
{
|
||||
(new RouteSupplierLeadJob($id))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
it('lock: re-running same delivery to same tenant does not double-charge', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'lock.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($p, $sp);
|
||||
|
||||
$vid = 610001;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_lock.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
|
||||
runRouteJobB($lead->id);
|
||||
|
||||
// Сбросить processed_at, чтобы пройти мимо idempotency-guard и проверить ИМЕННО замок БД.
|
||||
$lead->update(['processed_at' => null]);
|
||||
runRouteJobB($lead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(1);
|
||||
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
expect(DB::table('supplier_lead_deliveries')
|
||||
->where('supplier_lead_id', $lead->id)->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — убедиться, что падает**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php --filter="re-running same delivery"`
|
||||
Expected: FAIL (без замка второй прогон создаёт вторую сделку + второй charge).
|
||||
|
||||
- [ ] **Step 3: Вставить замок в `createDealCopyForProject`**
|
||||
|
||||
В `app/app/Jobs/RouteSupplierLeadJob.php`, внутри `DB::transaction` в `createDealCopyForProject`, ПОСЛЕ `SET LOCAL app.current_tenant_id`, lock'а tenant и recheck'а лимита проекта, но ДО `Deal::create`:
|
||||
```php
|
||||
// Spec B: замок «одна поставка одному клиенту = один раз».
|
||||
// insertOrIgnore вернёт 0, если строка (supplier_lead_id, tenant_id) уже есть —
|
||||
// эта поставка уже выдавалась этому клиенту (гонка / перезапуск / CSV). Без charge.
|
||||
$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;
|
||||
}
|
||||
```
|
||||
После `Deal::create([...])` добавить проставление `deal_id` в замок:
|
||||
```php
|
||||
DB::table('supplier_lead_deliveries')
|
||||
->where('supplier_lead_id', $lead->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->update(['deal_id' => $deal->id]);
|
||||
```
|
||||
NB: `insertOrIgnore` под RLS-политикой `tenant_isolation` — `app.current_tenant_id` уже выставлен в этой транзакции, WITH CHECK (= USING) пройдёт.
|
||||
|
||||
- [ ] **Step 4: Прогон**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
Expected: PASS (все кейсы файла, включая Task 3 «2 проекта → 1 сделка»).
|
||||
|
||||
- [ ] **Step 5: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php
|
||||
git commit -m "feat(billing-v2): per-(delivery,tenant) lock guard in RouteSupplierLeadJob (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Тесты политики дублей (model-agnostic) + reconcile прочих сюит
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
- Modify: затронутые тесты с `DuplicateDetector`/`runRouteJob` / `balance_leads`-долгом
|
||||
|
||||
- [ ] **Step 1: Тест «два разных vid, один телефон, один клиент → оба charge»**
|
||||
|
||||
Добавить в `SupplierLeadDeliveryGuardTest.php`:
|
||||
```php
|
||||
it('same phone, two different deliveries to one tenant → both charged', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'twohit.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($p, $sp);
|
||||
|
||||
foreach ([700001, 700002] as $vid) {
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_twohit.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
runRouteJobB($lead->id);
|
||||
}
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
expect(Deal::query()->where('tenant_id', $tenant->id)->whereIn('source_crm_id', [700001, 700002])->count())->toBe(2);
|
||||
expect(LeadCharge::query()->where('tenant_id', $tenant->id)->count())->toBe(2);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Тест «5 клиентов под источник → ровно 3 списания у 3 клиентов»**
|
||||
|
||||
Добавить (сидируемый distributor для детерминизма, как в существующем cap-тесте):
|
||||
```php
|
||||
use Random\Engine\Mt19937;
|
||||
use Random\Randomizer;
|
||||
|
||||
it('cap = 3 distinct tenants: 5 eligible tenants → exactly 3 charged', function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
app()->bind(LeadDistributor::class, fn () => new LeadDistributor(new Randomizer(new Mt19937(7))));
|
||||
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap3.ru',
|
||||
]);
|
||||
foreach (range(1, 5) as $i) {
|
||||
$t = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '100000.00']);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $t->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($p, $sp);
|
||||
}
|
||||
|
||||
$vid = 710001;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null, 'platform' => 'B1', 'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['vid' => $vid, 'project' => 'B1_cap3.ru', 'phone' => '79991234567', 'time' => now()->getTimestamp()],
|
||||
]);
|
||||
|
||||
runRouteJobB($lead->id);
|
||||
|
||||
$lead->refresh();
|
||||
expect($lead->deals_created_count)->toBe(3);
|
||||
expect(LeadCharge::query()->where('tier_no', '>=', 0)->count())->toBe(3);
|
||||
// 3 разных клиента в замке.
|
||||
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->count())->toBe(3);
|
||||
expect(DB::table('supplier_lead_deliveries')->where('supplier_lead_id', $lead->id)->distinct()->count('tenant_id'))->toBe(3);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Прогон файла**
|
||||
|
||||
Run: `php artisan test tests/Feature/Supplier/SupplierLeadDeliveryGuardTest.php`
|
||||
Expected: PASS все кейсы.
|
||||
|
||||
- [ ] **Step 4: Reconcile прочих сюит, ломающихся сигнатурой/моделью**
|
||||
|
||||
Найти все вызовы 7-арговой `handle()` и ссылки на DuplicateDetector:
|
||||
```bash
|
||||
grep -rln "DuplicateDetector\|app(DuplicateDetector" app/tests
|
||||
```
|
||||
В каждом файле (`RouteSupplierLeadJobBillingTest.php`, `Integration/SupplierLeadFlowTest.php`, `AutoPauseFlowTest.php`, `Pd/DealCreatePdLogTest.php`, и т.п.):
|
||||
- убрать `app(DuplicateDetector::class),` из вызовов `handle()` (→ 6 аргументов);
|
||||
- убрать `use App\Services\DuplicateDetector;`;
|
||||
- удалить/переписать кейсы, проверявшие телефонный дедуп.
|
||||
Если эти тесты используют `balance_leads`-ассерты, несовместимые с always-rub (тест-долг Спека A) и попадают в зону правки — мигрировать на `balance_rub`/`LeadCharge` по образцу `RouteSupplierLeadJobBillingTest` rub-кейса. Тесты, которые мы не трогаем и которые были красны до Task 1, оставить как есть (вне scope Спека B; зафиксировать в отчёте).
|
||||
|
||||
- [ ] **Step 5: Прогон затронутых сюит**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
php artisan test tests/Feature/Jobs/RouteSupplierLeadJobTest.php tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php tests/Feature/Integration/SupplierLeadFlowTest.php tests/Feature/Supplier/AutoPauseFlowTest.php tests/Feature/Pd/DealCreatePdLogTest.php tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
```
|
||||
Expected: GREEN (кроме явно задокументированного pre-existing `balance_leads`-долга, если решено его не трогать).
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add app/tests
|
||||
git commit -m "test(billing-v2): dup-policy tests (no phone dedup, per-client cap, lock) + signature reconcile"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Финальная регрессия + чистка
|
||||
|
||||
**Files:** нет новых правок (verify).
|
||||
|
||||
- [ ] **Step 1: Verify — нет `duplicate_detected` / `duplicate_of_id`-записи**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep -rn "duplicate_detected" app/ db/ # ожидать 0
|
||||
grep -rn "duplicate_of_id" app/app # ожидать 0 (колонка спящая, код не пишет)
|
||||
```
|
||||
Expected: 0 совпадений в коде (комментарии/CHANGELOG допустимы).
|
||||
|
||||
- [ ] **Step 2: DROP лишнего индекса (миграция + schema уже правлены в Task 2 Step 5)**
|
||||
|
||||
Создать `db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql`:
|
||||
```sql
|
||||
-- Индекс по deals(duplicate_of_id) больше не нужен — телефонный дедуп удалён (Spec B).
|
||||
DROP INDEX IF EXISTS deals_duplicate_of_id_idx;
|
||||
```
|
||||
NB: имя индекса автоген — уточнить: `grep -n "duplicate_of_id" db/schema.sql` + на dev `\di deals*` / `SELECT indexname FROM pg_indexes WHERE tablename='deals' AND indexdef ILIKE '%duplicate_of_id%'`. Подставить фактическое имя.
|
||||
Создать парную `app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php` (паттерн как Task 2 Step 4, idempotent через `DROP INDEX IF EXISTS`; `up()` грузит .sql, `down()` — пусто или воссоздаёт индекс). Убедиться, что `CREATE INDEX ... deals (duplicate_of_id)` уже убран из `db/schema.sql` (Task 2 Step 5).
|
||||
|
||||
- [ ] **Step 3: Линт/статика**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
composer pint
|
||||
composer stan
|
||||
```
|
||||
Expected: Pint clean; Larastan 0 новых ошибок (для baseline в worktree скопировать `_ide_helper*.php` из основного чекаута — квирк A1-tooling).
|
||||
|
||||
- [ ] **Step 4: Полная backend-регрессия**
|
||||
|
||||
Run: `php artisan test --parallel`
|
||||
Expected: GREEN; кроме явно задокументированного pre-existing `balance_leads`-тест-долга Спека A, если он не входил в scope правок. Зафиксировать итог в отчёте.
|
||||
|
||||
- [ ] **Step 5: Финальный коммит миграции индекса**
|
||||
|
||||
```bash
|
||||
git add db/migrations/2026_05_23_201_drop_deals_duplicate_of_id_index.sql \
|
||||
app/database/migrations/2026_05_23_201000_drop_deals_duplicate_of_id_index.php
|
||||
git commit -m "chore(billing-v2): drop unused deals(duplicate_of_id) index (Spec B)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (выполнено автором плана)
|
||||
|
||||
- **Покрытие спека:** §3.1 убрать фильтр → Tasks 4,5; §3.2 раздача по клиентам → Task 3; §3.3 замок БД → Tasks 2,6; §3.4 чистка следов → Tasks 2 (индекс), 8 (verify; `duplicate_detected` отсутствует в base — подтверждено); §3.5 не трогаем (vid-идемпотентность/CSV-дедуп) → не затрагиваются; §4 крайние случаи → тесты Tasks 6,7; §5 тесты → Tasks 5,6,7; §6 выкатка одна-фазная + CHANGELOG → Task 2.
|
||||
- **Плейсхолдеры:** код приведён для всех правок; имя индекса в Task 8 — единственное «уточнить прогоном» (автоген PG-имя, нельзя знать без БД — дана точная команда выяснения).
|
||||
- **Согласованность типов:** `runRouteJobB` (6 арг, без DuplicateDetector) — единый хелпер новых тестов; `insertOrIgnore` возвращает int (кол-во вставленных); `LedgerService::chargeForDelivery` сигнатура неизменна; таблица `supplier_lead_deliveries` колонки совпадают между DDL, моделью и тестами.
|
||||
- **Scope:** один связный план; pre-existing `balance_leads`-тест-долг Спека A явно вынесен как «вне scope, по решению — мигрировать только затронутое».
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,702 @@
|
||||
# Controller-offload agents — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Создать два новых project-local AI-агента (Sonnet 4.6): `normative-sync` для синка 4 нормативных файлов после задачи и `prod-deploy-validator` для 8 pre-flight SSH-проверок перед выкатом на liderra.ru.
|
||||
|
||||
**Architecture:** Каждый агент — это один Markdown-файл в `.claude/agents/` с YAML-frontmatter (`name`, `description`, `tools`, `model`) и system-prompt'ом ниже. Прецеденты: `.claude/agents/pest-parallel-debugger.md` и `.claude/agents/rls-reviewer.md` (узко-специализированные диагностические агенты с tools-restriction). Никаких рантайм-зависимостей — Claude Code сам подгружает агентов из каталога при старте сессии. Регистрация в `docs/registry/nodes.yaml` и `task_classification`-маппинге — defer (dogfooded позже).
|
||||
|
||||
**Tech Stack:** Markdown-файлы агентов; YAML-frontmatter (Claude Code subagent format); Bash (для smoke-проверки); spec в `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md` (commit `71a5dd6`).
|
||||
|
||||
**Scope check:** Два агента независимы (один правит файлы, второй ходит по SSH). Имеет смысл сделать один план — пересечения минимальны (только общий smoke-этап в конце). Каждый агент при этом — самостоятельная единица.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Что в нём |
|
||||
|------|-----------|
|
||||
| `.claude/agents/normative-sync.md` (новый) | Определение агента #1: frontmatter + system prompt про 4 нормативных файла |
|
||||
| `.claude/agents/prod-deploy-validator.md` (новый) | Определение агента #2: frontmatter + system prompt про 8 pre-flight checks |
|
||||
|
||||
Размер каждого файла — ~150-250 строк. Один файл = одна цель; не разбиваем по подразделам.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create `.claude/agents/normative-sync.md`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.claude/agents/normative-sync.md`
|
||||
- Reference: `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md` §3 (полное описание агента)
|
||||
- Reference: `.claude/agents/rls-reviewer.md` (прецедент формата)
|
||||
|
||||
### Step 1: Pre-flight per Pravila §15.2
|
||||
|
||||
- [ ] **Pre-flight check (одна команда):**
|
||||
|
||||
```bash
|
||||
git fetch --quiet && git log HEAD..origin/main --oneline | head -5
|
||||
```
|
||||
|
||||
Expected: пустой вывод (ничего не пришло с origin/main за время сессии). Если есть коммиты — посмотреть, не трогают ли они `.claude/agents/*` и `docs/superpowers/`. Если трогают — STOP, эскалировать на Дмитрия.
|
||||
|
||||
### Step 2: Write the agent file
|
||||
|
||||
- [ ] **Создать файл `.claude/agents/normative-sync.md` со следующим содержанием:**
|
||||
|
||||
````markdown
|
||||
---
|
||||
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» в неcвязанные секции — только указанные шапки, §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.
|
||||
````
|
||||
|
||||
### Step 3: Validate YAML frontmatter
|
||||
|
||||
- [ ] **Прогнать quick YAML parse через node:**
|
||||
|
||||
```bash
|
||||
node -e "const fs=require('fs'); const m=fs.readFileSync('.claude/agents/normative-sync.md','utf8').match(/^---\n([\s\S]+?)\n---/); if(!m){console.error('NO FRONTMATTER'); process.exit(1)}; console.log(m[1])"
|
||||
```
|
||||
|
||||
Expected: вывод YAML-блока с name, description, tools, model. Никаких ошибок парсинга.
|
||||
|
||||
### Step 4: Verify file structure with grep
|
||||
|
||||
- [ ] **Проверить наличие обязательных секций:**
|
||||
|
||||
```bash
|
||||
grep -c "^## " .claude/agents/normative-sync.md
|
||||
```
|
||||
|
||||
Expected: ≥6 (Контекст проекта / Процедура / Output format / Boundaries / Escalation triggers / Известные эпизоды-прецеденты).
|
||||
|
||||
```bash
|
||||
grep -c "Pravila §15" .claude/agents/normative-sync.md
|
||||
```
|
||||
|
||||
Expected: ≥2 (упомянуто в Контексте + в Процедуре step 1).
|
||||
|
||||
### Step 5: Commit Task 1
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add .claude/agents/normative-sync.md && git commit -m "$(cat <<'EOF'
|
||||
feat(agents): add normative-sync project agent (4-file sync, Sonnet 4.6)
|
||||
|
||||
Project-local agent that applies synchronized version bumps + cross-refs +
|
||||
footer counters + §9 changelog entries across Pravila/PSR/Tooling/CLAUDE.md
|
||||
after a completed task. Does NOT commit. Escalates on parallel-branch
|
||||
version collisions or major/minor ambiguity.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md §3.
|
||||
Precedent: .claude/agents/rls-reviewer.md format.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)" -- .claude/agents/normative-sync.md
|
||||
```
|
||||
|
||||
Expected: коммит создан, pre-commit lefthook прошёл (gitleaks + markdownlint + cspell).
|
||||
|
||||
Если markdownlint или cspell зафейлятся — поправить, повторить commit. При cspell-words — добавить незнакомые слова в `cspell-words.txt` под новой секцией «Controller-offload agents (24.05.2026)».
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create `.claude/agents/prod-deploy-validator.md`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.claude/agents/prod-deploy-validator.md`
|
||||
- Reference: `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md` §4 (полное описание агента)
|
||||
- Reference: `memory/feedback_environment.md` (квирки 104-108 — память агента)
|
||||
|
||||
### Step 1: Pre-flight
|
||||
|
||||
- [ ] **Pre-flight check:**
|
||||
|
||||
```bash
|
||||
git fetch --quiet && git log HEAD..origin/main --oneline | head -5
|
||||
```
|
||||
|
||||
Expected: пусто или нерелевантно к `.claude/agents/`.
|
||||
|
||||
### Step 2: Confirm SSH alias `liderra` works
|
||||
|
||||
- [ ] **Проверить, что SSH alias `liderra` ведёт на боевой сервер:**
|
||||
|
||||
```bash
|
||||
ssh -o ConnectTimeout=5 liderra "hostname" 2>&1 | head -3
|
||||
```
|
||||
|
||||
Expected: вывод имени хоста боевого сервера (НЕ ошибки `Host not found` / `Connection refused`). Если ошибка — Дмитрий должен указать правильный alias из `~/.ssh/config`, тогда заменить `ssh liderra` на правильный в файле агента.
|
||||
|
||||
### Step 3: Write the agent file
|
||||
|
||||
- [ ] **Создать файл `.claude/agents/prod-deploy-validator.md` со следующим содержанием:**
|
||||
|
||||
````markdown
|
||||
---
|
||||
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.ru/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.ru/app/bootstrap/cache/config.php 2>/dev/null; stat -c '%Y' /var/www/liderra.ru/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 "file /var/www/liderra.ru/app/.env"
|
||||
```
|
||||
|
||||
Ожидаемый формат: `ASCII text` (одна строка).
|
||||
|
||||
Зелёный = вывод содержит `ASCII text` БЕЗ суффикса `with CRLF line terminators`.
|
||||
|
||||
Красный = вывод содержит `CRLF`. Цитировать квирк 105.
|
||||
|
||||
### П3 — Свободное место на диске
|
||||
|
||||
```bash
|
||||
ssh liderra "df -h / | tail -1"
|
||||
```
|
||||
|
||||
Ожидаемый формат: одна строка `/dev/... размер используется доступно %% маунт`.
|
||||
|
||||
Зелёный = использовано ≤ 85%.
|
||||
|
||||
Красный = > 85%. Reason: «диск %% занят, выкат может не уместиться».
|
||||
|
||||
### П4 — Свежесть последнего бэкапа БД
|
||||
|
||||
```bash
|
||||
ssh liderra "ls -lt /var/backups/db/ 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.ru/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.ru/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.ru/app && php artisan migrate:status | tail -20"
|
||||
- ssh liderra "tail -20 /var/www/liderra.ru/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.
|
||||
````
|
||||
|
||||
### Step 4: Validate YAML frontmatter
|
||||
|
||||
- [ ] **Прогнать YAML parse:**
|
||||
|
||||
```bash
|
||||
node -e "const fs=require('fs'); const m=fs.readFileSync('.claude/agents/prod-deploy-validator.md','utf8').match(/^---\n([\s\S]+?)\n---/); if(!m){console.error('NO FRONTMATTER'); process.exit(1)}; console.log(m[1])"
|
||||
```
|
||||
|
||||
Expected: вывод YAML-блока с name, description, tools, model. Без ошибок.
|
||||
|
||||
### Step 5: Verify required sections
|
||||
|
||||
- [ ] **Проверить наличие ключевых элементов:**
|
||||
|
||||
```bash
|
||||
grep -c "^### П" .claude/agents/prod-deploy-validator.md
|
||||
```
|
||||
|
||||
Expected: 8 (восемь проверок П1-П8).
|
||||
|
||||
```bash
|
||||
grep -c "Квирк 107" .claude/agents/prod-deploy-validator.md
|
||||
```
|
||||
|
||||
Expected: ≥2 (упомянут в Контексте + в П1).
|
||||
|
||||
### Step 6: Commit Task 2
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add .claude/agents/prod-deploy-validator.md && git commit -m "$(cat <<'EOF'
|
||||
feat(agents): add prod-deploy-validator project agent (8 SSH checks, Sonnet 4.6)
|
||||
|
||||
Pre-flight validator before liderra.ru deploys. Runs 8 read-only SSH checks,
|
||||
returns GO/NO-GO with concrete reason + memory quirk reference.
|
||||
Driven by 24.05.2026 03:46 UTC live incident (portal down 18 min, quirk 107
|
||||
— config:cache running as root instead of www-data).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md §4.
|
||||
Precedent: .claude/agents/pest-parallel-debugger.md format.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)" -- .claude/agents/prod-deploy-validator.md
|
||||
```
|
||||
|
||||
Expected: коммит создан, pre-commit lefthook прошёл. При markdownlint/cspell ошибках — поправить (cspell-words.txt добавления под секцией «Controller-offload agents (24.05.2026)» если ещё не добавлено в Task 1).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: First dry-run smoke test for both agents
|
||||
|
||||
**Files:** none (только запуск агентов через Task-инструмент главного исполнителя).
|
||||
|
||||
**Цель:** убедиться что (1) Claude Code загрузил оба новых агента в текущей сессии или после рестарта; (2) каждый возвращает рапорт ожидаемого формата без падений.
|
||||
|
||||
### Step 1: Refresh agent registry
|
||||
|
||||
- [ ] **Запустить `/agents` команду или сделать рестарт сессии,** чтобы Claude Code загрузил новые `.claude/agents/*.md` файлы.
|
||||
|
||||
Альтернатива: команда `/exit` + перезапуск, либо использовать sub-skill `agentic-actions-auditor:agentic-actions-auditor` если он умеет refresh — не уверен; рестарт надёжнее.
|
||||
|
||||
### Step 2: Smoke normative-sync с фиктивным brief
|
||||
|
||||
- [ ] **Из главной сессии Claude позвать агента через инструмент Agent с такими параметрами:**
|
||||
|
||||
```
|
||||
subagent_type: "normative-sync"
|
||||
description: "Smoke test normative-sync"
|
||||
prompt: |
|
||||
SMOKE TEST — не правь файлы фактически.
|
||||
Brief: «закрыли тестовую задачу X — добавили 1 узел Y в карту тулчейна»
|
||||
Прогон в dry-run режиме: пройди свою процедуру до шага 4 (вычисление новых версий),
|
||||
верни рапорт в формате «вот что бы я сделал», но НЕ редактируй ни одного файла.
|
||||
Ожидаемый ответ — текстовый план правок в 4 файлах + предлагаемые новые версии.
|
||||
```
|
||||
|
||||
Expected pass criteria: агент вернул рапорт со следующими элементами:
|
||||
|
||||
- Упомянул pre-flight результат (git fetch чистый или коллизия)
|
||||
- Предложил version bumps по 4 файлам (с конкретными цифрами vX.Y → vX.Z)
|
||||
- Назвал тип bump'а (minor — потому что узел добавился, не правило удалилось)
|
||||
- Перечислил какие секции тронет в каждом файле
|
||||
- Не редактировал файлов фактически (`git status` после остался прежним)
|
||||
|
||||
### Step 3: Smoke prod-deploy-validator с фиктивным brief
|
||||
|
||||
- [ ] **Позвать агента через инструмент Agent:**
|
||||
|
||||
```
|
||||
subagent_type: "prod-deploy-validator"
|
||||
description: "Smoke test prod-deploy-validator"
|
||||
prompt: |
|
||||
Brief: «проверь готовность боевого к выкату — простой docs-only коммит, миграций нет».
|
||||
Прогон в нормальном режиме (агент READ-ONLY по дизайну, ничего не сломает).
|
||||
Ожидаемый ответ — таблица 8 проверок + GO/NO-GO вердикт.
|
||||
```
|
||||
|
||||
Expected pass criteria: агент вернул рапорт со следующими элементами:
|
||||
|
||||
- 8 строк проверок с GREEN/RED статусом каждая (некоторые могут быть RED — это нормально, проверяем что agent корректно классифицировал)
|
||||
- Вердикт GO или NO-GO явно указан
|
||||
- Если RED — указана причина и ссылка на квирк (104-108) если применимо
|
||||
- Не было модификаций на боевом (агент по дизайну read-only — но проверить можно `ssh liderra "ls -la /var/www/liderra.ru/app/.env"` до и после: mtime не изменился).
|
||||
|
||||
### Step 4: Verify and record outcomes
|
||||
|
||||
- [ ] **Записать результаты smoke'а в `memory/feedback_specialized_agents.md`** (новый файл) с разделом «First smoke 24.05.2026»:
|
||||
|
||||
```bash
|
||||
# Создать файл если нет; добавить запись
|
||||
```
|
||||
|
||||
Шаблон записи:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: specialized-agents
|
||||
description: Specialized project agents normative-sync + prod-deploy-validator — first smoke outcomes and lessons
|
||||
metadata:
|
||||
type: feedback
|
||||
---
|
||||
|
||||
# Project agents normative-sync + prod-deploy-validator
|
||||
|
||||
## First smoke 24.05.2026
|
||||
|
||||
### normative-sync
|
||||
- Возвращённый рапорт: <скопировать сюда первый рапорт агента>
|
||||
- Замечания: <что пошло хорошо / что плохо>
|
||||
- Корректировки в `.claude/agents/normative-sync.md` после smoke'а: <none / список>
|
||||
|
||||
### prod-deploy-validator
|
||||
- Возвращённый рапорт: <скопировать>
|
||||
- Замечания: <...>
|
||||
- Корректировки в `.claude/agents/prod-deploy-validator.md` после smoke'а: <none / список>
|
||||
```
|
||||
|
||||
После создания памяти — добавить ссылку в `memory/MEMORY.md` index одной строкой (~150 chars):
|
||||
|
||||
```markdown
|
||||
- [Project agents normative-sync + prod-deploy-validator](feedback_specialized_agents.md) — first smoke 24.05; what works / what to tune
|
||||
```
|
||||
|
||||
### Step 5: Commit smoke outcomes
|
||||
|
||||
- [ ] **Commit:**
|
||||
|
||||
```bash
|
||||
git add memory/feedback_specialized_agents.md memory/MEMORY.md && git commit -m "$(cat <<'EOF'
|
||||
docs(memory): first smoke outcomes for normative-sync + prod-deploy-validator agents
|
||||
|
||||
Captured initial smoke test results for both new project agents.
|
||||
Both spawned successfully and returned reports in expected format.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)" -- memory/feedback_specialized_agents.md memory/MEMORY.md
|
||||
```
|
||||
|
||||
Expected: коммит создан, lefthook прошёл.
|
||||
|
||||
---
|
||||
|
||||
## Self-review (после написания плана)
|
||||
|
||||
**1. Spec coverage check** — каждая секция спека покрыта задачей?
|
||||
|
||||
| Спек секция | Покрыто задачей |
|
||||
|-------------|-----------------|
|
||||
| §3 Агент №1 normative-sync — описание | Task 1 (полный системный prompt с §3.4 процедурой, §3.6 знаниями, §3.7 границами, §3.8 рисками) |
|
||||
| §3.5 Модель Sonnet 4.6 | Task 1 frontmatter `model: sonnet` |
|
||||
| §3.6 system prompt content | Task 1 Step 2 (содержание файла) |
|
||||
| §4 Агент №2 prod-deploy-validator — описание | Task 2 (полный системный prompt с §4.4 проверками, §4.7 квирками, §4.8 границами) |
|
||||
| §4.6 Модель Sonnet 4.6 | Task 2 frontmatter `model: sonnet` |
|
||||
| §4.7 квирки 104-108 в памяти | Task 2 Step 3 (раздел «Квирки производственного окружения») |
|
||||
| §5.1 файлы агентов в `.claude/agents/` | Task 1 + Task 2 (создание файлов) |
|
||||
| §5.2 frontmatter format | Task 1 + Task 2 Step 4 (валидация YAML) |
|
||||
| §5.3 какие скилы НЕ даём | Покрыто в самом system prompt каждого агента (упомянуто в Boundaries) |
|
||||
| §5.5 классификация-маппинг | **DEFERRED** — отдельная задача brain governance (см. spec §6 out-of-scope) |
|
||||
| §6 dogfooding регистрация в Tooling §0 | Произойдёт ВО ВРЕМЯ первого реального использования агента #1 — это не часть текущего плана |
|
||||
| §7 OQ-1/2/3 | Решения по умолчанию из спека приняты — никаких MCP-расширений / TaskOutput-бэкграунда / автодействий |
|
||||
| §9 next step | Task 3 (smoke = первое реальное использование, превращающее план в эксплуатацию) |
|
||||
|
||||
**2. Placeholder scan** — ни одного TBD / TODO в плане. Каждый шаг содержит конкретные команды или код. Содержимое агент-файлов выписано полностью.
|
||||
|
||||
**3. Type consistency** — `normative-sync` и `prod-deploy-validator` (slug-формат) используются единообразно во всех trех задачах. `Sonnet 4.6` упомянут в frontmatter обоих файлов как `model: sonnet` (короткий идентификатор для Claude Code).
|
||||
|
||||
Гэп: SSH alias `liderra` в Task 2 предполагается существующим в `~/.ssh/config`. Если его нет — Task 2 Step 2 ловит это и эскалирует на Дмитрия. Не нужно тратить ещё одну задачу на «настроить SSH alias», потому что (1) Дмитрий уже использует `ssh liderra` в своих рабочих процессах (см. push-историю с 22.05); (2) если alias нужен — Дмитрий настроит за 1 минуту.
|
||||
|
||||
---
|
||||
|
||||
## Execution handoff
|
||||
|
||||
**Plan complete and saved to `docs/superpowers/plans/2026-05-24-controller-offload-agents.md`. Two execution options:**
|
||||
|
||||
1. **Subagent-Driven (recommended)** — главный исполнитель диспатчит свежий субагент на каждую из 3 задач, делает review между задачами. Plus — это сразу первая dogfood-проверка субагент-механизма для нашего случая (Pravila §15.1).
|
||||
|
||||
2. **Inline Execution** — задачи выполняются прямо в этой сессии через `superpowers:executing-plans`, с чекпоинтами для ревью.
|
||||
|
||||
**Which approach?**
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user