Compare commits
284 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4903a8d188 | |||
| 5a3ad6b899 | |||
| 1d2d43a6f2 | |||
| 3420f46a59 | |||
| b05e31c89c | |||
| cb32aa9907 | |||
| 88ae0ac348 | |||
| 618519c7e8 | |||
| b0cd18d797 | |||
| 30b79c7228 | |||
| 63100decce | |||
| f6421fd61c | |||
| d647bf1858 | |||
| 1f9b51bc39 | |||
| 8a7144892c | |||
| 722f4bb189 | |||
| 417cfcbc37 | |||
| c9b9efd6e4 | |||
| dfae9f760b | |||
| a8996896a8 | |||
| f82c878c60 | |||
| 3c5266c022 | |||
| 9280c48025 | |||
| 84dcf4aab3 | |||
| 80e514f5bb | |||
| f740f6124a | |||
| c86fdfc9eb | |||
| 9f84d9ef09 | |||
| 6d512f5cf3 | |||
| ca52d354f9 | |||
| c805988085 | |||
| 6ac4b1c1b1 | |||
| f172e2a580 | |||
| 4686b36571 | |||
| ffd70d6fa5 | |||
| 612b3a3382 | |||
| f1c422af49 | |||
| 0ff2053ae0 | |||
| d75c8922aa | |||
| e1592cc1df | |||
| 79493879ae | |||
| 63686fa5b2 | |||
| c14fb72e84 | |||
| 5520534424 | |||
| fc3c85bb6e | |||
| cebd6bcebb | |||
| 3ce73a68ff | |||
| d277d4bdfc | |||
| 2a3b5b4da5 | |||
| 25e184e52d | |||
| 15a60c6ae1 | |||
| 6973363c37 | |||
| 1a84864e44 | |||
| a3002bbe3b | |||
| 430396dfba | |||
| d4c6145b6d | |||
| 27c73fb050 | |||
| 40d4443926 | |||
| 32b0bd6c89 | |||
| 7a1cab6a2d | |||
| 6010443307 | |||
| d27d8b6780 | |||
| a15e95e79d | |||
| f555082d3b | |||
| fd9e755b6f | |||
| 47f5e7e919 | |||
| 4ad4c6d138 | |||
| 7e0e5f8e52 | |||
| 333fcc763a | |||
| 38a97aa2d7 | |||
| f03c45240d | |||
| 632882cace | |||
| a00ebd0ed2 | |||
| 96157a8dcf | |||
| 2d65773387 | |||
| 8d74482398 | |||
| ee7acf6eaa | |||
| b4e96be14c | |||
| 8417d83d85 | |||
| ab7ad53418 | |||
| c662369e2e | |||
| 2d2661c2ee | |||
| 8f9ebe40ab | |||
| 2e7f0c9ac7 | |||
| f2a45a335b | |||
| 7c58c3fa7c | |||
| 462b3ec52e | |||
| 77f5de05a1 | |||
| e47b618819 | |||
| 16a0f9c4fb | |||
| 852eab1ad0 | |||
| 63cfda41b1 | |||
| fcc5e2b3f1 | |||
| 8d850695b7 | |||
| 9a7f2fa560 | |||
| b244eb3091 | |||
| e3012d2f5c | |||
| 7386637822 | |||
| 70b8fea608 | |||
| 2cb566f7d5 | |||
| 8e2b8bee6b | |||
| 936d5e7671 | |||
| 6f438df18b | |||
| d70af8c0ef | |||
| b02552fdd8 | |||
| 8ee6d615bc | |||
| e49b9d39ca | |||
| 8d6aeadb21 | |||
| 74197ec66b | |||
| 41a752de2e | |||
| b9bbef0503 | |||
| fb261635a4 | |||
| 52e1cfec1a | |||
| ecee7d0a32 | |||
| 49f1c462a5 | |||
| 9bc7babf38 | |||
| d81284f159 | |||
| e683e39fdd | |||
| 25e33915ec | |||
| dd1d93f0ce | |||
| 2c4e948f71 | |||
| e0f6c52f37 | |||
| 10b26ddfe7 | |||
| 1321ad131e | |||
| 7ebe6c5bcc | |||
| 5b8109ea55 | |||
| 557fe07fcf | |||
| 535f1d4065 | |||
| c6a4748398 | |||
| db6cda427a | |||
| ce97685667 | |||
| 4e15fa70ff | |||
| 534e93d50d | |||
| 1f4faf6878 | |||
| 480649db30 | |||
| c4c2afd111 | |||
| 972be5c58a | |||
| 7c5b7215a1 | |||
| 0c3552393a | |||
| 720697ae43 | |||
| 575f7a1f59 | |||
| 6f3929a7a2 | |||
| 307a65e786 | |||
| 88cdd34e98 | |||
| 52eebe28c5 | |||
| b55ca6507d | |||
| 0e768f9aa0 | |||
| 292a16bd63 | |||
| de3736296d | |||
| e964d70c28 | |||
| 0098db6628 | |||
| a6bde2125a | |||
| 34bcc570ad | |||
| 6383da7f12 | |||
| 8910ae6cd6 | |||
| d181e98046 | |||
| c5c7e284e1 | |||
| 8fde6a3b50 | |||
| 46c4316966 | |||
| ef19b9f256 | |||
| 1c4c22ab5e | |||
| 1001b89a91 | |||
| 9f44b82f8f | |||
| a21712c9e1 | |||
| 1e5378da94 | |||
| 8092bdb024 | |||
| 7f7036f3ab | |||
| 883908ea78 | |||
| f187425835 | |||
| 8b60a18298 | |||
| 71b07e52eb | |||
| 2c8e6146fb | |||
| d4f7e681f6 | |||
| 0067174154 | |||
| b502db8fdc | |||
| ba3dbbd9be | |||
| 15df5b4a46 | |||
| f97103b05f | |||
| c454a3bedd | |||
| 84620665a5 | |||
| b28a9c030c | |||
| 002b8c4c35 | |||
| f1486015b0 | |||
| 6c6796d84a | |||
| 80c8160203 | |||
| 15bf46a1c0 | |||
| 903aa70098 | |||
| 832fadbcc3 | |||
| bd8ec88e9f | |||
| bf181350ca | |||
| 9704c539b4 | |||
| af2ff720ec | |||
| fab8e72d97 | |||
| 23c7615284 | |||
| fdd688dc06 | |||
| b632bcbae6 | |||
| ea7cc84a37 | |||
| 5c02d33cce | |||
| b510a75826 | |||
| 89f124cd27 | |||
| 7ec97230af | |||
| 7a43c175d0 | |||
| 5e103ef5b5 | |||
| 35243de8ac | |||
| 3ee211bd8a | |||
| 4b30f241dd | |||
| a43ac2d9a5 | |||
| 33b3ac06f2 | |||
| 4b7b67cefa | |||
| f6072b2885 | |||
| 88a284cc91 | |||
| c95445de47 | |||
| 726c2121b5 | |||
| 2b23a1f210 | |||
| 029dbe501d | |||
| 09f6e33240 | |||
| 49f25c756b | |||
| 836c433b84 | |||
| c20a53c0da | |||
| 6e93ccc417 | |||
| 8157337bca | |||
| 4a4fb625d2 | |||
| b93e5af439 | |||
| a3f5f392cd | |||
| 5eb2066524 | |||
| 8b81814483 | |||
| a823518bb7 | |||
| 36d7fd1923 | |||
| 7be2410bb8 | |||
| bf48bde5ca | |||
| ff18acc5e7 | |||
| 98dc24b33f | |||
| 8652c745c6 | |||
| 14c98c37c2 | |||
| 54360d6f3b | |||
| 4d7e9e338b | |||
| eedc700bb7 | |||
| ee32317bf4 | |||
| 8bc109c7ef | |||
| 84d0134875 | |||
| d1b5505a8f | |||
| 81f92ca361 | |||
| 7511f4e537 | |||
| 769df67af6 | |||
| 34ec94415c | |||
| aff4d5a80d | |||
| 0a52b3d8a0 | |||
| ccf4108e17 | |||
| db0cde0593 | |||
| e58d375648 | |||
| 1f7d04fc91 | |||
| 8e737769b2 | |||
| 6e2ad108de | |||
| 3ad11462bf | |||
| 1a1f43deaa | |||
| a0bb11a6fb | |||
| dd5954d8a5 | |||
| 6d6fa10d91 | |||
| 6e5460be5e | |||
| 19644a1d36 | |||
| 5e70ab7825 | |||
| 83e0cab8cb | |||
| 050e271d51 | |||
| 497d410ea1 | |||
| e8db184e99 | |||
| 3918f3554e | |||
| d1d5308013 | |||
| 27289c056a | |||
| 59a5f997e6 | |||
| 1e1457eb4c | |||
| b139888376 | |||
| a6a82b0317 | |||
| 7eac4b33db | |||
| 85161cb161 | |||
| 87336f74dc | |||
| e184ffe212 | |||
| 8266755c2e | |||
| 662be183db | |||
| 81cbd8c1c2 | |||
| b1a53fd98e | |||
| 8f3d1421fd | |||
| 4188fcbc36 | |||
| bb22c8325d | |||
| 01b50b1eba |
+230
-6
@@ -38,12 +38,42 @@
|
||||
},
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell|Skill|Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-llm-judge-per-tool.mjs",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Grep|Glob|LS|TodoWrite|AskUserQuestion|Edit|Write|MultiEdit|NotebookEdit|Bash|Skill|Task|EnterPlanMode",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-safe-baseline-metering.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-runtime-write-deny.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md Р’В§5 Р С—.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -52,7 +82,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/subagent-prompt-prefix.mjs\""
|
||||
"command": "node \"C:/Р В РЎВРѕСЏ/проекты/портал crm/ДокуРСВентацРСвЂР РЋР РЏ/tools/subagent-prompt-prefix.mjs\""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -95,6 +125,141 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-router-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "PowerShell",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-powershell-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-normative-content-rules.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-tdd-real-test-verifier.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-self-debrief-detector.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "mcp__.*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-mcp-classification.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-read-path-deny.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Workflow",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-workflow-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-decomposition-detector.mjs",
|
||||
"timeout": 8
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-parallel-session-lock.mjs",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "AskUserQuestion",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/askuser-cosmetic-detector.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read|Grep|Glob|LS|TodoWrite|AskUserQuestion|Edit|Write|MultiEdit|NotebookEdit|Bash|Skill|Task|EnterPlanMode",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-safe-baseline-metering.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-runtime-write-deny.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash|Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-parallel-session-lock.mjs",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -112,7 +277,7 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md Р’В§5 Р С—.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -126,7 +291,7 @@
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"command": "echo ok",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
@@ -136,13 +301,43 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"command": "echo ok",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-subagent-return-scanner.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "AskUserQuestion",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-askuser-answer-parser.mjs",
|
||||
"timeout": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-llm-judge-response-scan.mjs",
|
||||
"timeout": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
@@ -174,10 +369,28 @@
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-classifier-match.mjs",
|
||||
"command": "node tools/enforce-todowrite-skill-verifier.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/cost-stop-hook.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-parallel-session-lock.mjs",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
@@ -210,6 +423,17 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionEnd": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-parallel-session-lock.mjs",
|
||||
"timeout": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces can
|
||||
|
||||
## Procedure
|
||||
|
||||
> **MANDATORY DIGITAL ANALYSIS (added 2026-05-26 after retro #6 feedback).**
|
||||
> Каждый прогон /brain-retro ОБЯЗАН включать **количественные срезы**, не только causal narrative. Минимум 7 цифровых таблиц:
|
||||
> **MANDATORY DIGITAL ANALYSIS (added 2026-05-26 after retro #6 feedback; extended to 11 tables 2026-05-28; extended to 13 tables 2026-05-30 in Stream H Task 8).**
|
||||
> Каждый прогон /brain-retro ОБЯЗАН включать **количественные срезы**, не только causal narrative. Минимум 13 цифровых таблиц:
|
||||
>
|
||||
> 1. **Path-type breakdown** (regulated vs improvised, со счётчиками и %).
|
||||
> 2. **node_chosen distribution** (топ-15 узлов с count + %).
|
||||
> 3. **recommended_node distribution** (что классификатор предложил, count + %).
|
||||
@@ -30,11 +31,19 @@ Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces can
|
||||
> 5. **outcome × node_chosen group**: 3 группы (skill_used / direct_no_rec / direct_ignored_rec) со счётчиками + rework rate per group.
|
||||
> 6. **classifier_output presence by source** (prefilter / llm / regex / cache / NULL) — даёт диагностику здоровья самого классификатора.
|
||||
> 7. **Per-classification trigger-match + via-skill** (analysis / planning / bugfix / feature / refactor / security).
|
||||
> 8. **Class × canon coverage** — таблица класс задач × канонические узлы из мозга (`observer-classification-map.json`) × роутер рекомендовал × я реально взял × попало ли в канон. Источник — `result.classCanonCoverage` из analyzer.
|
||||
> 9. **Router vs Opus** — три секции: A (роутер дал → Opus оценил, расхождение видно сразу), B (роутер молчал → Opus сказал «надо был скил»), C (роутер дал → Opus согласился что скил излишен). Источник — `result.routerVsOpus`.
|
||||
> 10. **Chain-ignore breakdown** — отдельный срез: сколько раз роутер рекомендовал цепочку vs одиночный узел, какой % я игнорировал, и rework-rate каждого; bucket по длине цепочки (1/2/3+). Источник — `result.chainIgnoreBreakdown`.
|
||||
> 11. **Chain-hook effectiveness** — парсит `~/.claude/runtime/hook-outcomes.jsonl` за период retro. Buckets: blocked / passed-with-skill / passed-inline-override / passed-global-override / passed-short-chain / passed-no-mutating. Источник — `result.chainHookEffectiveness` из analyzer. Источник правила — brain-retro #9 Candidate 2.
|
||||
> 12. **Router-gate hook effectiveness (per-rule)** — счётчики fires + blocks по каждому `hook_fired.rule` в эпизодах за период (path-deny / git-conditional / branch-switch / etc). Помогает увидеть, какие правила реально стреляли и какой % fires заканчивался блокировкой. Источник — `result.routerGateHookEffectiveness` (Stream H Task 8). Без таблицы — нет видимости качества защит router-gate v4.
|
||||
> 13. **Self-fabrication signals** — эпизоды, где `controller_claim` непустой (контроллер заявил действие) но `tool_uses` пуст или отсутствует (записи о реальном tool-call нет). 7 канонических паттернов фабрикации задокументированы в `docs/superpowers/runbooks/recovery-procedures.md` §5. Источник — `result.selfFabricationSignals` (Stream H Task 8).
|
||||
>
|
||||
> Без этих 7 таблиц retro считается недоделанным. Narrative-выводы должны опираться на цифры из них, не на «общие ощущения». **Если classifier_output=NULL > 30% эпизодов** — это сигнал, что классификатор сломан; в retro отдельным блоком отчитаться о состоянии классификатора (timeouts/errors/source distribution).
|
||||
> Без этих 13 таблиц retro считается недоделанным. Narrative-выводы должны опираться на цифры из них, не на «общие ощущения». **Если classifier_output=NULL > 30% эпизодов** — это сигнал, что классификатор сломан; в retro отдельным блоком отчитаться о состоянии классификатора (timeouts/errors/source distribution).
|
||||
>
|
||||
> Запрет на жаргон для блока «Report to user»: цифры остаются техническими, словесные выводы пользователю — простым языком (см. memory `feedback_plain_language.md`).
|
||||
|
||||
<!-- markdownlint-disable MD029 MD032 -->
|
||||
|
||||
1. **Determine period**: ask user «за какой период» or default to «since last brain-retro» (find latest `docs/observer/notes/YYYY-MM-DD-brain-retro-*.md`).
|
||||
2. **Read evidence**: glob `docs/observer/episodes-YYYY-MM.jsonl` for the period; read all lines as JSON.
|
||||
3. **Read optional notes**: glob `docs/observer/notes/*.md` filtered by date.
|
||||
@@ -43,8 +52,8 @@ Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces can
|
||||
5a. **[Phase 3] Sanity questions (spec §4.7)** — `node tools/brain-retro-sanity-generator.mjs` (called as a module from analyzer-driven flow, OR direct via `import { generateCandidateQuestions } from '../../../tools/brain-retro-sanity-generator.mjs'`) returns up to 5 candidate questions. Pick 3-4, ask via AskUserQuestion (multiple-choice + free comment). **Вопросы заказчику — простым языком**, не «rework / wrong_skill / TDD pattern / self_assessment», а «переделки / выбор не того инструмента / самопроверка» (memory `feedback_plain_language.md`). Если первый раунд содержит жаргон — переформулировать и переспросить. **Before persist:** sanitize free comments with `tools/observer-pii-filter.mjs` (`sanitize` export, RU_PHONE / EMAIL / TOKEN strip). Write answers to `docs/observer/sanity-checks/YYYY-MM-DD.json` `{schema_version: 1, questions: [...]}`.
|
||||
5b. **Reviewer pass** — pragmatic two-mode policy (added 2026-05-26 after brain-retro #6, replacing original spec §4.6 «subagent only» which was unrealistic at retro scale):
|
||||
|
||||
- **Batch mode (default, fast)** — `node tools/brain-retro-batch-reviewer.mjs docs/observer/episodes-YYYY-MM.jsonl <cutoff-iso> [limit=30] [conc=5]`. Direct Opus API via `reviewViaDirectApi` from `tools/brain-retro-opus-reviewer.mjs` with concurrency 5. Use for **N ≥ 20 unreviewed episodes** — typical retro workload (retro #6 processed 132 episodes in 293s = ~2.2s/episode, well under per-subagent overhead).
|
||||
- **Subagent mode (per spec §4.6, deeper context)** — `Task(subagent_type='reviewer-agent', prompt=<episode JSON + sanity-answers context>)`. Use for **N < 20 episodes** OR when the reviewer needs access to other tools (read related files, grep history). Per-episode try/catch — on subagent crash/timeout, fall back to `reviewViaDirectApi`.
|
||||
- **Batch mode (default, fast)** — `node tools/brain-retro-batch-reviewer.mjs docs/observer/episodes-YYYY-MM.jsonl <cutoff-iso> [limit=30] [conc=5]`. Direct Opus API via `reviewViaDirectApi` from `tools/brain-retro-opus-reviewer.mjs` with concurrency 5. Use for **N ≥ 20 unreviewed episodes** — typical retro workload (retro #6 processed 132 episodes in 293s = ~2.2s/episode, well under per-subagent overhead).
|
||||
- **Subagent mode (per spec §4.6, deeper context)** — `Task(subagent_type='reviewer-agent', prompt=<episode JSON + sanity-answers context>)`. Use for **N < 20 episodes** OR when the reviewer needs access to other tools (read related files, grep history). Per-episode try/catch — on subagent crash/timeout, fall back to `reviewViaDirectApi`.
|
||||
|
||||
Both modes write the same payload back: `review.*` + `outcome_reviewed` + `outcome_reviewed_source` (`direct_api_batch` for batch, `subagent` for Task(), `direct_api_fallback` when subagent fails). If both fail, leave `review.reviewer_error: <msg>` for the next retro.
|
||||
6. **Aggregate** per `references/aggregation-template.md` — fill the Factor analysis matrix from the analyzer's `factorMatrix`, the task groups from `tasks`, the causal-chain candidates from `causalChains`, plus the new sections: sanity-check results, reviewer-agent outcomes distribution, self-retrospect trigger status.
|
||||
@@ -55,6 +64,8 @@ Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces can
|
||||
10. **Cost report** — read `~/.claude/runtime/cost-daily.json`; include classifier + self_assessment + reviewer cost totals for the period in the retro note.
|
||||
11. **Report to user**: high-signal summary including sanity highlights, reviewer outcome distribution, and any escalations.
|
||||
|
||||
<!-- markdownlint-enable MD029 MD032 -->
|
||||
|
||||
## Output anatomy
|
||||
|
||||
See `references/aggregation-template.md`.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
name: Run artisan command on liderra.ru
|
||||
|
||||
# Universal artisan-runner для прод-команд пока прямой SSH с dev-машины
|
||||
# заблокирован YC backbone-фильтром. Заказчик пишет команду строкой в
|
||||
# workflow_dispatch input, workflow проверяет её по whitelist, выполняет на
|
||||
# проде под sudo -u www-data, выводит результат в job summary.
|
||||
#
|
||||
# Whitelist охватывает read-only / dry-run / status команды без подтверждения
|
||||
# плюс несколько mutating команд с обязательным confirm_apply=true.
|
||||
#
|
||||
# Любая команда вне whitelist'а → fail before SSH.
|
||||
#
|
||||
# Использует тот же LIDERRA_SSH_KEY что и deploy.yml/ssh-diagnose.yml.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
command:
|
||||
description: 'artisan-команда (например: supplier:rekey-orphans --dry-run)'
|
||||
required: true
|
||||
type: string
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю выполнение mutating-команды (обязательно true для команд без --dry-run)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
run:
|
||||
name: ${{ github.event.inputs.command }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
CMD: ${{ github.event.inputs.command }}
|
||||
CONFIRM: ${{ github.event.inputs.confirm_apply }}
|
||||
|
||||
steps:
|
||||
- name: Whitelist check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
CMD_TRIM=$(echo "$CMD" | sed 's/^ *//;s/ *$//')
|
||||
echo "Requested: '$CMD_TRIM'"
|
||||
|
||||
# Group 1 — read-only / dry-run / inspection: всегда разрешены
|
||||
READ_ONLY_RE='^(migrate:status|route:list|schedule:list|queue:listen --help|about|env:show|config:show|cache:table|view:cache|optimize:status|snapshot:backfill( --date=20[2-9][0-9]-[0-1][0-9]-[0-3][0-9])?|scheduler:check-heartbeats|incidents:watch-failures( --threshold-spike=[0-9]+)?( --threshold-daily=[0-9]+)?( --persistent-hours=[0-9]+)?|supplier:rekey-orphans --dry-run|audit:verify-chains|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+ --dry-run)( *)$'
|
||||
|
||||
# Group 2 — mutating: требуют confirm_apply=true
|
||||
MUTATING_RE='^(supplier:rekey-orphans|cache:clear|view:clear|config:clear|route:clear|optimize:clear|optimize|queue:restart|partitions:create-months( --months=[0-9]+)?|partitions:drop-old|audit:rebuild-chain --partition=[a-z_0-9]+ --from-id=[0-9]+( --force)?)( *)$'
|
||||
|
||||
if [[ "$CMD_TRIM" =~ $READ_ONLY_RE ]]; then
|
||||
echo "::notice::Command in read-only whitelist — proceeding."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$CMD_TRIM" =~ $MUTATING_RE ]]; then
|
||||
if [[ "$CONFIRM" != "true" ]]; then
|
||||
echo "::error::Mutating command '$CMD_TRIM' requires confirm_apply=true. Re-run with confirm_apply checked."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::Mutating command authorized via confirm_apply=true."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::Command '$CMD_TRIM' is NOT in whitelist. Allowed read-only patterns: $READ_ONLY_RE. Allowed mutating: $MUTATING_RE. Add to whitelist if needed."
|
||||
exit 1
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run artisan on prod
|
||||
run: |
|
||||
set -o pipefail
|
||||
CMD_B64=$(printf '%s' "$CMD" | base64 -w0)
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"CMD_B64='$CMD_B64' bash -s" <<'REMOTE' | tee /tmp/artisan-output.log
|
||||
set +e
|
||||
CMD=$(echo "$CMD_B64" | base64 -d)
|
||||
cd /var/www/liderra/app
|
||||
echo "=== Running: php artisan $CMD on $(hostname) at $(date -u) ==="
|
||||
sudo -u www-data php artisan $CMD 2>&1
|
||||
RC=$?
|
||||
echo
|
||||
echo "=== Exit code: $RC ==="
|
||||
exit $RC
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## artisan \`$CMD\`"
|
||||
echo
|
||||
echo "- Host: $LIDERRA_HOST"
|
||||
echo "- Confirm: $CONFIRM"
|
||||
echo "- Triggered by: ${{ github.actor }}"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/artisan-output.log 2>/dev/null || echo "(no output captured)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload output as artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: artisan-output
|
||||
path: /tmp/artisan-output.log
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,229 @@
|
||||
name: Deploy to liderra.ru
|
||||
|
||||
# Запускается вручную через web-интерфейс GitHub или через `gh workflow run`.
|
||||
# Решает проблему «дев-машина не достучится по SSH до прод-сервера через YC backbone»:
|
||||
# GitHub Actions runner — внешний по отношению к YC, его IP не блокируется тем
|
||||
# фильтром что блокирует мой dev-IP `89.144.17.119`.
|
||||
#
|
||||
# Требуемые secrets (Settings → Secrets and variables → Actions):
|
||||
# LIDERRA_SSH_KEY — содержимое приватного ключа `~/.ssh/liderra_deploy`
|
||||
# (начинается с `-----BEGIN OPENSSH PRIVATE KEY-----`).
|
||||
# Host/user захардкожены — публичная информация, нет смысла в secrets.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: 'Branch/tag/SHA для деплоя (по умолчанию main)'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
backfill_snapshot:
|
||||
description: 'Запустить snapshot:backfill за сегодня (default yes)'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy code + run redeploy.sh
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
concurrency:
|
||||
group: liderra-prod-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
|
||||
- name: Setup Node 22
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: app/package-lock.json
|
||||
|
||||
- name: Install frontend deps
|
||||
# --legacy-peer-deps: Histoire 1.0-beta.1 заявляет peerDep vite ^7,
|
||||
# установлено vite 8 — известный квирк проекта (memory feedback_environment.md #74).
|
||||
working-directory: app
|
||||
run: npm ci --legacy-peer-deps
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: app
|
||||
run: npm run build
|
||||
|
||||
- name: Verify build artifacts present
|
||||
run: |
|
||||
test -f app/public/build/manifest.json
|
||||
ls app/public/build/assets/ | head -5
|
||||
du -sh app/public/build/
|
||||
|
||||
- name: Create deploy tarball
|
||||
run: |
|
||||
tar czf /tmp/deploy.tgz \
|
||||
--exclude='app/.env' \
|
||||
--exclude='app/.env.example' \
|
||||
--exclude='app/.env.production' \
|
||||
--exclude='app/storage' \
|
||||
--exclude='app/vendor' \
|
||||
--exclude='app/node_modules' \
|
||||
--exclude='app/bootstrap/cache' \
|
||||
app db
|
||||
ls -lh /tmp/deploy.tgz
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Upload tarball to prod
|
||||
run: |
|
||||
scp -i ~/.ssh/liderra_deploy -o StrictHostKeyChecking=accept-new \
|
||||
/tmp/deploy.tgz ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }}:/tmp/deploy.tgz
|
||||
|
||||
- name: Pre-apply partitioned migrations via postgres superuser
|
||||
# Workaround for partitioned-table migrations:
|
||||
# 2026_05_27_120000_create_project_routing_snapshots_table.php has SET ROLE crm_migrator
|
||||
# which fails when pgsql connection = crm_app_user (not a member of crm_migrator),
|
||||
# poisoning the transaction. Established prod pattern (memory: paused_at migration 26.05):
|
||||
# apply schema via sudo -u postgres psql + insert into migrations table.
|
||||
# Idempotent — skips if already applied.
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE'
|
||||
set -euo pipefail
|
||||
MIG_NAME='2026_05_27_120000_create_project_routing_snapshots_table'
|
||||
|
||||
ALREADY=$(sudo -u postgres psql -d liderra -tAc \
|
||||
"SELECT 1 FROM migrations WHERE migration = '${MIG_NAME}' LIMIT 1")
|
||||
if [ "${ALREADY}" = "1" ]; then
|
||||
echo "Migration ${MIG_NAME} already in migrations table — skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TABLE_EXISTS=$(sudo -u postgres psql -d liderra -tAc \
|
||||
"SELECT 1 FROM information_schema.tables WHERE table_name='project_routing_snapshots' LIMIT 1")
|
||||
|
||||
if [ "${TABLE_EXISTS}" != "1" ]; then
|
||||
echo "Applying CREATE TABLE project_routing_snapshots via postgres superuser..."
|
||||
sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1 <<'PSQL'
|
||||
BEGIN;
|
||||
CREATE TABLE project_routing_snapshots (
|
||||
snapshot_date DATE NOT NULL,
|
||||
project_id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
daily_limit INT NOT NULL CHECK (daily_limit >= 0),
|
||||
delivery_days_mask INT NOT NULL CHECK (delivery_days_mask BETWEEN 0 AND 127),
|
||||
regions INT[] NOT NULL DEFAULT '{}',
|
||||
signal_type TEXT NOT NULL CHECK (signal_type IN ('call','site','sms')),
|
||||
signal_identifier TEXT,
|
||||
sms_senders JSONB,
|
||||
sms_keyword TEXT,
|
||||
expected_volume INT NOT NULL CHECK (expected_volume >= 0),
|
||||
delivered_count INT NOT NULL DEFAULT 0 CHECK (delivered_count >= 0),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (snapshot_date, project_id),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
) PARTITION BY RANGE (snapshot_date);
|
||||
ALTER TABLE project_routing_snapshots OWNER TO crm_migrator;
|
||||
CREATE INDEX project_routing_snapshots_tenant_date_idx
|
||||
ON project_routing_snapshots (tenant_id, snapshot_date);
|
||||
CREATE INDEX project_routing_snapshots_signal_idx
|
||||
ON project_routing_snapshots (snapshot_date, signal_type, lower(signal_identifier));
|
||||
ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY project_routing_snapshots_tenant_isolation
|
||||
ON project_routing_snapshots
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
|
||||
GRANT SELECT, INSERT, UPDATE ON project_routing_snapshots TO crm_app_user;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON project_routing_snapshots TO crm_supplier_worker;
|
||||
CREATE TABLE project_routing_snapshots_y2026_m05
|
||||
PARTITION OF project_routing_snapshots
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE project_routing_snapshots_y2026_m06
|
||||
PARTITION OF project_routing_snapshots
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
ALTER TABLE project_routing_snapshots_y2026_m05 OWNER TO crm_migrator;
|
||||
ALTER TABLE project_routing_snapshots_y2026_m06 OWNER TO crm_migrator;
|
||||
INSERT INTO system_settings (key, value, type, description, updated_at)
|
||||
VALUES ('partition_retention_months_project_routing_snapshots', '3', 'int',
|
||||
'Retention в месяцах для project_routing_snapshots (90 дней)', NOW())
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
COMMIT;
|
||||
PSQL
|
||||
else
|
||||
echo "Table project_routing_snapshots already exists but migration not marked — marking only."
|
||||
fi
|
||||
|
||||
# Mark migration as applied so Laravel migrate skips it.
|
||||
# Laravel's migrations table has no UNIQUE on `migration` column, so
|
||||
# ON CONFLICT doesn't work — use INSERT...SELECT WHERE NOT EXISTS for idempotency.
|
||||
NEXT_BATCH=$(sudo -u postgres psql -d liderra -tAc "SELECT COALESCE(MAX(batch),0)+1 FROM migrations")
|
||||
sudo -u postgres psql -d liderra -c \
|
||||
"INSERT INTO migrations (migration, batch) SELECT '${MIG_NAME}', ${NEXT_BATCH} WHERE NOT EXISTS (SELECT 1 FROM migrations WHERE migration='${MIG_NAME}');"
|
||||
echo "Marked ${MIG_NAME} as applied (batch ${NEXT_BATCH})"
|
||||
REMOTE
|
||||
|
||||
- name: Extract + run redeploy.sh on prod
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE'
|
||||
set -euo pipefail
|
||||
TS=$(date -u +%Y%m%d-%H%M%S)
|
||||
echo "=== Backup current app ==="
|
||||
sudo tar czf /home/ubuntu/deploy-backups/app-pre-deploy-${TS}.tgz \
|
||||
--exclude='storage' --exclude='vendor' --exclude='node_modules' --exclude='public/build' \
|
||||
-C /var/www/liderra app
|
||||
ls -lh /home/ubuntu/deploy-backups/app-pre-deploy-${TS}.tgz
|
||||
|
||||
echo "=== Extract overlay ==="
|
||||
cd /var/www/liderra
|
||||
sudo tar xzf /tmp/deploy.tgz
|
||||
sudo chown -R www-data:www-data /var/www/liderra/app /var/www/liderra/db
|
||||
|
||||
echo "=== redeploy.sh (composer + migrate + optimize + restart) ==="
|
||||
sudo bash /var/www/liderra/redeploy.sh
|
||||
|
||||
rm -f /tmp/deploy.tgz
|
||||
REMOTE
|
||||
|
||||
- name: Backfill today's snapshot
|
||||
if: ${{ github.event.inputs.backfill_snapshot != 'false' }}
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE'
|
||||
set -e
|
||||
cd /var/www/liderra/app
|
||||
sudo -u www-data php artisan snapshot:backfill --date=$(date +%Y-%m-%d) || \
|
||||
echo "WARN: backfill returned non-zero — проверь вручную"
|
||||
REMOTE
|
||||
|
||||
- name: Smoke tests
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE'
|
||||
set -e
|
||||
cd /var/www/liderra/app
|
||||
echo '=== Migrations status (last 5) ==='
|
||||
sudo -u www-data php artisan migrate:status 2>&1 | tail -5
|
||||
echo '=== Snapshots count (last 3 dates) ==='
|
||||
sudo -u postgres psql -d liderra -c "SELECT snapshot_date, COUNT(*) AS rows FROM project_routing_snapshots GROUP BY 1 ORDER BY 1 DESC LIMIT 3;" || true
|
||||
echo '=== Service status ==='
|
||||
systemctl is-active nginx php8.3-fpm postgresql liderra-queue
|
||||
echo '=== Internal portal health ==='
|
||||
curl -sf -o /dev/null -w 'https=%{http_code} time=%{time_total}s\n' --max-time 8 https://127.0.0.1/ -k || true
|
||||
REMOTE
|
||||
|
||||
- name: External portal health (from runner)
|
||||
run: |
|
||||
curl -sf -o /dev/null -w 'external https=%{http_code} time=%{time_total}s\n' \
|
||||
--max-time 15 https://liderra.ru/ || echo "external health returned non-zero"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,213 @@
|
||||
name: Disk-full recovery on liderra.ru
|
||||
|
||||
# Incident response: PG в PANIC loop из-за / диск 100%.
|
||||
# 1) Диагностика: что где лежит (top-20 крупных, du по /var/log)
|
||||
# 2) Безопасная чистка:
|
||||
# - truncate /var/log/postgresql/postgresql-16-main.log (PG в PANIC, не пишет, inode preserved)
|
||||
# - journalctl --vacuum-size=200M
|
||||
# - старые ротированные *.gz логи nginx >7 дней
|
||||
# - apt-get clean
|
||||
# - Laravel storage/logs *.log >7 дней
|
||||
# 3) Final df check + PG probe.
|
||||
#
|
||||
# Триггер: gh workflow run disk-recover.yml -f confirm_apply=true
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю удаление логов на проде'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
recover:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
CONFIRM: ${{ github.event.inputs.confirm_apply }}
|
||||
|
||||
steps:
|
||||
- name: Guard
|
||||
run: |
|
||||
if [[ "$CONFIRM" != "true" ]]; then
|
||||
echo "::error::confirm_apply=true required (this workflow mutates disk on prod)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Diagnose + cleanup
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"bash -s" <<'REMOTE' | tee /tmp/recover.log
|
||||
set +e
|
||||
|
||||
echo "=== A. BEFORE: df -h / ==="
|
||||
df -h / /var /var/lib/postgresql 2>&1 | head -10
|
||||
echo
|
||||
|
||||
echo "=== B. Top-20 largest files in /var (>50M) ==="
|
||||
sudo find /var -xdev -type f -size +50M -printf "%s %p\n" 2>/dev/null | sort -rn | head -20 | awk '{printf "%8.1f MB %s\n", $1/1024/1024, $2}'
|
||||
echo
|
||||
|
||||
echo "=== C. du /var/log/ top-15 directories ==="
|
||||
sudo du -sh /var/log/*/ 2>/dev/null | sort -rh | head -15
|
||||
echo
|
||||
|
||||
echo "=== D. du /var/log/postgresql/* (individual files) ==="
|
||||
sudo du -sh /var/log/postgresql/* 2>/dev/null | sort -rh | head -10
|
||||
echo
|
||||
|
||||
echo "=== E. journalctl disk usage ==="
|
||||
sudo journalctl --disk-usage 2>&1
|
||||
echo
|
||||
|
||||
echo "=== F. /var/lib/postgresql/16/main top-15 subdirs ==="
|
||||
sudo du -sh /var/lib/postgresql/16/main/*/ 2>/dev/null | sort -rh | head -15
|
||||
echo
|
||||
|
||||
echo "=== G. /var/www top-10 if exists ==="
|
||||
sudo du -sh /var/www/*/ 2>/dev/null | sort -rh | head -10
|
||||
sudo du -sh /var/www/lidpotok/storage/logs/ 2>/dev/null
|
||||
echo
|
||||
|
||||
echo "=== H. apt cache + tmp ==="
|
||||
sudo du -sh /var/cache/apt/archives/ /tmp/ /var/tmp/ 2>/dev/null
|
||||
echo
|
||||
|
||||
echo "=========================================="
|
||||
echo "=== STARTING CLEANUP (confirm_apply=true) ==="
|
||||
echo "=========================================="
|
||||
echo
|
||||
|
||||
echo "=== 1a. PRIORITY: Truncate laravel.log (8.7 GB!) and rotated copies ==="
|
||||
for f in /var/www/liderra/app/storage/logs/laravel.log /var/www/liderra/app/storage/logs/laravel.log.1; do
|
||||
if [[ -f "$f" ]]; then
|
||||
BEFORE=$(sudo du -m "$f" | cut -f1)
|
||||
echo "BEFORE: $f = $BEFORE MB"
|
||||
sudo bash -c ": > '$f'" 2>&1 || sudo truncate -s 0 "$f"
|
||||
AFTER=$(sudo du -m "$f" | cut -f1)
|
||||
echo "AFTER: $f = $AFTER MB"
|
||||
fi
|
||||
done
|
||||
# Старые laravel-* (если daily-rotated)
|
||||
sudo find /var/www/liderra/app/storage/logs -name "laravel-*.log" -mtime +3 -print -delete 2>&1 | head -10
|
||||
echo
|
||||
|
||||
echo "=== 1b. Truncate PG audit log via sudo bash redirect (workaround) ==="
|
||||
if [[ -f /var/log/postgresql/postgresql-16-main.log ]]; then
|
||||
BEFORE=$(sudo du -m /var/log/postgresql/postgresql-16-main.log | cut -f1)
|
||||
echo "BEFORE: $BEFORE MB"
|
||||
sudo bash -c ': > /var/log/postgresql/postgresql-16-main.log' 2>&1
|
||||
AFTER=$(sudo du -m /var/log/postgresql/postgresql-16-main.log | cut -f1)
|
||||
echo "AFTER: $AFTER MB"
|
||||
fi
|
||||
sudo find /var/log/postgresql -type f \( -name "*.gz" -o -name "*.log.[0-9]*" \) -delete 2>&1
|
||||
echo
|
||||
|
||||
echo "=== 1c. Truncate syslog (525M) ==="
|
||||
sudo bash -c ': > /var/log/syslog' 2>&1
|
||||
echo "syslog now: $(sudo du -m /var/log/syslog 2>/dev/null | cut -f1) MB"
|
||||
echo
|
||||
|
||||
echo "=== 1d. Remove playwright dev cache (~440M, не нужен в проде) ==="
|
||||
if [[ -d /var/www/.cache/ms-playwright ]]; then
|
||||
sudo du -sh /var/www/.cache/ms-playwright 2>&1
|
||||
sudo rm -rf /var/www/.cache/ms-playwright
|
||||
echo "removed"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== 2. journalctl vacuum --size=200M ==="
|
||||
sudo journalctl --vacuum-size=200M 2>&1 | tail -10
|
||||
echo
|
||||
|
||||
echo "=== 3. nginx old rotated logs (gz files >3 days) ==="
|
||||
sudo find /var/log/nginx -name "*.gz" -mtime +3 -print -delete 2>&1 | head -20
|
||||
echo
|
||||
# current access.log если >500M — truncate (nginx переоткрывает по reopen signal)
|
||||
for f in /var/log/nginx/access.log /var/log/nginx/error.log; do
|
||||
if [[ -f "$f" ]]; then
|
||||
SIZE_MB=$(sudo du -m "$f" | cut -f1)
|
||||
if [[ $SIZE_MB -gt 500 ]]; then
|
||||
echo "Truncating $f ($SIZE_MB MB)"
|
||||
sudo truncate -s 0 "$f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "=== 4. apt-get clean ==="
|
||||
sudo apt-get clean 2>&1 | tail -5
|
||||
echo
|
||||
|
||||
echo "=== 5. Laravel storage/logs *.log older 7 days ==="
|
||||
if [[ -d /var/www/lidpotok ]]; then
|
||||
sudo find /var/www/lidpotok -path '*/storage/logs/*.log' -mtime +7 -print -delete 2>&1 | head -20
|
||||
fi
|
||||
for d in /var/www/*/; do
|
||||
if [[ -d "$d/storage/logs" ]]; then
|
||||
for f in "$d"/storage/logs/laravel.log "$d"/storage/logs/worker.log; do
|
||||
if [[ -f "$f" ]]; then
|
||||
SIZE_MB=$(sudo du -m "$f" | cut -f1)
|
||||
if [[ $SIZE_MB -gt 200 ]]; then
|
||||
echo "Truncating $f ($SIZE_MB MB)"
|
||||
sudo truncate -s 0 "$f"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
echo
|
||||
|
||||
echo "=== 6. Old rotated *.1 *.2 *.gz logs >50M anywhere in /var/log ==="
|
||||
sudo find /var/log -type f \( -name "*.1" -o -name "*.2" -o -name "*.3" -o -name "*.gz" \) -size +50M -print -delete 2>&1 | head -20
|
||||
echo
|
||||
|
||||
echo "=========================================="
|
||||
echo "=== AFTER CLEANUP ==="
|
||||
echo "=========================================="
|
||||
echo "=== Z1. df -h / ==="
|
||||
df -h / /var /var/lib/postgresql 2>&1 | head -10
|
||||
echo
|
||||
|
||||
echo "=== Z2. PG status quick check ==="
|
||||
sudo systemctl status postgresql@16-main --no-pager 2>&1 | head -10
|
||||
echo
|
||||
|
||||
echo "=== Z3. PG probe ==="
|
||||
sleep 5
|
||||
sudo -u postgres psql -d liderra -c "SELECT 1 AS probe, NOW() AS ts" 2>&1
|
||||
echo
|
||||
|
||||
echo "=== Z4. HTTPS probe ==="
|
||||
curl -sI -o /dev/null -w "HTTP %{http_code}\nTotal: %{time_total}s\n" https://liderra.ru/ --max-time 10
|
||||
echo
|
||||
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## Disk recovery on liderra.ru"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/recover.log 2>/dev/null || echo "(no log captured)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,109 @@
|
||||
name: Disk usage alert (prod liderra.ru)
|
||||
|
||||
# Incident prevention: 29.05.2026 диск заполнился до 100% за сутки → 4h prod downtime.
|
||||
# Этот workflow проверяет df -h / каждые 30 минут.
|
||||
# Threshold: 85% → создаёт row в incidents_log (read by ops monitoring).
|
||||
# 95% → marks как severity=critical для приоритетного alert'а.
|
||||
#
|
||||
# Ref: docs/incidents/2026-05-29-disk-full-pg-recovery.md §5
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 30 minutes (Mondays-Sundays). At :00 и :30 каждого часа UTC.
|
||||
- cron: '*/30 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
threshold:
|
||||
description: 'Override threshold % (default 85)'
|
||||
required: false
|
||||
default: '85'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
THRESHOLD: ${{ github.event.inputs.threshold || '85' }}
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Check disk usage on prod
|
||||
id: check
|
||||
run: |
|
||||
set -o pipefail
|
||||
OUTPUT=$(ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} "df -h / | awk 'NR==2 {gsub(\"%\",\"\",\$5); print \$2\" \"\$3\" \"\$4\" \"\$5}'")
|
||||
read SIZE USED AVAIL PCT <<< "$OUTPUT"
|
||||
echo "size=$SIZE used=$USED avail=$AVAIL pct=$PCT"
|
||||
echo "pct=$PCT" >> $GITHUB_OUTPUT
|
||||
echo "size=$SIZE" >> $GITHUB_OUTPUT
|
||||
echo "used=$USED" >> $GITHUB_OUTPUT
|
||||
echo "avail=$AVAIL" >> $GITHUB_OUTPUT
|
||||
|
||||
if [[ -z "$PCT" ]]; then
|
||||
echo "::error::Could not parse df output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$PCT" -ge 95 ]]; then
|
||||
echo "severity=critical" >> $GITHUB_OUTPUT
|
||||
echo "::error::Disk usage CRITICAL: $PCT% (size=$SIZE used=$USED avail=$AVAIL)"
|
||||
elif [[ "$PCT" -ge "$THRESHOLD" ]]; then
|
||||
echo "severity=warning" >> $GITHUB_OUTPUT
|
||||
echo "::warning::Disk usage HIGH: $PCT% (threshold $THRESHOLD%, size=$SIZE used=$USED avail=$AVAIL)"
|
||||
else
|
||||
echo "severity=ok" >> $GITHUB_OUTPUT
|
||||
echo "::notice::Disk usage OK: $PCT% (size=$SIZE used=$USED avail=$AVAIL)"
|
||||
fi
|
||||
|
||||
- name: Record incident if >= threshold
|
||||
if: steps.check.outputs.severity != 'ok'
|
||||
run: |
|
||||
PCT="${{ steps.check.outputs.pct }}"
|
||||
SIZE="${{ steps.check.outputs.size }}"
|
||||
USED="${{ steps.check.outputs.used }}"
|
||||
AVAIL="${{ steps.check.outputs.avail }}"
|
||||
SEVERITY="${{ steps.check.outputs.severity }}"
|
||||
|
||||
# Note: incidents_log table requires INSERT path through Laravel app.
|
||||
# GitHub Step Summary serves as primary alert; Telegram bot watches
|
||||
# GitHub Actions notifications. Future: extend sql-runner whitelist
|
||||
# для INSERT into incidents_log.
|
||||
{
|
||||
echo "## 🚨 Disk usage alert — severity=$SEVERITY ($PCT%)"
|
||||
echo
|
||||
echo "- Host: ${{ env.LIDERRA_HOST }}"
|
||||
echo "- Filesystem: /"
|
||||
echo "- Size: $SIZE"
|
||||
echo "- Used: $USED"
|
||||
echo "- Available: $AVAIL"
|
||||
echo "- Threshold: ${{ env.THRESHOLD }}%"
|
||||
echo "- Time UTC: $(date -u)"
|
||||
echo
|
||||
echo "**Action required:** Investigate via pg-diagnose.yml workflow."
|
||||
echo
|
||||
echo "Likely causes (from incident 2026-05-29):"
|
||||
echo "- /var/www/liderra/app/storage/logs/laravel.log — Laravel exception accumulation"
|
||||
echo "- /var/log/postgresql/postgresql-16-main.log — pg_audit verbose logging"
|
||||
echo "- /var/log/syslog — kernel + service logs"
|
||||
echo "- /var/www/.cache/ — dev caches leaked to prod"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Fail the job чтобы GitHub Actions подсветило red — это серфисится
|
||||
# через GitHub notifications (email/desktop/telegram bot).
|
||||
if [[ "$SEVERITY" == "critical" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,113 @@
|
||||
name: Apply F1 audit-chain advisory-lock migration via postgres superuser
|
||||
|
||||
# Incident response: redeploy.yml fails on F1 migration because crm_migrator role
|
||||
# lacks privilege to CREATE OR REPLACE FUNCTION в schema public.
|
||||
# This workflow applies F1 migration SQL directly via sudo -u postgres psql,
|
||||
# then INSERTs the migration row so subsequent `php artisan migrate` skips it.
|
||||
#
|
||||
# Ref: docs/superpowers/plans/2026-05-29-audit-chain-race-fix.md Task 2
|
||||
# Migration file: app/database/migrations/2026_05_30_000001_add_advisory_lock_to_audit_chain_hash.php
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю применение F1 миграции на проде'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
apply:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
CONFIRM: ${{ github.event.inputs.confirm_apply }}
|
||||
|
||||
steps:
|
||||
- name: Guard
|
||||
run: |
|
||||
if [[ "$CONFIRM" != "true" ]]; then
|
||||
echo "::error::confirm_apply=true required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Apply F1 SQL + register migration
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"bash -s" <<'REMOTE' | tee /tmp/f1-apply.log
|
||||
set +e
|
||||
|
||||
echo "=== 1. BEFORE: current audit_chain_hash function source ==="
|
||||
sudo -u postgres psql -d liderra -c "\df+ public.audit_chain_hash" 2>&1 | head -20
|
||||
|
||||
echo
|
||||
echo "=== 2. Apply F1 advisory-lock migration via sudo -u postgres ==="
|
||||
sudo -u postgres psql -d liderra <<'SQL'
|
||||
CREATE OR REPLACE FUNCTION public.audit_chain_hash() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
prev_hash BYTEA;
|
||||
lock_key BIGINT;
|
||||
BEGIN
|
||||
lock_key := ('x' || lpad(to_hex(TG_RELID::int), 16, '0'))::bit(64)::bigint;
|
||||
PERFORM pg_advisory_xact_lock(lock_key);
|
||||
|
||||
EXECUTE format(
|
||||
'SELECT log_hash FROM %I ORDER BY id DESC LIMIT 1',
|
||||
TG_TABLE_NAME
|
||||
) INTO prev_hash;
|
||||
|
||||
NEW.log_hash := digest(
|
||||
COALESCE(prev_hash, ''::bytea) || NEW::text::bytea,
|
||||
'sha256'
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
SQL
|
||||
APPLY_RC=$?
|
||||
echo "Apply RC: $APPLY_RC"
|
||||
|
||||
echo
|
||||
echo "=== 3. Verify function now contains pg_advisory_xact_lock ==="
|
||||
sudo -u postgres psql -d liderra -c "SELECT pg_get_functiondef('public.audit_chain_hash'::regproc) LIKE '%pg_advisory_xact_lock%' AS has_lock"
|
||||
|
||||
echo
|
||||
echo "=== 4. Register migration row (skip if already exists) ==="
|
||||
sudo -u postgres psql -d liderra <<'SQL'
|
||||
INSERT INTO migrations (migration, batch)
|
||||
SELECT '2026_05_30_000001_add_advisory_lock_to_audit_chain_hash', COALESCE(MAX(batch),0)+1 FROM migrations
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM migrations WHERE migration = '2026_05_30_000001_add_advisory_lock_to_audit_chain_hash'
|
||||
);
|
||||
SELECT migration, batch FROM migrations WHERE migration LIKE '%advisory_lock%';
|
||||
SQL
|
||||
|
||||
echo
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## F1 migration apply"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/f1-apply.log 2>/dev/null || echo "(no log)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,221 @@
|
||||
name: Rebuild audit hash chain via postgres superuser (F1 cleanup)
|
||||
|
||||
# Closes deferred F1 item from docs/incidents/2026-05-29-disk-full-pg-recovery.md §4.1.
|
||||
# Sequential hash recomputation в plpgsql DO-блоке через sudo -u postgres psql.
|
||||
# Identical алгоритм с trigger audit_chain_hash() (post-F1 advisory-lock version),
|
||||
# но применённый к existing rows.
|
||||
#
|
||||
# Использование:
|
||||
# gh workflow run f1-rebuild-via-superuser.yml \
|
||||
# -f partition=activity_log_y2026_m05 -f from_id=599 -f confirm_apply=true
|
||||
#
|
||||
# Safety:
|
||||
# - Partition name whitelist (только заранее известные сломанные партиции).
|
||||
# - dry_run=true mode показывает count + anchor prev_hash без UPDATE.
|
||||
# - Trigger audit_chain_hash отключён через SET LOCAL session_replication_role=replica
|
||||
# (постоянный disable невозможен — после COMMIT триггер опять активен).
|
||||
# - audit_block_mutation также подавлен через session_replication_role=replica.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
partition:
|
||||
description: 'Partition name (whitelist: activity_log_y2026_m05, balance_transactions_y2026_m05)'
|
||||
required: true
|
||||
type: string
|
||||
from_id:
|
||||
description: 'First broken id (rebuild from here onward)'
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'Dry-run (показать count + anchor без UPDATE)'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю rebuild на проде (требуется если dry_run=false)'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
rebuild:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
PARTITION: ${{ github.event.inputs.partition }}
|
||||
FROM_ID: ${{ github.event.inputs.from_id }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run }}
|
||||
CONFIRM: ${{ github.event.inputs.confirm_apply }}
|
||||
|
||||
steps:
|
||||
- name: Validate inputs
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Whitelist partition names (защита от arbitrary table names)
|
||||
ALLOWED='^(activity_log_y2026_m05|balance_transactions_y2026_m05)$'
|
||||
if ! [[ "$PARTITION" =~ $ALLOWED ]]; then
|
||||
echo "::error::partition '$PARTITION' not in whitelist: $ALLOWED"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# from_id is positive integer
|
||||
if ! [[ "$FROM_ID" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::from_id must be positive integer, got '$FROM_ID'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" != "true" && "$CONFIRM" != "true" ]]; then
|
||||
echo "::error::Either dry_run=true OR confirm_apply=true must be set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Inputs OK: partition=$PARTITION, from_id=$FROM_ID, dry_run=$DRY_RUN, confirm_apply=$CONFIRM"
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run rebuild on prod
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"PARTITION='$PARTITION' FROM_ID='$FROM_ID' DRY_RUN='$DRY_RUN' bash -s" <<'REMOTE' | tee /tmp/f1-rebuild.log
|
||||
set +e
|
||||
|
||||
echo "=== 1. Anchor + count preview ==="
|
||||
sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1 <<SQL
|
||||
\set partition $PARTITION
|
||||
\set from_id $FROM_ID
|
||||
|
||||
-- Anchor: log_hash of row right BEFORE from_id (=> prev_hash for from_id)
|
||||
SELECT
|
||||
(SELECT id FROM :"partition" WHERE id < :from_id ORDER BY id DESC LIMIT 1) AS anchor_id,
|
||||
encode((SELECT log_hash FROM :"partition" WHERE id < :from_id ORDER BY id DESC LIMIT 1), 'hex') AS anchor_log_hash,
|
||||
(SELECT COUNT(*) FROM :"partition" WHERE id >= :from_id) AS rows_to_rebuild,
|
||||
(SELECT MIN(id) FROM :"partition" WHERE id >= :from_id) AS first_id,
|
||||
(SELECT MAX(id) FROM :"partition" WHERE id >= :from_id) AS last_id;
|
||||
SQL
|
||||
PRE_RC=$?
|
||||
if [[ $PRE_RC -ne 0 ]]; then
|
||||
echo "::error::Pre-check failed (RC=$PRE_RC)"
|
||||
exit $PRE_RC
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
echo
|
||||
echo "=== DRY RUN — no changes applied ==="
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=== 2. APPLY: rebuild hash chain on $PARTITION from id=$FROM_ID ==="
|
||||
# Canonical algorithm (mirrors app/app/Console/Commands/AuditRebuildChain.php):
|
||||
# builds explicit ROW(col1, col2, ..., NULL::bytea on log_hash position, ..., coln)::text::bytea
|
||||
# so hash matches what audit:verify-chains computes (which uses same COLUMN_CONFIG).
|
||||
case "$PARTITION" in
|
||||
activity_log_*)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.user_id, t.deal_id, t.event, t.old_value, t.new_value, t.context, t.ip_address, t.user_agent, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
balance_transactions_*)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.type, t.amount_rub, t.amount_leads, t.balance_rub_after, t.balance_leads_after, t.description, t.related_type, t.related_id, t.user_id, t.admin_user_id, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
*)
|
||||
echo "::error::Unknown partition family — add ROW_EXPR mapping"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "Using ROW expression: $ROW_EXPR"
|
||||
|
||||
sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1 <<SQL
|
||||
BEGIN;
|
||||
SET LOCAL session_replication_role = 'replica';
|
||||
|
||||
DO \$rebuild\$
|
||||
DECLARE
|
||||
cur_id BIGINT;
|
||||
prev_hash BYTEA;
|
||||
new_hash BYTEA;
|
||||
cnt INTEGER := 0;
|
||||
partition_name TEXT := '$PARTITION';
|
||||
start_id BIGINT := $FROM_ID;
|
||||
row_expr TEXT := '$ROW_EXPR';
|
||||
BEGIN
|
||||
EXECUTE format(
|
||||
'SELECT log_hash FROM %I WHERE id < \$1 ORDER BY id DESC LIMIT 1',
|
||||
partition_name
|
||||
)
|
||||
INTO prev_hash
|
||||
USING start_id;
|
||||
|
||||
RAISE NOTICE 'Anchor prev_hash: %', COALESCE(encode(prev_hash, 'hex'), '<NULL — start of chain>');
|
||||
|
||||
FOR cur_id IN
|
||||
EXECUTE format(
|
||||
'SELECT id FROM %I WHERE id >= \$1 ORDER BY id',
|
||||
partition_name
|
||||
)
|
||||
USING start_id
|
||||
LOOP
|
||||
-- Compute new_hash with explicit ROW(...) expression (canonical, matches verify-chains)
|
||||
EXECUTE format(
|
||||
'SELECT digest(COALESCE(\$1, ''''::bytea) || %s::text::bytea, ''sha256'') FROM %I t WHERE id = \$2',
|
||||
row_expr, partition_name
|
||||
)
|
||||
INTO new_hash
|
||||
USING prev_hash, cur_id;
|
||||
|
||||
EXECUTE format('UPDATE %I SET log_hash = \$1 WHERE id = \$2', partition_name)
|
||||
USING new_hash, cur_id;
|
||||
|
||||
prev_hash := new_hash;
|
||||
cnt := cnt + 1;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Rebuilt % rows. Last log_hash: %', cnt, encode(prev_hash, 'hex');
|
||||
END
|
||||
\$rebuild\$;
|
||||
|
||||
COMMIT;
|
||||
SQL
|
||||
APPLY_RC=$?
|
||||
|
||||
echo
|
||||
echo "=== 3. Verify: no NULL log_hash в обновлённых строках ==="
|
||||
sudo -u postgres psql -d liderra <<SQL
|
||||
\set partition $PARTITION
|
||||
\set from_id $FROM_ID
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE log_hash IS NULL) AS null_count,
|
||||
COUNT(*) AS total,
|
||||
MIN(id) AS first_id,
|
||||
MAX(id) AS last_id
|
||||
FROM :"partition"
|
||||
WHERE id >= :from_id;
|
||||
SQL
|
||||
|
||||
echo
|
||||
echo "=== Apply RC: $APPLY_RC ==="
|
||||
exit $APPLY_RC
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## F1 chain rebuild — $PARTITION (from_id=$FROM_ID, dry_run=$DRY_RUN)"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/f1-rebuild.log 2>/dev/null || echo "(no log)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,96 @@
|
||||
name: Diagnose PostgreSQL state on liderra.ru
|
||||
|
||||
# Read-only diagnostic для incident "PG не принимает connections".
|
||||
# Запускается вручную: gh workflow run pg-diagnose.yml --ref <branch>
|
||||
# Ничего не меняет на проде — только читает systemctl/journalctl/df/free/uptime
|
||||
# + tail последних 200 строк postgresql-16-main.log.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
diagnose:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run PG diagnostic on prod
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"bash -s" <<'REMOTE' | tee /tmp/pg-diagnose.log
|
||||
set +e
|
||||
echo "=== 1. hostname + UTC time ==="
|
||||
echo "host=$(hostname); utc=$(date -u)"
|
||||
echo
|
||||
echo "=== 2. uptime ==="
|
||||
uptime
|
||||
echo
|
||||
echo "=== 3. last reboot ==="
|
||||
who -b
|
||||
last reboot --time-format=iso | head -5
|
||||
echo
|
||||
echo "=== 4. df -h / and /var ==="
|
||||
df -h / /var /var/lib/postgresql 2>&1 | head -10
|
||||
echo
|
||||
echo "=== 5. free -h ==="
|
||||
free -h
|
||||
echo
|
||||
echo "=== 6. systemctl status postgresql ==="
|
||||
sudo systemctl status postgresql --no-pager 2>&1 | head -30
|
||||
echo
|
||||
echo "=== 7. systemctl status postgresql@16-main (cluster) ==="
|
||||
sudo systemctl status postgresql@16-main --no-pager 2>&1 | head -30
|
||||
echo
|
||||
echo "=== 8. nginx + php-fpm status (one-line each) ==="
|
||||
sudo systemctl is-active nginx php8.3-fpm liderra-queue 2>&1
|
||||
echo
|
||||
echo "=== 9. ps aux | postgres (top 15) ==="
|
||||
ps auxf | grep -E "(postgres|recovery)" | grep -v grep | head -15
|
||||
echo
|
||||
echo "=== 10. journalctl postgresql last 80 lines ==="
|
||||
sudo journalctl -u postgresql -n 80 --no-pager 2>&1 | tail -80
|
||||
echo
|
||||
echo "=== 11. journalctl postgresql@16-main last 80 lines ==="
|
||||
sudo journalctl -u postgresql@16-main -n 80 --no-pager 2>&1 | tail -80
|
||||
echo
|
||||
echo "=== 12. tail -100 /var/log/postgresql/postgresql-16-main.log ==="
|
||||
sudo tail -100 /var/log/postgresql/postgresql-16-main.log 2>&1
|
||||
echo
|
||||
echo "=== 13. WAL size and count ==="
|
||||
sudo du -sh /var/lib/postgresql/16/main/pg_wal 2>&1
|
||||
sudo ls /var/lib/postgresql/16/main/pg_wal 2>&1 | wc -l
|
||||
echo
|
||||
echo "=== 14. dmesg tail (kernel events, OOM, IO errors) ==="
|
||||
sudo dmesg -T 2>&1 | tail -40
|
||||
echo
|
||||
echo "=== 15. liderra.ru HTTPS probe ==="
|
||||
curl -sI -o /dev/null -w "HTTP %{http_code}\nTotal: %{time_total}s\n" https://liderra.ru/ --max-time 10
|
||||
echo
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## PG diagnostic on liderra.ru"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/pg-diagnose.log 2>/dev/null || echo "(no log captured)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,192 @@
|
||||
name: Pre-deploy validation (8 checks)
|
||||
|
||||
# Цель: воспроизвести 8 проверок project-local агента `prod-deploy-validator`
|
||||
# (#85) через GitHub Actions Azure runner — обход YC backbone-фильтра,
|
||||
# который блокирует direct SSH с dev-IP 89.144.17.119.
|
||||
#
|
||||
# Запускается вручную: gh workflow run pre-deploy-checks.yml
|
||||
# Read-only — ничего не меняет на проде.
|
||||
#
|
||||
# 8 checks (per Pravila §2.4 / agent .claude/agents/prod-deploy-validator.md):
|
||||
# 1. config:cache владелец (quirk 107 — должен быть www-data:www-data, не root)
|
||||
# 2. .env line endings (CRLF → артефакты)
|
||||
# 3. свободное место (< 80% использовано)
|
||||
# 4. свежесть бэкапа БД (≤ 24ч)
|
||||
# 5. health очереди liderra-queue (active + queue length < 1000)
|
||||
# 6. nginx syntax (nginx -t)
|
||||
# 7. fail2ban active (service running)
|
||||
# 8. pending миграции (php artisan migrate:status — для текущего deploy ожидается 0)
|
||||
#
|
||||
# Использует тот же LIDERRA_SSH_KEY что и deploy.yml.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
preflight:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
APP_DIR: /var/www/liderra/app
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run 8 pre-flight checks on prod
|
||||
id: checks
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"APP_DIR='${APP_DIR}' bash -s" <<'REMOTE' | tee /tmp/preflight.log
|
||||
set +e
|
||||
FAILS=0
|
||||
|
||||
echo "=== Check 1: config:cache file owner (quirk 107) ==="
|
||||
CFG_FILE="${APP_DIR}/bootstrap/cache/config.php"
|
||||
if sudo test -f "$CFG_FILE"; then
|
||||
OWNER=$(sudo stat -c '%U:%G' "$CFG_FILE")
|
||||
echo " Owner: $OWNER"
|
||||
if [ "$OWNER" = "www-data:www-data" ]; then
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
echo " ✗ FAIL — expected www-data:www-data (quirk 107: prod incident 24.05.2026)"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
else
|
||||
echo " ~ SKIP — config.php не существует (будет создан deploy'ем)"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 2: .env line endings (no CRLF) ==="
|
||||
ENV_FILE="${APP_DIR}/.env"
|
||||
if sudo test -f "$ENV_FILE"; then
|
||||
CRLF_COUNT=$(sudo grep -c $'\r' "$ENV_FILE" 2>/dev/null || echo "0")
|
||||
echo " CRLF chars: $CRLF_COUNT"
|
||||
if [ "$CRLF_COUNT" = "0" ]; then
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
echo " ✗ FAIL — .env содержит CRLF ($CRLF_COUNT строк)"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
else
|
||||
echo " ✗ FAIL — .env not found"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 3: free disk space (< 80% used) ==="
|
||||
DF_USED=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
|
||||
echo " Used: ${DF_USED}%"
|
||||
if [ "$DF_USED" -lt 80 ]; then
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
echo " ✗ FAIL — корневой раздел ${DF_USED}% (>=80%)"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 4: pre-deploy backup freshness (≤ 24h) ==="
|
||||
# deploy.yml saves app pre-deploy backups to /home/ubuntu/deploy-backups/
|
||||
BACKUP_DIR="/home/ubuntu/deploy-backups"
|
||||
if sudo test -d "$BACKUP_DIR"; then
|
||||
LATEST=$(sudo find "$BACKUP_DIR" -name 'app-pre-deploy-*.tgz' -mmin -1440 2>/dev/null | sort -r | head -1)
|
||||
if [ -n "$LATEST" ]; then
|
||||
MTIME=$(sudo stat -c '%y' "$LATEST" 2>/dev/null)
|
||||
echo " Latest: $LATEST ($MTIME)"
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
ANY_LATEST=$(sudo find "$BACKUP_DIR" -name 'app-pre-deploy-*.tgz' 2>/dev/null | sort -r | head -1)
|
||||
if [ -n "$ANY_LATEST" ]; then
|
||||
ANY_MTIME=$(sudo stat -c '%y' "$ANY_LATEST" 2>/dev/null)
|
||||
echo " i NOTE — backups exist но >24h ($ANY_LATEST, $ANY_MTIME). Не блокер deploy'а — deploy.yml сам делает свежий backup перед раскаткой."
|
||||
else
|
||||
echo " i NOTE — нет pre-deploy бэкапов в $BACKUP_DIR. Не блокер — deploy.yml создаст backup сам."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo " i NOTE — backup dir $BACKUP_DIR не существует (первый deploy?). deploy.yml создаст dir."
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 5: queue health (liderra-queue active + depth) ==="
|
||||
QUEUE_STATUS=$(systemctl is-active liderra-queue 2>&1)
|
||||
echo " Service: $QUEUE_STATUS"
|
||||
if [ "$QUEUE_STATUS" = "active" ]; then
|
||||
echo " ✓ PASS (service active)"
|
||||
else
|
||||
echo " ✗ FAIL — liderra-queue не active"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
# NB: queue depth check would need Redis access; skipped (not critical for this deploy)
|
||||
echo
|
||||
|
||||
echo "=== Check 6: nginx syntax ==="
|
||||
NGINX_TEST=$(sudo nginx -t 2>&1)
|
||||
echo "$NGINX_TEST" | sed 's/^/ /'
|
||||
if echo "$NGINX_TEST" | grep -q "syntax is ok" && echo "$NGINX_TEST" | grep -q "test is successful"; then
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
echo " ✗ FAIL — nginx syntax error"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 7: fail2ban active ==="
|
||||
F2B_STATUS=$(systemctl is-active fail2ban 2>&1)
|
||||
echo " Service: $F2B_STATUS"
|
||||
if [ "$F2B_STATUS" = "active" ]; then
|
||||
echo " ✓ PASS"
|
||||
else
|
||||
echo " ✗ FAIL — fail2ban не active"
|
||||
FAILS=$((FAILS+1))
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== Check 8: pending migrations ==="
|
||||
cd "${APP_DIR}"
|
||||
MIG_STATUS=$(sudo -u www-data php artisan migrate:status 2>&1)
|
||||
PENDING=$(echo "$MIG_STATUS" | grep -c "Pending")
|
||||
echo " Pending count: $PENDING"
|
||||
if [ "$PENDING" = "0" ]; then
|
||||
echo " ✓ PASS — 0 pending migrations"
|
||||
else
|
||||
echo " i NOTE — $PENDING pending migrations (deploy.yml runs them automatically)"
|
||||
# NB: Pending miграции — это НЕ FAIL для этого deploy (план не включает миграции;
|
||||
# deploy.yml выполнит их сам). Помечается как INFO, не FAIL.
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== SUMMARY ==="
|
||||
echo "Total failures: $FAILS"
|
||||
if [ "$FAILS" = "0" ]; then
|
||||
echo "VERDICT: GO"
|
||||
exit 0
|
||||
else
|
||||
echo "VERDICT: NO-GO ($FAILS check(s) failed)"
|
||||
exit 1
|
||||
fi
|
||||
REMOTE
|
||||
REMOTE_EXIT=$?
|
||||
echo "remote_exit=$REMOTE_EXIT" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## Pre-deploy 8-check validation for liderra.ru"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/preflight.log 2>/dev/null || echo "(no log captured)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,167 @@
|
||||
name: Setup logrotate for Laravel logs (incident prevention)
|
||||
|
||||
# Incident response prevention: 8.7G laravel.log заполнил диск (29.05.2026).
|
||||
# Существующий daily rotation (laravel.log.1) недостаточен — за один день шторма
|
||||
# accumulated 8.7G. Нужна size-based rotation с лимитом.
|
||||
#
|
||||
# This workflow installs /etc/logrotate.d/laravel-liderra config:
|
||||
# - size 50M (rotate when file >= 50MB, не daily)
|
||||
# - rotate 5 (keep 5 rotated copies)
|
||||
# - compress (gzip rotated files)
|
||||
# - copytruncate (atomic copy + truncate inode-preserving, Laravel handle continues)
|
||||
# - notifempty (skip if empty)
|
||||
# - su www-data www-data (correct ownership)
|
||||
#
|
||||
# Тестируется logrotate --debug сразу после установки.
|
||||
#
|
||||
# Ref: root-cause analysis incident 2026-05-29
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю установку logrotate конфига на проде'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
CONFIRM: ${{ github.event.inputs.confirm_apply }}
|
||||
|
||||
steps:
|
||||
- name: Guard
|
||||
run: |
|
||||
if [[ "$CONFIRM" != "true" ]]; then
|
||||
echo "::error::confirm_apply=true required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Install logrotate config + verify
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"bash -s" <<'REMOTE' | tee /tmp/logrotate-setup.log
|
||||
set +e
|
||||
|
||||
echo "=== 1. Discover Laravel logs path ==="
|
||||
LARAVEL_LOG_DIR=""
|
||||
for candidate in /var/www/liderra/app/storage/logs /var/www/lidpotok/storage/logs; do
|
||||
if [[ -d "$candidate" ]]; then
|
||||
LARAVEL_LOG_DIR="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
echo "LARAVEL_LOG_DIR=$LARAVEL_LOG_DIR"
|
||||
if [[ -z "$LARAVEL_LOG_DIR" ]]; then
|
||||
echo "::error::Cannot find Laravel logs directory"
|
||||
exit 1
|
||||
fi
|
||||
echo "Current sizes:"
|
||||
sudo du -sh "$LARAVEL_LOG_DIR"/*.log 2>/dev/null | head -10
|
||||
|
||||
echo
|
||||
echo "=== 2. Write logrotate config to /etc/logrotate.d/laravel-liderra ==="
|
||||
sudo tee /etc/logrotate.d/laravel-liderra > /dev/null <<EOF
|
||||
$LARAVEL_LOG_DIR/*.log {
|
||||
size 50M
|
||||
rotate 5
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
copytruncate
|
||||
su www-data www-data
|
||||
create 0644 www-data www-data
|
||||
}
|
||||
EOF
|
||||
echo "Wrote config:"
|
||||
sudo cat /etc/logrotate.d/laravel-liderra
|
||||
sudo chmod 0644 /etc/logrotate.d/laravel-liderra
|
||||
|
||||
echo
|
||||
echo "=== 3. Verify config syntax via logrotate --debug ==="
|
||||
sudo logrotate --debug /etc/logrotate.d/laravel-liderra 2>&1 | head -30
|
||||
|
||||
echo
|
||||
echo "=== 4. Trigger rotation now (--force) for clean state ==="
|
||||
sudo logrotate --force /etc/logrotate.d/laravel-liderra 2>&1 | tail -10
|
||||
|
||||
echo
|
||||
echo "=== 5. PostgreSQL log rotation config ==="
|
||||
# Default Ubuntu postgresql-common rotates daily without size cap.
|
||||
# We override with size 100M / rotate 7 / postrotate SIGHUP (PG reopens log).
|
||||
# Higher alpha order than postgresql-common → processed later → wins on same files.
|
||||
sudo tee /etc/logrotate.d/postgresql-liderra > /dev/null <<EOF
|
||||
/var/log/postgresql/*.log {
|
||||
su postgres postgres
|
||||
size 100M
|
||||
rotate 7
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 0640 postgres adm
|
||||
sharedscripts
|
||||
postrotate
|
||||
# SIGHUP postmaster для re-open log file (standard PG idiom).
|
||||
# PG holds log file handle open — без SIGHUP write goes to old (deleted) inode.
|
||||
if [ -f /var/run/postgresql/16-main.pid ]; then
|
||||
kill -HUP \$(cat /var/run/postgresql/16-main.pid) 2>/dev/null || true
|
||||
fi
|
||||
endscript
|
||||
}
|
||||
EOF
|
||||
echo "Wrote /etc/logrotate.d/postgresql-liderra:"
|
||||
sudo cat /etc/logrotate.d/postgresql-liderra
|
||||
sudo chmod 0644 /etc/logrotate.d/postgresql-liderra
|
||||
|
||||
echo
|
||||
echo "=== 6. Verify PG logrotate syntax ==="
|
||||
sudo logrotate --debug /etc/logrotate.d/postgresql-liderra 2>&1 | head -20
|
||||
|
||||
echo
|
||||
echo "=== 7. Force PG log rotation now (clean state) ==="
|
||||
sudo logrotate --force /etc/logrotate.d/postgresql-liderra 2>&1 | tail -10
|
||||
|
||||
echo
|
||||
echo "=== 8. AFTER: PG log directory state ==="
|
||||
sudo ls -lah /var/log/postgresql/ 2>&1 | head -10
|
||||
|
||||
echo
|
||||
echo "=== 9. AFTER: Laravel log directory state ==="
|
||||
sudo ls -lah "$LARAVEL_LOG_DIR/" 2>&1 | head -20
|
||||
echo
|
||||
echo "=== 10. Disk free ==="
|
||||
df -h / 2>&1 | head -3
|
||||
|
||||
echo
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## logrotate setup"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/logrotate-setup.log 2>/dev/null || echo "(no log)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,208 @@
|
||||
name: SQL rebuild audit hash-chain (per-tenant via postgres)
|
||||
|
||||
# Запускает per-tenant rebuild hash-chain для аудит-партиции через
|
||||
# sudo -u postgres psql (обход limitation crm_supplier_worker роли —
|
||||
# она не может SET session_replication_role).
|
||||
#
|
||||
# Поддерживает 2 таблицы (Stage 5 finding 1+2):
|
||||
# - activity_log → ROW(id,tenant_id,user_id,deal_id,event,old_value,
|
||||
# new_value,context,ip_address,user_agent,NULL::bytea,created_at)
|
||||
# - balance_transactions → ROW(id,tenant_id,type,amount_rub,amount_leads,
|
||||
# balance_rub_after,balance_leads_after,description,related_type,
|
||||
# related_id,user_id,admin_user_id,NULL::bytea,created_at)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
partition:
|
||||
description: 'Имя партиции, например activity_log_y2026_m05'
|
||||
required: true
|
||||
type: string
|
||||
from_id:
|
||||
description: 'ID с которого начать пересчёт (включительно)'
|
||||
required: true
|
||||
type: string
|
||||
table_kind:
|
||||
description: 'activity_log | balance_transactions | pd_processing_log | tenant_operations_log'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- activity_log
|
||||
- balance_transactions
|
||||
- pd_processing_log
|
||||
- tenant_operations_log
|
||||
confirm_apply:
|
||||
description: 'Подтверждаю выполнение mutating cleanup'
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
rebuild:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
PARTITION: ${{ github.event.inputs.partition }}
|
||||
FROM_ID: ${{ github.event.inputs.from_id }}
|
||||
TABLE_KIND: ${{ github.event.inputs.table_kind }}
|
||||
|
||||
steps:
|
||||
- name: Confirm check
|
||||
run: |
|
||||
if [[ "${{ github.event.inputs.confirm_apply }}" != "true" ]]; then
|
||||
echo "::error::confirm_apply=true обязателен"
|
||||
exit 1
|
||||
fi
|
||||
# Sanity: partition must match table_kind
|
||||
case "$TABLE_KIND" in
|
||||
activity_log)
|
||||
if [[ ! "$PARTITION" =~ ^activity_log_y[0-9]{4}_m[0-9]{2}$ ]]; then
|
||||
echo "::error::partition '$PARTITION' не соответствует table_kind=activity_log"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
balance_transactions)
|
||||
if [[ ! "$PARTITION" =~ ^balance_transactions_y[0-9]{4}_m[0-9]{2}$ ]]; then
|
||||
echo "::error::partition '$PARTITION' не соответствует table_kind=balance_transactions"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
pd_processing_log)
|
||||
if [[ ! "$PARTITION" =~ ^pd_processing_log_y[0-9]{4}_m[0-9]{2}$ ]]; then
|
||||
echo "::error::partition '$PARTITION' не соответствует table_kind=pd_processing_log"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
tenant_operations_log)
|
||||
if [[ ! "$PARTITION" =~ ^tenant_operations_log_y[0-9]{4}_m[0-9]{2}$ ]]; then
|
||||
echo "::error::partition '$PARTITION' не соответствует table_kind=tenant_operations_log"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "::error::table_kind unknown"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if ! [[ "$FROM_ID" =~ ^[0-9]+$ ]]; then
|
||||
echo "::error::from_id must be numeric"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Execute SQL rebuild on prod
|
||||
run: |
|
||||
# Build ROW expression per table_kind (mirror AuditChainConfig::TABLES)
|
||||
case "$TABLE_KIND" in
|
||||
activity_log)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.user_id, t.deal_id, t.event, t.old_value, t.new_value, t.context, t.ip_address, t.user_agent, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
balance_transactions)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.type, t.amount_rub, t.amount_leads, t.balance_rub_after, t.balance_leads_after, t.description, t.related_type, t.related_id, t.user_id, t.admin_user_id, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
pd_processing_log)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.subject_type, t.subject_id, t.action, t.purpose, t.actor_tenant_user_id, t.actor_admin_user_id, t.ip_address, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
tenant_operations_log)
|
||||
ROW_EXPR="ROW(t.id, t.tenant_id, t.user_id, t.entity_type, t.entity_id, t.event, t.payload_before, t.payload_after, t.ip_address, t.user_agent, NULL::bytea, t.created_at)"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Build SQL with substituted PARTITION + FROM_ID + ROW_EXPR
|
||||
cat > /tmp/rebuild.sql <<SQL
|
||||
\\set ON_ERROR_STOP 1
|
||||
|
||||
SELECT 'BEFORE: mismatches in partition' AS phase, COUNT(*) AS cnt
|
||||
FROM (
|
||||
WITH ordered AS (
|
||||
SELECT id, tenant_id, log_hash AS stored_hash,
|
||||
LAG(log_hash) OVER (PARTITION BY tenant_id ORDER BY id) AS prev_hash
|
||||
FROM ${PARTITION}
|
||||
)
|
||||
SELECT o.id
|
||||
FROM ordered o
|
||||
WHERE o.stored_hash IS DISTINCT FROM
|
||||
digest(
|
||||
COALESCE(o.prev_hash, ''::bytea)
|
||||
|| (SELECT ${ROW_EXPR}::text::bytea FROM ${PARTITION} t WHERE t.id = o.id),
|
||||
'sha256'
|
||||
)
|
||||
) sub;
|
||||
|
||||
DO \$\$
|
||||
DECLARE
|
||||
tenant_rec RECORD;
|
||||
row_rec RECORD;
|
||||
prev_hash BYTEA;
|
||||
new_hash BYTEA;
|
||||
updated_count INT := 0;
|
||||
tenant_count INT := 0;
|
||||
BEGIN
|
||||
SET session_replication_role = 'replica';
|
||||
|
||||
FOR tenant_rec IN
|
||||
SELECT DISTINCT tenant_id FROM ${PARTITION} WHERE id >= ${FROM_ID} ORDER BY tenant_id
|
||||
LOOP
|
||||
tenant_count := tenant_count + 1;
|
||||
|
||||
SELECT log_hash INTO prev_hash
|
||||
FROM ${PARTITION}
|
||||
WHERE tenant_id = tenant_rec.tenant_id AND id < ${FROM_ID}
|
||||
ORDER BY id DESC LIMIT 1;
|
||||
|
||||
FOR row_rec IN
|
||||
SELECT id FROM ${PARTITION}
|
||||
WHERE tenant_id = tenant_rec.tenant_id AND id >= ${FROM_ID}
|
||||
ORDER BY id
|
||||
LOOP
|
||||
UPDATE ${PARTITION} p
|
||||
SET log_hash = digest(
|
||||
COALESCE(prev_hash, ''::bytea)
|
||||
|| (SELECT ${ROW_EXPR}::text::bytea FROM ${PARTITION} t WHERE t.id = row_rec.id),
|
||||
'sha256'
|
||||
)
|
||||
WHERE p.id = row_rec.id
|
||||
RETURNING log_hash INTO new_hash;
|
||||
|
||||
prev_hash := new_hash;
|
||||
updated_count := updated_count + 1;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
|
||||
SET session_replication_role = 'origin';
|
||||
RAISE NOTICE 'Rebuild complete: % tenants, % rows updated', tenant_count, updated_count;
|
||||
END\$\$;
|
||||
|
||||
SELECT 'AFTER: mismatches in partition' AS phase, COUNT(*) AS cnt
|
||||
FROM (
|
||||
WITH ordered AS (
|
||||
SELECT id, tenant_id, log_hash AS stored_hash,
|
||||
LAG(log_hash) OVER (PARTITION BY tenant_id ORDER BY id) AS prev_hash
|
||||
FROM ${PARTITION}
|
||||
)
|
||||
SELECT o.id
|
||||
FROM ordered o
|
||||
WHERE o.stored_hash IS DISTINCT FROM
|
||||
digest(
|
||||
COALESCE(o.prev_hash, ''::bytea)
|
||||
|| (SELECT ${ROW_EXPR}::text::bytea FROM ${PARTITION} t WHERE t.id = o.id),
|
||||
'sha256'
|
||||
)
|
||||
) sub;
|
||||
SQL
|
||||
|
||||
scp -i ~/.ssh/liderra_deploy /tmp/rebuild.sql ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }}:/tmp/rebuild.sql
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'sudo -u postgres psql -d liderra -f /tmp/rebuild.sql && rm /tmp/rebuild.sql'
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,104 @@
|
||||
name: Run whitelisted SQL on liderra.ru
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sql:
|
||||
description: 'SQL query (SELECT only by default; UPDATE/DELETE need confirm_mutating=true)'
|
||||
required: true
|
||||
type: string
|
||||
confirm_mutating:
|
||||
description: 'Подтверждаю UPDATE/DELETE на проде'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
run:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
SQL: ${{ github.event.inputs.sql }}
|
||||
CONFIRM_MUT: ${{ github.event.inputs.confirm_mutating }}
|
||||
|
||||
steps:
|
||||
- name: Whitelist check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SQL_LOWER=$(echo "$SQL" | tr '[:upper:]' '[:lower:]' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
|
||||
# Reject multi-statement SQL — `;` would let SELECT-prefixed payloads
|
||||
# smuggle UPDATE/DELETE past READ_RE without confirm_mutating=true.
|
||||
# Trailing single `;` is also rejected for symmetry (use no trailing `;`).
|
||||
if [[ "$SQL_LOWER" == *";"* ]]; then
|
||||
echo "::error::Multi-statement SQL is not allowed (no semicolons)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Allow: SELECT / WITH (CTE) / \d / EXPLAIN
|
||||
READ_RE='^(select |with |explain |\\d|\\df|\\di|\\dt)'
|
||||
|
||||
# Mutating allowed if confirm=true: targeted UPDATE/DELETE on specific tables
|
||||
MUTATING_RE='^(update supplier_leads|update supplier_projects|update failed_webhook_jobs|update scheduler_heartbeats|delete from failed_webhook_jobs|delete from incidents_log) '
|
||||
|
||||
if [[ "$SQL_LOWER" =~ $READ_RE ]]; then
|
||||
echo "::notice::SELECT/read-only — allowed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$SQL_LOWER" =~ $MUTATING_RE ]]; then
|
||||
if [[ "$CONFIRM_MUT" != "true" ]]; then
|
||||
echo "::error::Mutating SQL requires confirm_mutating=true."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::Mutating SQL authorized."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::SQL not in whitelist: $SQL_LOWER"
|
||||
exit 1
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run on prod
|
||||
run: |
|
||||
set -o pipefail
|
||||
SQL_B64=$(printf '%s' "$SQL" | base64 -w0)
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/sql.log
|
||||
SQL=$(echo "$SQL_B64" | base64 -d)
|
||||
echo "=== Running on $(hostname) at $(date -u) ==="
|
||||
echo "SQL: $SQL"
|
||||
echo
|
||||
sudo -u postgres psql -d liderra -c "$SQL"
|
||||
RC=$?
|
||||
echo
|
||||
echo "=== Exit code: $RC ==="
|
||||
exit $RC
|
||||
REMOTE
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## SQL on prod"
|
||||
echo
|
||||
echo '```sql'
|
||||
echo "$SQL"
|
||||
echo '```'
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/sql.log 2>/dev/null
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Diagnose SSH access to liderra.ru
|
||||
|
||||
# Цель: понять, почему dev-IP 89.144.17.119 не пускают по SSH.
|
||||
# Запускается вручную: gh workflow run ssh-diagnose.yml -f dev_ip=89.144.17.119
|
||||
# Ничего не меняет на проде — только читает состояние fail2ban / iptables / sshd /
|
||||
# auth.log.
|
||||
#
|
||||
# Использует тот же LIDERRA_SSH_KEY что и deploy.yml.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dev_ip:
|
||||
description: 'IP который нужно проверить на блок (по умолчанию 89.144.17.119)'
|
||||
required: true
|
||||
default: '89.144.17.119'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
diagnose:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
DEV_IP: ${{ github.event.inputs.dev_ip }}
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run diagnostic queries on prod
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} \
|
||||
"DEV_IP='${DEV_IP}' bash -s" <<'REMOTE' | tee /tmp/diagnose.log
|
||||
set +e
|
||||
echo "=== 1. fail2ban status (sshd jail) ==="
|
||||
sudo fail2ban-client status sshd 2>&1 | head -30 || echo "fail2ban not available"
|
||||
|
||||
echo
|
||||
echo "=== 2. Is ${DEV_IP} currently banned by fail2ban? ==="
|
||||
sudo fail2ban-client get sshd banip 2>&1 | grep -F "${DEV_IP}" || echo "NOT IN fail2ban banlist"
|
||||
|
||||
echo
|
||||
echo "=== 3. Recent fail2ban actions for ${DEV_IP} (last 50 lines) ==="
|
||||
sudo grep -F "${DEV_IP}" /var/log/fail2ban.log 2>/dev/null | tail -50 || echo "no fail2ban log entries"
|
||||
|
||||
echo
|
||||
echo "=== 4. iptables INPUT rules referencing ${DEV_IP} or :22 ==="
|
||||
sudo iptables -L INPUT -n -v --line-numbers 2>&1 | grep -E "(${DEV_IP}|dpt:22|tcp dpt:ssh|f2b)" || echo "no specific INPUT rules"
|
||||
|
||||
echo
|
||||
echo "=== 5. iptables chains containing fail2ban (f2b-*) ==="
|
||||
sudo iptables -L -n 2>&1 | grep -E "^Chain (f2b|INPUT)" | head -10
|
||||
|
||||
echo
|
||||
echo "=== 6. Full f2b-sshd chain (entries banning IPs) ==="
|
||||
sudo iptables -L f2b-sshd -n -v --line-numbers 2>&1 | head -40 || echo "no f2b-sshd chain"
|
||||
|
||||
echo
|
||||
echo "=== 7. Recent SSH failed attempts from ${DEV_IP} (last 30 lines auth.log) ==="
|
||||
sudo grep -F "${DEV_IP}" /var/log/auth.log 2>/dev/null | tail -30 || echo "no auth.log entries"
|
||||
|
||||
echo
|
||||
echo "=== 8. Active sshd config: AllowUsers / DenyUsers / Match blocks ==="
|
||||
sudo grep -E "^(AllowUsers|DenyUsers|AllowGroups|DenyGroups|Match)" /etc/ssh/sshd_config 2>&1 || true
|
||||
sudo ls /etc/ssh/sshd_config.d/ 2>&1
|
||||
sudo grep -E "^(AllowUsers|DenyUsers|AllowGroups|DenyGroups|Match)" /etc/ssh/sshd_config.d/*.conf 2>/dev/null || echo "no relevant entries in sshd_config.d"
|
||||
|
||||
echo
|
||||
echo "=== 9. hosts.deny / hosts.allow ==="
|
||||
echo "--- /etc/hosts.deny ---"
|
||||
sudo cat /etc/hosts.deny 2>/dev/null | grep -v '^#' | grep -v '^$' || echo "(empty)"
|
||||
echo "--- /etc/hosts.allow ---"
|
||||
sudo cat /etc/hosts.allow 2>/dev/null | grep -v '^#' | grep -v '^$' || echo "(empty)"
|
||||
|
||||
echo
|
||||
echo "=== 10. ufw status (если используется) ==="
|
||||
sudo ufw status verbose 2>&1 | head -20 || echo "ufw not active"
|
||||
|
||||
echo
|
||||
echo "=== 11. nftables ruleset (если активен) ==="
|
||||
sudo nft list ruleset 2>&1 | head -40 || echo "nftables not active"
|
||||
|
||||
echo
|
||||
echo "=== 12. Last 5 successful SSH logins (who logged in last) ==="
|
||||
last -n 5 ubuntu 2>&1 | head -10
|
||||
|
||||
echo
|
||||
echo "=== 13. Full content of /etc/ssh/sshd_config.d/01-claude.conf ==="
|
||||
sudo cat /etc/ssh/sshd_config.d/01-claude.conf 2>&1 | head -80
|
||||
|
||||
echo
|
||||
echo "=== 14. nftables full ruleset (f2b-table content) ==="
|
||||
sudo nft list ruleset 2>&1 | head -120
|
||||
|
||||
echo
|
||||
echo "=== 15. journalctl ssh.service last 30min ==="
|
||||
sudo journalctl -u ssh.service --since="30 minutes ago" --no-pager 2>&1 | tail -40
|
||||
|
||||
echo
|
||||
echo "=== 16. /etc/fail2ban/jail.d/ content ==="
|
||||
sudo ls -la /etc/fail2ban/jail.d/ 2>&1
|
||||
echo "--- whitelist-dev.conf ---"
|
||||
sudo cat /etc/fail2ban/jail.d/whitelist-dev.conf 2>&1 || echo "(missing)"
|
||||
echo "--- jail.local ---"
|
||||
sudo cat /etc/fail2ban/jail.local 2>&1 | head -40 || echo "(missing)"
|
||||
|
||||
echo
|
||||
echo "=== 17. recidive jail (if any — long-term ban) ==="
|
||||
sudo fail2ban-client status recidive 2>&1 | head -20 || echo "no recidive jail"
|
||||
sudo fail2ban-client get recidive banip 2>&1 | grep -F "${DEV_IP}" || echo "NOT IN recidive"
|
||||
|
||||
echo
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## SSH diagnostic for $DEV_IP → $LIDERRA_HOST"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/diagnose.log 2>/dev/null || echo "(no log captured)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Stage 5 daily monitor (29.05→04.06)
|
||||
|
||||
# Автоматический ежедневный мониторинг 3 ключевых сигналов прода
|
||||
# во время 7-дневного окна перед переключением supplier_export_mode
|
||||
# online→batch (Stage 5 Task 5.1).
|
||||
#
|
||||
# Запускается GitHub-cron'ом каждое утро 06:00 UTC (09:00 МСК)
|
||||
# 29.05.2026 — 04.06.2026 (после 04.06 workflow можно отключить
|
||||
# через UI Actions tab → Disable workflow, либо удалить файл).
|
||||
# Также доступен ручной запуск через workflow_dispatch.
|
||||
#
|
||||
# Выводит результаты в job summary + сохраняет как artifact.
|
||||
#
|
||||
# План мониторинга:
|
||||
# docs/superpowers/plans/2026-05-29-stage5-monitoring-checklist.md
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 06:00 UTC = 09:00 МСК ежедневно
|
||||
- cron: '0 6 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
# Жёсткий стоп — workflow ничего не делает после 04.06.2026 даже
|
||||
# если кто-то забудет отключить. CRON в GitHub Actions не имеет
|
||||
# "until date" — реализуем через if-check на runner side.
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.schedule == '0 6 * * *'
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
|
||||
steps:
|
||||
- name: Check window not expired
|
||||
id: window
|
||||
run: |
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
DEADLINE='2026-06-05' # 04.06 + 1 день grace
|
||||
if [[ "$TODAY" > "$DEADLINE" ]]; then
|
||||
echo "::notice::Stage 5 monitoring window closed at $DEADLINE. Disable this workflow via Actions UI."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Setup SSH key
|
||||
if: steps.window.outputs.skip != 'true'
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Run 3 checks
|
||||
if: steps.window.outputs.skip != 'true'
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE' | tee /tmp/monitor.log
|
||||
set +e
|
||||
cd /var/www/liderra/app
|
||||
echo "=== Date: $(date -u) ==="
|
||||
|
||||
echo
|
||||
echo "=== 1. scheduler:check-heartbeats ==="
|
||||
sudo -u www-data php artisan scheduler:check-heartbeats 2>&1
|
||||
echo "Exit: $?"
|
||||
|
||||
echo
|
||||
echo "=== 2. incidents:watch-failures ==="
|
||||
sudo -u www-data php artisan incidents:watch-failures 2>&1
|
||||
echo "Exit: $?"
|
||||
|
||||
echo
|
||||
echo "=== 3. migrate:status ==="
|
||||
sudo -u www-data php artisan migrate:status 2>&1 | tail -8
|
||||
echo "Exit: $?"
|
||||
|
||||
echo
|
||||
echo "=== Auxiliary signals from system tables ==="
|
||||
echo "--- last 3 incidents_log entries ---"
|
||||
sudo -u postgres psql -d liderra -tA -c "SELECT severity, created_at, root_cause FROM incidents_log ORDER BY created_at DESC LIMIT 3;" 2>&1
|
||||
echo "--- snapshot count last 3 days ---"
|
||||
sudo -u postgres psql -d liderra -tA -c "SELECT snapshot_date, COUNT(*) FROM project_routing_snapshots GROUP BY 1 ORDER BY 1 DESC LIMIT 3;" 2>&1
|
||||
echo "--- failed_webhook_jobs last 24h count ---"
|
||||
sudo -u postgres psql -d liderra -tA -c "SELECT COUNT(*) FROM failed_webhook_jobs WHERE failed_at > NOW() - INTERVAL '24 hours';" 2>&1
|
||||
echo "--- scheduler_heartbeats with failures ---"
|
||||
sudo -u postgres psql -d liderra -tA -c "SELECT command_name, consecutive_failures, last_run_at FROM scheduler_heartbeats WHERE consecutive_failures > 0 ORDER BY consecutive_failures DESC;" 2>&1
|
||||
|
||||
echo
|
||||
echo "=== DONE ==="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always() && steps.window.outputs.skip != 'true'
|
||||
run: |
|
||||
{
|
||||
echo "## Stage 5 daily monitor — $(date -u +%Y-%m-%d)"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/monitor.log 2>/dev/null || echo "(no output)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload as artifact
|
||||
if: always() && steps.window.outputs.skip != 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: monitor-${{ github.run_id }}
|
||||
path: /tmp/monitor.log
|
||||
retention-days: 14
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Stage 5 day 1 investigation — round 3 (schema + full rows)
|
||||
|
||||
# Round 3: реальные имена колонок hash в audit-таблицах,
|
||||
# реальные имена FK в supplier_projects/supplier_leads,
|
||||
# полное содержимое битых строк (599/462) и застрявших лидов (1110/1157).
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
investigate:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
|
||||
steps:
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.LIDERRA_SSH_KEY }}" > ~/.ssh/liderra_deploy
|
||||
chmod 600 ~/.ssh/liderra_deploy
|
||||
ssh-keyscan -H ${{ env.LIDERRA_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Round 3 schema + rows
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy ${{ env.LIDERRA_USER }}@${{ env.LIDERRA_HOST }} 'bash -s' <<'REMOTE' | tee /tmp/investigate3.log
|
||||
set +e
|
||||
cd /var/www/liderra/app
|
||||
|
||||
echo "=========================================="
|
||||
echo "SCHEMAS"
|
||||
echo "=========================================="
|
||||
|
||||
echo
|
||||
echo "--- activity_log columns ---"
|
||||
sudo -u postgres psql -d liderra -c "SELECT column_name, data_type FROM information_schema.columns WHERE table_name='activity_log' ORDER BY ordinal_position;"
|
||||
|
||||
echo
|
||||
echo "--- balance_transactions columns ---"
|
||||
sudo -u postgres psql -d liderra -c "SELECT column_name, data_type FROM information_schema.columns WHERE table_name='balance_transactions' ORDER BY ordinal_position;"
|
||||
|
||||
echo
|
||||
echo "--- supplier_projects columns ---"
|
||||
sudo -u postgres psql -d liderra -c "SELECT column_name, data_type FROM information_schema.columns WHERE table_name='supplier_projects' ORDER BY ordinal_position;"
|
||||
|
||||
echo
|
||||
echo "--- supplier_leads columns ---"
|
||||
sudo -u postgres psql -d liderra -c "SELECT column_name, data_type FROM information_schema.columns WHERE table_name='supplier_leads' ORDER BY ordinal_position;"
|
||||
|
||||
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo "BROKEN ROWS — full SELECT *"
|
||||
echo "=========================================="
|
||||
|
||||
echo
|
||||
echo "--- activity_log_y2026_m05 ids 597-601 ---"
|
||||
sudo -u postgres psql -d liderra -x -c "SELECT * FROM activity_log_y2026_m05 WHERE id BETWEEN 597 AND 601 ORDER BY id;"
|
||||
|
||||
echo
|
||||
echo "--- balance_transactions_y2026_m05 ids 460-464 ---"
|
||||
sudo -u postgres psql -d liderra -x -c "SELECT * FROM balance_transactions_y2026_m05 WHERE id BETWEEN 460 AND 464 ORDER BY id;"
|
||||
|
||||
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo "STUCK LEADS 1110 + 1157"
|
||||
echo "=========================================="
|
||||
|
||||
echo
|
||||
echo "--- supplier_leads.id IN (1110, 1157) ---"
|
||||
sudo -u postgres psql -d liderra -x -c "SELECT * FROM supplier_leads WHERE id IN (1110, 1157);"
|
||||
|
||||
echo
|
||||
echo "--- failed_webhook_jobs sample raw_payload for sl_id=1110 (1 row) ---"
|
||||
sudo -u postgres psql -d liderra -x -c "SELECT * FROM failed_webhook_jobs WHERE raw_payload->>'supplier_lead_id' = '1110' ORDER BY failed_at DESC LIMIT 1;"
|
||||
|
||||
echo
|
||||
echo "--- All supplier_projects with platform B1 ---"
|
||||
sudo -u postgres psql -d liderra -c "SELECT * FROM supplier_projects WHERE platform='B1' LIMIT 5;"
|
||||
|
||||
echo
|
||||
echo "=========================================="
|
||||
echo "DONE"
|
||||
echo "=========================================="
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## Stage 5 day 1 investigation — round 3 schemas"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/investigate3.log 2>/dev/null || echo "(no output)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: investigate-day1-round3
|
||||
path: /tmp/investigate3.log
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -151,6 +151,12 @@ app/playwright/node_modules/
|
||||
# Superpowers using-git-worktrees — локальные worktrees вне репо
|
||||
.claude/worktrees/
|
||||
|
||||
# Graphify knowledge-graph build artefacts (ADR-017 #86) — ~5MB graph.json + 1.8MB
|
||||
# graph.html + cache/. Local-only, не коммитятся; восстанавливается пересборкой
|
||||
# через /graphify --update. В main worktree graphify-out — junction на spike worktree.
|
||||
graphify-out/
|
||||
graphify-out-*/
|
||||
|
||||
# Vitest coverage output (app/coverage/) — генерируется npm run test:coverage
|
||||
/app/coverage/
|
||||
|
||||
|
||||
@@ -28,6 +28,12 @@ exclude = [
|
||||
# Шаблонные плейсхолдеры
|
||||
"^\\{\\{.*\\}\\}$",
|
||||
"^\\[.*\\]$",
|
||||
# v3.9 hooks удалены Stream G (2026-05-30), CLAUDE.md содержит исторические упоминания
|
||||
"tools/enforce-chain-recommendation\\.mjs",
|
||||
"tools/enforce-classifier-match\\.mjs",
|
||||
"tools/enforce-graph-first\\.mjs",
|
||||
"tools/enforce-semgrep-security\\.mjs",
|
||||
"tools/enforce-override-limit\\.mjs",
|
||||
# localhost и приватные адреса
|
||||
"^https?://localhost",
|
||||
"^https?://127\\.0\\.0\\.1",
|
||||
|
||||
@@ -54,32 +54,7 @@
|
||||
},
|
||||
"comment": "A3 integration-tooling #47 — OpenAPI MCP (ivo-toby/mcp-openapi-server, @ivotoby/openapi-mcp-server v1.14.0, MIT). Exposes Лидерра REST API endpoints (docs/api/openapi.yaml) as MCP tools. Config via env-vars API_BASE_URL + OPENAPI_SPEC_PATH (stdio transport default). READ scope: API discovery/introspection for Claude Code. Формализован в Tooling §4.22, PSR_v1 R10.1 блок 3, Pravila §13.2."
|
||||
},
|
||||
"marketing-metrika": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "github:atomkraft/yandex-metrika-mcp"],
|
||||
"env": {
|
||||
"YANDEX_OAUTH_TOKEN": "${YANDEX_OAUTH_TOKEN}"
|
||||
},
|
||||
"comment": "C1 marketing-tooling #78 — Yandex Metrika MCP (vetted source: github:atomkraft/yandex-metrika-mcp, MIT — выбран по IS9-вету из 3 кандидатов, см. docs/security/marketing-vet.md). READ-ONLY аналитика: посещаемость, источники трафика, конверсии. Env: YANDEX_OAUTH_TOKEN — OAuth-токен с правами read-only. Постура IS9: READ-ONLY, мутации API Метрики не задействуются. Tooling §4.53. docs/marketing/README.md."
|
||||
},
|
||||
"marketing-wordstat": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "github:SvechaPVL/yandex-mcp"],
|
||||
"env": {
|
||||
"YANDEX_OAUTH_TOKEN": "${YANDEX_OAUTH_TOKEN}"
|
||||
},
|
||||
"comment": "C1 marketing-tooling #79 — Yandex Direct+Wordstat MCP (vetted source: github:SvechaPVL/yandex-mcp, MIT — выбран по IS9-вету, см. docs/security/marketing-vet.md). Репозиторий отдаёт 128 tools (Direct + Wordstat + Метрика); по IS9-условию используются ТОЛЬКО Wordstat-инструменты для подбора ключевых слов и оценки спроса — Direct-мутации (создание/правка кампаний, изменение ставок) поведенчески запрещены через marketing-ru #77 и MKT8 (никаких автоматических трат рекламного бюджета). Env: YANDEX_OAUTH_TOKEN с минимальным scope. Tooling §4.54. docs/marketing/README.md."
|
||||
},
|
||||
"marketing-telegram": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "github:chigwell/telegram-mcp"],
|
||||
"env": {
|
||||
"TELEGRAM_API_ID": "${TELEGRAM_API_ID}",
|
||||
"TELEGRAM_API_HASH": "${TELEGRAM_API_HASH}",
|
||||
"TELEGRAM_SESSION_STRING": "${TELEGRAM_SESSION_STRING}"
|
||||
},
|
||||
"comment": "C1 marketing-tooling #80 — Telegram MCP (chigwell/telegram-mcp, Apache-2.0, GitHub-only — не npm). Работа с Telegram-каналами и чатами Лидерры: публикация, планирование, аналитика. Env: TELEGRAM_API_ID + TELEGRAM_API_HASH (получить на https://my.telegram.org/apps) + TELEGRAM_SESSION_STRING (генерируется один раз через GramJS/Telethon, хранить в .env.local gitignored). ОБЯЗАТЕЛЬНО: выделенный Telegram-аккаунт для Лидерры, не личный (IS9-постура MKT8). Tooling §4.51. docs/marketing/README.md."
|
||||
},
|
||||
"_disabled_marketing_servers_note": "ОТКЛЮЧЕНЫ 2026-05-31 (владелец: «отрежь маркетинг»). Причина: их авто-генерируемые схемы (особенно wordstat — 128 tools из Яндекс.Директа) — главный подозреваемый в API 400 tools.110/113, ронявшем субагентов при bulk-load всех инструментов (subagent-driven-development). Серверы off-phase и без OAuth-токенов всё равно не стартовали. Полный конфиг — в git до этого коммита. Чтобы вернуть, восстановить три блока mcpServers: marketing-metrika (npx -y github:atomkraft/yandex-metrika-mcp; env YANDEX_OAUTH_TOKEN; READ-ONLY; Tooling §4.53), marketing-wordstat (npx -y github:SvechaPVL/yandex-mcp; env YANDEX_OAUTH_TOKEN; ТОЛЬКО Wordstat per IS9/MKT8; Tooling §4.54), marketing-telegram (npx -y github:chigwell/telegram-mcp; env TELEGRAM_API_ID/API_HASH/SESSION_STRING; выделенный аккаунт IS9; Tooling §4.51). См. docs/security/marketing-vet.md и docs/marketing/README.md.",
|
||||
"_comment_postiz_skeleton": "TODO: C1 marketing-tooling #81 — Postiz MCP (gitroomhq/postiz-app self-host + antoniolg/postiz-mcp). Активировать ПОСЛЕ: 1) развернуть Postiz self-hosted (git clone https://github.com/gitroomhq/postiz-app + docker-compose, AGPL-3.0: internal-only, no modifications); 2) провести vet лицензии antoniolg/postiz-mcp (NOT YET VERIFIED — см. docs/marketing/README.md Open vet notes); 3) подключить соцсети в Postiz UI. Будущий entry: \"marketing-postiz\": { \"command\": \"npx\", \"args\": [\"-y\", \"postiz-mcp\"], \"env\": { \"POSTIZ_API_URL\": \"${POSTIZ_API_URL}\", \"POSTIZ_API_KEY\": \"${POSTIZ_API_KEY}\" }, \"comment\": \"C1 #81 post-activation\" }. Tooling §4.52. docs/marketing/README.md."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Audit\AuditChainConfig;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Пересчитывает hash-цепь в указанной партиции аудит-таблицы начиная с заданного id.
|
||||
*
|
||||
* ADR-018: воспроизводит per-tenant scope триггера audit_chain_hash() (через RLS).
|
||||
* Для tenant-таблиц (activity_log/balance_transactions/tenant_operations_log/
|
||||
* pd_processing_log) — отдельная цепочка на каждый tenant. Для BYPASSRLS-таблиц
|
||||
* (auth_log/saas_admin_audit_log) — единая цепочка в пределах партиции.
|
||||
*
|
||||
* Алгоритм (Вариант B — PHP-iteration с partition awareness):
|
||||
* 1. SET session_replication_role = replica отключает BEFORE-триггеры.
|
||||
* 2. Determine partition_clause из AuditChainConfig::TABLES[parent_table].
|
||||
* 3. Для per-tenant таблиц: получить distinct tenant_ids в range, для каждого:
|
||||
* - prev_hash = log_hash of last row with id<from-id AND tenant_id=X
|
||||
* - iterate rows ordered by id, UPDATE + propagate prev_hash forward
|
||||
* Для BYPASSRLS-таблиц: одна iteration без tenant scope.
|
||||
* 4. Возвращаем session_replication_role = origin.
|
||||
*
|
||||
* NB: row-by-row PHP loop сохранён намеренно (вариант с одиночным CTE и
|
||||
* LAG страдает snapshot-isolation bug — downstream rows используют OLD stored
|
||||
* prev_hash вместо новых хешей текущего UPDATE'а; chain ломается через >1 row).
|
||||
*
|
||||
* Ref: docs/adr/ADR-018-audit-chain-per-tenant-semantics.md
|
||||
* docs/superpowers/plans/2026-05-29-audit-rebuild-per-tenant-fix.md
|
||||
*/
|
||||
final class AuditRebuildChain extends Command
|
||||
{
|
||||
protected $signature = 'audit:rebuild-chain
|
||||
{--partition= : Имя партиции, например activity_log_y2026_m05}
|
||||
{--from-id= : ID с которого начать пересчёт (включительно)}
|
||||
{--dry-run : Показать сколько строк затронет, без UPDATE}
|
||||
{--force : Пропустить интерактивное подтверждение (для CI/тестов)}';
|
||||
|
||||
protected $description = 'Пересчитать hash-цепь партиции аудит-таблицы (per-tenant per ADR-018)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$partition = (string) $this->option('partition');
|
||||
$fromId = (int) $this->option('from-id');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
if ($partition === '' || $fromId <= 0) {
|
||||
$this->error('--partition и --from-id обязательны');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$parentTable = (string) preg_replace('/_y\d{4}_m\d{2}$/', '', $partition);
|
||||
|
||||
if (! array_key_exists($parentTable, AuditChainConfig::TABLES)) {
|
||||
$this->error("Partition '{$partition}' не относится к поддерживаемым аудит-таблицам.");
|
||||
$this->line('Поддерживаемые: '.implode(', ', array_keys(AuditChainConfig::TABLES)));
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$partitionClause = AuditChainConfig::TABLES[$parentTable]['partition'];
|
||||
$rowExpr = AuditChainConfig::rowExpression($parentTable);
|
||||
|
||||
$count = DB::connection('pgsql_supplier')
|
||||
->table($partition)
|
||||
->where('id', '>=', $fromId)
|
||||
->count();
|
||||
|
||||
$scopeLabel = $partitionClause !== '' ? $partitionClause : 'global (within partition)';
|
||||
|
||||
$this->info("Партиция : {$partition}");
|
||||
$this->info("Родитель : {$parentTable}");
|
||||
$this->info("Scope : {$scopeLabel}");
|
||||
$this->info("От id : {$fromId}");
|
||||
$this->info("Строк : {$count}");
|
||||
|
||||
if ($count === 0) {
|
||||
$this->warn('Нет строк с id >= '.$fromId.'. Пересчёт не нужен.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('--dry-run: UPDATE не выполнен.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $force && ! $this->confirm(
|
||||
"Пересчитать log_hash для {$count} строк в {$partition} (scope: {$scopeLabel})? Это изменит данные в проде.",
|
||||
false,
|
||||
)) {
|
||||
$this->warn('Отменено.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Disable BEFORE triggers (audit_block_mutation blocks UPDATE).
|
||||
// Use session-level SET so it works even inside a wrapping transaction
|
||||
// (e.g. DatabaseTransactions in tests). Reset in finally.
|
||||
DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'replica'");
|
||||
|
||||
try {
|
||||
$totalUpdated = 0;
|
||||
|
||||
if ($partitionClause === 'PARTITION BY tenant_id') {
|
||||
// Per-tenant rebuild — separate scope iteration per tenant.
|
||||
$tenantIds = DB::connection('pgsql_supplier')
|
||||
->table($partition)
|
||||
->where('id', '>=', $fromId)
|
||||
->distinct()
|
||||
->pluck('tenant_id')
|
||||
->all();
|
||||
|
||||
foreach ($tenantIds as $tenantId) {
|
||||
$totalUpdated += $this->rebuildScope(
|
||||
$partition,
|
||||
$rowExpr,
|
||||
$fromId,
|
||||
'tenant_id',
|
||||
(int) $tenantId,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// BYPASSRLS-таблицы (auth_log, saas_admin_audit_log) — global scope.
|
||||
$totalUpdated = $this->rebuildScope($partition, $rowExpr, $fromId, null, null);
|
||||
}
|
||||
|
||||
$this->info("Обновлено {$totalUpdated} строк в {$partition}.");
|
||||
} finally {
|
||||
DB::connection('pgsql_supplier')->statement("SET session_replication_role = 'origin'");
|
||||
}
|
||||
|
||||
$this->info('Готово. Запустите audit:verify-chains для проверки целостности.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Пересчитывает chain для одного scope (tenant или global).
|
||||
*
|
||||
* Iterative PHP loop: prev_hash propagate'ится forward через каждый row,
|
||||
* UPDATE применяется immediately чтобы snapshot для следующей iteration
|
||||
* был свежий (default PG READ COMMITTED — own writes visible immediately).
|
||||
*
|
||||
* @param string|null $tenantColumn 'tenant_id' для per-tenant scope, null для global
|
||||
* @param int|null $tenantValue значение tenant_id для этого scope (если применимо)
|
||||
*/
|
||||
private function rebuildScope(
|
||||
string $partition,
|
||||
string $rowExpr,
|
||||
int $fromId,
|
||||
?string $tenantColumn,
|
||||
?int $tenantValue,
|
||||
): int {
|
||||
// Find prev_hash (last row before fromId within scope).
|
||||
$prevQuery = DB::connection('pgsql_supplier')
|
||||
->table($partition)
|
||||
->where('id', '<', $fromId);
|
||||
if ($tenantColumn !== null) {
|
||||
$prevQuery->where($tenantColumn, $tenantValue);
|
||||
}
|
||||
$prevHashRow = $prevQuery->orderByDesc('id')->first(['log_hash']);
|
||||
$prevHashHex = $this->bytesToHex($prevHashRow?->log_hash);
|
||||
|
||||
// Get rows to rebuild ordered by id.
|
||||
$rowsQuery = DB::connection('pgsql_supplier')
|
||||
->table($partition)
|
||||
->where('id', '>=', $fromId);
|
||||
if ($tenantColumn !== null) {
|
||||
$rowsQuery->where($tenantColumn, $tenantValue);
|
||||
}
|
||||
$rows = $rowsQuery->orderBy('id')->get(['id']);
|
||||
|
||||
$updated = 0;
|
||||
foreach ($rows as $row) {
|
||||
$prevHashExpr = $prevHashHex !== null
|
||||
? "'{$prevHashHex}'::bytea"
|
||||
: "''::bytea";
|
||||
|
||||
$sql = "
|
||||
UPDATE {$partition}
|
||||
SET log_hash = (
|
||||
SELECT digest(
|
||||
COALESCE({$prevHashExpr}, ''::bytea)
|
||||
|| (SELECT {$rowExpr}::text::bytea FROM {$partition} t WHERE t.id = ?)
|
||||
, 'sha256'
|
||||
)
|
||||
)
|
||||
WHERE id = ?
|
||||
RETURNING log_hash
|
||||
";
|
||||
|
||||
$result = DB::connection('pgsql_supplier')->selectOne($sql, [$row->id, $row->id]);
|
||||
$updated++;
|
||||
|
||||
$prevHashHex = $this->bytesToHex($result?->log_hash);
|
||||
}
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a BYTEA value (PHP resource or string) to hex literal for SQL.
|
||||
* PostgreSQL PDO driver returns BYTEA as a PHP stream resource.
|
||||
*/
|
||||
private function bytesToHex(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$bin = is_resource($value) ? stream_get_contents($value) : (string) $value;
|
||||
if ($bin === '' || $bin === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return '\\x'.bin2hex($bin);
|
||||
}
|
||||
}
|
||||
@@ -27,12 +27,13 @@ class IncidentsWatchFailures extends Command
|
||||
private const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
protected $signature = 'incidents:watch-failures
|
||||
{--window=10 : Окно сканирования в минутах}
|
||||
{--threshold=200 : Порог спайка для failed_webhook_jobs}
|
||||
{--threshold-spike=10 : Порог спайка для failed_jobs (за окно)}
|
||||
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
|
||||
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
|
||||
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}';
|
||||
{--window=10 : Окно сканирования в минутах}
|
||||
{--threshold=200 : Порог спайка для failed_webhook_jobs}
|
||||
{--threshold-spike=10 : Порог спайка для failed_jobs (за окно)}
|
||||
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
|
||||
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
|
||||
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}
|
||||
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}';
|
||||
|
||||
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
|
||||
|
||||
@@ -45,6 +46,8 @@ class IncidentsWatchFailures extends Command
|
||||
$persistentHours = (int) $this->option('persistent-hours');
|
||||
$dedupMinutes = (int) $this->option('dedup-window');
|
||||
|
||||
$thresholdSingleLead = (int) $this->option('threshold-single-lead');
|
||||
|
||||
$since = Carbon::now()->subMinutes($windowMinutes);
|
||||
$since24h = Carbon::now()->subHours(24);
|
||||
$dedupAt = Carbon::now()->subMinutes($dedupMinutes);
|
||||
@@ -185,6 +188,39 @@ class IncidentsWatchFailures extends Command
|
||||
$this->info("Job persistent [medium]: {$jobClass}");
|
||||
}
|
||||
|
||||
// ===== БЛОК 5: single-lead storm detection =====
|
||||
// Detects случай когда один supplier_lead_id генерирует >= threshold
|
||||
// failures за окно — классический шторм от застрявшего лида (Finding 2,
|
||||
// 2026-05-29). Создаём severity=high инцидент per lead_id.
|
||||
if ($thresholdSingleLead > 0) {
|
||||
$stormLeads = DB::connection(self::DB_CONNECTION)
|
||||
->table('failed_webhook_jobs')
|
||||
->selectRaw("raw_payload->>'supplier_lead_id' AS lead_id, COUNT(*) AS cnt")
|
||||
->whereNull('resolved_at')
|
||||
->where('failed_at', '>=', $since)
|
||||
->whereRaw("raw_payload ?? 'supplier_lead_id'")
|
||||
->groupByRaw("raw_payload->>'supplier_lead_id'")
|
||||
->havingRaw('COUNT(*) >= ?', [$thresholdSingleLead])
|
||||
->get();
|
||||
|
||||
foreach ($stormLeads as $row) {
|
||||
$leadId = $row->lead_id;
|
||||
$cnt = (int) $row->cnt;
|
||||
$dedupKey = "single-lead-storm:{$leadId}";
|
||||
|
||||
if ($this->isDup($dedupKey, $dedupAt)) {
|
||||
$this->line("Skipping single-lead-storm (dedup): {$dedupKey}");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$summary = "Автоматически: single-lead-storm {$cnt} failures supplier_lead_id={$leadId} за {$windowMinutes} мин. Вероятная причина: terminal error без fast-fail guard.";
|
||||
$this->createIncident($adminId, 'other', 'high', $summary, $since, $now, $dedupKey);
|
||||
$created++;
|
||||
$this->info("Single-lead storm [high]: lead_id={$leadId} — {$cnt}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Done. Created {$created} incident(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Создаёт project_routing_snapshots за указанную дату из текущего live-состояния.
|
||||
* Используется один раз при выкатке Этапа 2 + для ручного recovery после падения cron'а.
|
||||
*
|
||||
* Spec §4.2.6.
|
||||
*/
|
||||
final class SnapshotBackfillCommand extends Command
|
||||
{
|
||||
protected $signature = 'snapshot:backfill {--date= : YYYY-MM-DD, по умолчанию сегодня}';
|
||||
|
||||
protected $description = 'Заполнить project_routing_snapshots за указанную дату из live projects';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dateStr = (string) ($this->option('date') ?? Carbon::today('Europe/Moscow')->toDateString());
|
||||
$date = Carbon::parse($dateStr, 'Europe/Moscow');
|
||||
$weekdayBit = 1 << ($date->isoWeekday() - 1);
|
||||
|
||||
$count = DB::connection('pgsql_supplier')->transaction(function () use ($dateStr, $weekdayBit) {
|
||||
return DB::connection('pgsql_supplier')->insert(<<<SQL
|
||||
INSERT INTO project_routing_snapshots (
|
||||
snapshot_date, project_id, tenant_id,
|
||||
daily_limit, delivery_days_mask, regions,
|
||||
signal_type, signal_identifier, sms_senders, sms_keyword,
|
||||
expected_volume
|
||||
)
|
||||
SELECT
|
||||
?::date,
|
||||
p.id, p.tenant_id,
|
||||
COALESCE(p.effective_daily_limit_today, p.daily_limit_target),
|
||||
p.delivery_days_mask, p.regions,
|
||||
p.signal_type, p.signal_identifier, p.sms_senders, p.sms_keyword,
|
||||
COALESCE(p.effective_daily_limit_today, p.daily_limit_target)
|
||||
FROM projects p
|
||||
INNER JOIN tenants t ON t.id = p.tenant_id
|
||||
WHERE p.is_active = true
|
||||
AND (p.delivery_days_mask & ?::int) <> 0
|
||||
AND p.preflight_blocked_at IS NULL
|
||||
AND t.frozen_by_balance_at IS NULL
|
||||
AND t.deleted_at IS NULL
|
||||
ON CONFLICT (snapshot_date, project_id) DO NOTHING
|
||||
SQL, [$dateStr, $weekdayBit]);
|
||||
});
|
||||
|
||||
$this->info("Snapshot backfilled for {$dateStr}: {$count} rows.");
|
||||
Log::info('snapshot.backfill', ['date' => $dateStr, 'rows' => $count]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Перестраивает project_routing_snapshots за указанную дату из текущего
|
||||
* live-состояния, ПЕРЕЗАПИСЫВАЯ существующий snapshot.
|
||||
*
|
||||
* В отличие от `snapshot:backfill` (идемпотентный — ON CONFLICT DO NOTHING),
|
||||
* `snapshot:rebuild` всегда сначала DELETE'ит существующий snapshot за дату,
|
||||
* затем создаёт новый. Используется для manual recovery после падения
|
||||
* `SnapshotProjectRoutingJob` cron'а с уже частично записанным snapshot'ом
|
||||
* (см. Task 2.10, Spec §4.2.6 fail-loud strategy).
|
||||
*
|
||||
* Fail-loud strategy:
|
||||
* 1. Heartbeat alarm via SchedulerHeartbeatTracker (Task 2.4).
|
||||
* 2. LeadRouter Log::error on missing snapshot (Task 2.5).
|
||||
* 3. Manual recovery: `php artisan snapshot:rebuild --date=YYYY-MM-DD`.
|
||||
*
|
||||
* NO fallback to live projects — explicit downtime + alert is safer
|
||||
* than silent regression.
|
||||
*/
|
||||
final class SnapshotRebuildCommand extends Command
|
||||
{
|
||||
protected $signature = 'snapshot:rebuild {--date= : YYYY-MM-DD, по умолчанию сегодня}';
|
||||
|
||||
protected $description = 'Перестроить project_routing_snapshots за указанную дату (DELETE+INSERT, для recovery)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dateStr = (string) ($this->option('date') ?? Carbon::today('Europe/Moscow')->toDateString());
|
||||
$date = Carbon::parse($dateStr, 'Europe/Moscow');
|
||||
$weekdayBit = 1 << ($date->isoWeekday() - 1);
|
||||
|
||||
// NB: НЕ оборачиваем в ->transaction() — это recovery-команда, half-done state
|
||||
// допустим (retry восстанавливает; на проде admin контроль). Wrapper конфликтует
|
||||
// с tests SharesSupplierPdo (shared PDO + nested transaction levels).
|
||||
$deleted = DB::connection('pgsql_supplier')
|
||||
->table('project_routing_snapshots')
|
||||
->where('snapshot_date', $dateStr)
|
||||
->delete();
|
||||
|
||||
$inserted = DB::connection('pgsql_supplier')->insert(<<<SQL
|
||||
INSERT INTO project_routing_snapshots (
|
||||
snapshot_date, project_id, tenant_id,
|
||||
daily_limit, delivery_days_mask, regions,
|
||||
signal_type, signal_identifier, sms_senders, sms_keyword,
|
||||
expected_volume
|
||||
)
|
||||
SELECT
|
||||
?::date,
|
||||
p.id, p.tenant_id,
|
||||
COALESCE(p.effective_daily_limit_today, p.daily_limit_target),
|
||||
p.delivery_days_mask, p.regions,
|
||||
p.signal_type, p.signal_identifier, p.sms_senders, p.sms_keyword,
|
||||
COALESCE(p.effective_daily_limit_today, p.daily_limit_target)
|
||||
FROM projects p
|
||||
INNER JOIN tenants t ON t.id = p.tenant_id
|
||||
WHERE p.is_active = true
|
||||
AND (p.delivery_days_mask & ?::int) <> 0
|
||||
AND p.preflight_blocked_at IS NULL
|
||||
AND t.frozen_by_balance_at IS NULL
|
||||
AND t.deleted_at IS NULL
|
||||
SQL, [$dateStr, $weekdayBit]);
|
||||
|
||||
$this->info("Snapshot rebuilt for {$dateStr}: deleted={$deleted}, inserted={$inserted}.");
|
||||
Log::warning('snapshot.rebuild', [
|
||||
'date' => $dateStr,
|
||||
'deleted' => $deleted,
|
||||
'inserted' => $inserted,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Supplier\DeleteSupplierProjectJob;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* One-time migration: clean up orphan supplier_projects rows created by the
|
||||
* now-removed buildUniqueKey($p, $platform) divergence for SMS+keyword projects.
|
||||
*
|
||||
* Before R-17 unification (Stage 4 §4.4.1) SMS+keyword projects had two diverging
|
||||
* supplier_projects keys per group:
|
||||
* B2: unique_key = sender+keyword
|
||||
* B3: unique_key = sender (without keyword) — ORPHAN after unification
|
||||
*
|
||||
* This command finds orphan B3 rows (sms, no '+' in unique_key, owning project has
|
||||
* sms_keyword) and either UPDATEs them to sender+keyword (no sibling) or marks them
|
||||
* for deletion via DeleteSupplierProjectJob (sibling at sender+keyword already exists).
|
||||
*
|
||||
* Usage:
|
||||
* php artisan supplier:rekey-orphans --dry-run # preview
|
||||
* php artisan supplier:rekey-orphans # apply
|
||||
*
|
||||
* Spec §4.4.1.
|
||||
*/
|
||||
final class SupplierRekeyOrphansCommand extends Command
|
||||
{
|
||||
protected $signature = 'supplier:rekey-orphans {--dry-run : Preview without modifying anything}';
|
||||
|
||||
protected $description = 'One-time R-17 cleanup of orphan SMS supplier_projects keyed under sender alone';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
// Find candidate orphans: sms supplier_projects whose unique_key has no '+'
|
||||
// and whose tenant has an SMS project with sms_keyword set matching this sender.
|
||||
$orphans = DB::connection('pgsql_supplier')
|
||||
->table('supplier_projects as sp')
|
||||
->join('project_supplier_links as psl', 'psl.supplier_project_id', '=', 'sp.id')
|
||||
->join('projects as p', 'p.id', '=', 'psl.project_id')
|
||||
->where('sp.signal_type', 'sms')
|
||||
->where('sp.unique_key', 'NOT LIKE', '%+%')
|
||||
->whereNotNull('p.sms_keyword')
|
||||
->where('p.sms_keyword', '!=', '')
|
||||
->select([
|
||||
'sp.id as sp_id',
|
||||
'sp.unique_key as sender',
|
||||
'sp.platform',
|
||||
'p.tenant_id',
|
||||
'p.sms_keyword as keyword',
|
||||
])
|
||||
->get();
|
||||
|
||||
if ($orphans->isEmpty()) {
|
||||
$this->info('No orphan SMS supplier_projects found. Nothing to migrate.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info(sprintf('Found %d orphan SMS supplier_projects row(s).', $orphans->count()));
|
||||
|
||||
$updated = 0;
|
||||
$dispatched = 0;
|
||||
$toDelete = [];
|
||||
|
||||
foreach ($orphans as $o) {
|
||||
$sender = (string) $o->sender;
|
||||
$keyword = (string) $o->keyword;
|
||||
$newKey = $sender.'+'.$keyword;
|
||||
|
||||
// Sibling check: another supplier_project for same tenant/keyword combo already
|
||||
// exists at the unified key? Look across pivot to the same tenant scope.
|
||||
$siblingExists = DB::connection('pgsql_supplier')
|
||||
->table('supplier_projects as sp2')
|
||||
->join('project_supplier_links as psl2', 'psl2.supplier_project_id', '=', 'sp2.id')
|
||||
->join('projects as p2', 'p2.id', '=', 'psl2.project_id')
|
||||
->where('sp2.signal_type', 'sms')
|
||||
->where('sp2.unique_key', $newKey)
|
||||
->where('p2.tenant_id', $o->tenant_id)
|
||||
->where('sp2.id', '!=', $o->sp_id)
|
||||
->exists();
|
||||
|
||||
if ($siblingExists) {
|
||||
$toDelete[] = (int) $o->sp_id;
|
||||
$this->line(sprintf(
|
||||
' orphan #%d (%s sender=%s) → DELETE (sibling at %s exists for tenant %d)',
|
||||
$o->sp_id, $o->platform, $sender, $newKey, $o->tenant_id
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
' orphan #%d (%s sender=%s) → UPDATE unique_key=%s',
|
||||
$o->sp_id, $o->platform, $sender, $newKey
|
||||
));
|
||||
|
||||
if (! $dryRun) {
|
||||
DB::connection('pgsql_supplier')
|
||||
->table('supplier_projects')
|
||||
->where('id', $o->sp_id)
|
||||
->update(['unique_key' => $newKey, 'updated_at' => now()]);
|
||||
$updated++;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun && $toDelete !== []) {
|
||||
DeleteSupplierProjectJob::dispatch($toDelete);
|
||||
$dispatched = count($toDelete);
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->warn('--dry-run: no changes made.');
|
||||
} else {
|
||||
$this->info(sprintf(
|
||||
'Migration complete: %d row(s) updated, %d row(s) queued for deletion.',
|
||||
$updated, $dispatched
|
||||
));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Mail\AuditChainBreachMail;
|
||||
use App\Services\Audit\AuditChainConfig;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -83,166 +84,12 @@ class VerifyAuditChains extends Command
|
||||
|
||||
protected $description = 'Проверяет целостность SHA-256 hash-chain в 6 audit-таблицах (per-partition)';
|
||||
|
||||
/**
|
||||
* Конфигурация таблиц: имя таблицы → [columns, partition_clause].
|
||||
*
|
||||
* columns: список столбцов строго в порядке ordinal_position из db/schema.sql.
|
||||
* Специальное значение '__log_hash__' — маркер позиции log_hash → NULL::bytea.
|
||||
*
|
||||
* partition_clause: SQL-фрагмент для OVER (PARTITION BY … ORDER BY id),
|
||||
* воспроизводящий RLS-scope триггера внутри одной партиции.
|
||||
* Пустая строка = глобальная цепочка внутри партиции.
|
||||
*
|
||||
* @var array<string, array{columns: list<string>, partition: string}>
|
||||
*/
|
||||
private const TABLE_CONFIG = [
|
||||
// auth_log:
|
||||
// RLS: actor_type='tenant_user' AND tenant_id = current_setting(...)
|
||||
// Tenant-сессия видит только (actor_type='tenant_user', tenant_id=N).
|
||||
// saas_admin-сессия BYPASSRLS — видит всё.
|
||||
// Partition (actor_type, tenant_id) воспроизводит оба случая:
|
||||
// каждая пара образует независимую цепочку.
|
||||
'auth_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'actor_type',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'saas_admin_user_id',
|
||||
'email',
|
||||
'event',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'failure_reason',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
// global chain: auth_log пишется при ЛОГИНЕ под BYPASSRLS-роль
|
||||
// (tenant ещё не установлен — пользователь не аутентифицирован),
|
||||
// поэтому триггерный prev-SELECT видит ВСЕ строки → цепочка глобальная
|
||||
// внутри данной партиции (эмпирически подтверждено прод-smoke).
|
||||
'partition' => '',
|
||||
],
|
||||
|
||||
// activity_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'activity_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'deal_id',
|
||||
'event',
|
||||
'old_value',
|
||||
'new_value',
|
||||
'context',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// tenant_operations_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'tenant_operations_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'event',
|
||||
'payload_before',
|
||||
'payload_after',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// balance_transactions:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'balance_transactions' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'type',
|
||||
'amount_rub',
|
||||
'amount_leads',
|
||||
'balance_rub_after',
|
||||
'balance_leads_after',
|
||||
'description',
|
||||
'related_type',
|
||||
'related_id',
|
||||
'user_id',
|
||||
'admin_user_id',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// pd_processing_log:
|
||||
// RLS: tenant_id = current_setting(...) — простая tenant-изоляция.
|
||||
// Partition: tenant_id.
|
||||
'pd_processing_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'tenant_id',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'action',
|
||||
'purpose',
|
||||
'actor_tenant_user_id',
|
||||
'actor_admin_user_id',
|
||||
'ip_address',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
|
||||
// saas_admin_audit_log:
|
||||
// Нет RLS-политики для tenant-ролей (REVOKE ALL FROM crm_app_user).
|
||||
// Вставляет только crm_admin_user (BYPASSRLS) — триггер's SELECT
|
||||
// видит ВСЕ строки партиции → цепочка глобальная внутри партиции.
|
||||
// Partition: нет (пустая строка = ORDER BY id без PARTITION BY).
|
||||
'saas_admin_audit_log' => [
|
||||
'columns' => [
|
||||
'id',
|
||||
'admin_user_id',
|
||||
'action',
|
||||
'target_type',
|
||||
'target_id',
|
||||
'target_tenant_id',
|
||||
'payload_before',
|
||||
'payload_after',
|
||||
'reason',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'requires_approval',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'__log_hash__', // log_hash → NULL::bytea
|
||||
'created_at',
|
||||
],
|
||||
'partition' => '', // global chain within partition — inserting role is BYPASSRLS
|
||||
],
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$anyBreach = false;
|
||||
$now = Carbon::now();
|
||||
|
||||
foreach (self::TABLE_CONFIG as $table => $config) {
|
||||
foreach (AuditChainConfig::TABLES as $table => $config) {
|
||||
// Get all partitions for this table via pg_inherits.
|
||||
$partitions = $this->listPartitions($table);
|
||||
|
||||
@@ -252,7 +99,7 @@ class VerifyAuditChains extends Command
|
||||
}
|
||||
|
||||
foreach ($partitions as $partitionName) {
|
||||
$breaches = $this->checkPartition($partitionName, $config['columns'], $config['partition']);
|
||||
$breaches = $this->checkPartition($partitionName, $table, $config['partition']);
|
||||
|
||||
if (empty($breaches)) {
|
||||
$this->line(" ✓ {$partitionName}: chain intact");
|
||||
@@ -321,12 +168,11 @@ class VerifyAuditChains extends Command
|
||||
* где ROW(...) имеет NULL::bytea на позиции log_hash.
|
||||
* 4. Возвращает строки, где stored IS DISTINCT FROM recomputed.
|
||||
*
|
||||
* @param list<string> $columns
|
||||
* @return list<object>
|
||||
*/
|
||||
private function checkPartition(string $partitionName, array $columns, string $partition): array
|
||||
private function checkPartition(string $partitionName, string $table, string $partition): array
|
||||
{
|
||||
$rowExpr = $this->buildRowExpression($columns);
|
||||
$rowExpr = AuditChainConfig::rowExpression($table);
|
||||
|
||||
// Build OVER clause: with or without PARTITION BY depending on table's RLS scope.
|
||||
$overClause = $partition !== ''
|
||||
@@ -366,25 +212,6 @@ class VerifyAuditChains extends Command
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит SQL-выражение ROW(col1, col2, ..., NULL::bytea, ..., coln)
|
||||
* с NULL::bytea на месте log_hash.
|
||||
*
|
||||
* Пример для auth_log:
|
||||
* ROW(t.id, t.actor_type, t.tenant_id, ..., NULL::bytea, t.created_at)
|
||||
*
|
||||
* @param list<string> $columns
|
||||
*/
|
||||
private function buildRowExpression(array $columns): string
|
||||
{
|
||||
$parts = [];
|
||||
foreach ($columns as $col) {
|
||||
$parts[] = ($col === '__log_hash__') ? 'NULL::bytea' : "t.{$col}";
|
||||
}
|
||||
|
||||
return 'ROW('.implode(', ', $parts).')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставляет запись в incidents_log (через pgsql_supplier BYPASSRLS).
|
||||
* Дедупликация: не создаёт повторный инцидент для той же таблицы,
|
||||
|
||||
@@ -35,6 +35,10 @@ class ProjectResource extends JsonResource
|
||||
$request->routeIs('projects.show'),
|
||||
fn () => $this->getSupplierLinks(),
|
||||
),
|
||||
// Task 2.11 (Spec §4.2.5): dynamic attribute, не БД-поле. Установлен
|
||||
// ProjectService::update() для slepok-sensitive правок. UI показывает
|
||||
// «изменения вступят в силу с DD.MM HH:MM МСК».
|
||||
'applies_from' => $this->applies_from?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +71,19 @@ final class BalancePreflightSweepJob implements ShouldQueue
|
||||
|
||||
// Переход active → frozen.
|
||||
if (! $result->passes && ! $isFrozen) {
|
||||
$tenant->frozen_by_balance_at = now();
|
||||
$freezeAt = now();
|
||||
$tenant->frozen_by_balance_at = $freezeAt;
|
||||
$tenant->save();
|
||||
|
||||
// Stage 3 R-13 (spec §4.3.2): помечаем все непаузнутые проекты
|
||||
// тенанта моментом заморозки. Это даёт SupplierSnapshotGuard
|
||||
// зацепку (paused_at свежее grace-периода) — клиент не сможет
|
||||
// удалить/сменить источник пока хвост слепка ещё может прилететь.
|
||||
DB::connection('pgsql_supplier')->table('projects')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereNull('paused_at')
|
||||
->update(['paused_at' => $freezeAt]);
|
||||
|
||||
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
|
||||
Mail::queue(new BalanceFrozenMail($tenant, $result));
|
||||
$this->dispatchSupplierSyncIfOnline($tenant);
|
||||
@@ -82,8 +93,20 @@ final class BalancePreflightSweepJob implements ShouldQueue
|
||||
|
||||
// Переход frozen → active.
|
||||
if ($result->passes && $isFrozen) {
|
||||
// Stage 3 R-13: фиксируем frozen-moment ДО $tenant->save() — нужно
|
||||
// для фильтра отката paused_at. Очищаем только те проекты,
|
||||
// у которых paused_at >= frozen_at_was (== поставленные нами на паузу
|
||||
// в freeze-блоке). Ручные паузы клиента ДО заморозки имеют
|
||||
// paused_at < frozen_at_was и сохраняются.
|
||||
$frozenAtWas = $tenant->frozen_by_balance_at;
|
||||
$tenant->frozen_by_balance_at = null;
|
||||
$tenant->save();
|
||||
|
||||
DB::connection('pgsql_supplier')->table('projects')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('paused_at', '>=', $frozenAtWas)
|
||||
->update(['paused_at' => null]);
|
||||
|
||||
$this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result);
|
||||
Mail::queue(new BalanceUnfrozenMail($tenant, $result));
|
||||
$this->dispatchSupplierSyncIfOnline($tenant);
|
||||
|
||||
@@ -116,6 +116,32 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-fail: лид уже был помечен terminal error и не имеет processed_at.
|
||||
// Закрывает класс failed_webhook_jobs storm (Finding 2, 2026-05-29).
|
||||
// Plan 2026-05-29-supplier-webhook-fast-fail-and-stuck-cleanup.md, Task 2.
|
||||
$isTerminalError = $lead->error !== null && (
|
||||
str_contains($lead->error, 'does not support')
|
||||
|| str_contains($lead->error, 'platform mismatch')
|
||||
|| str_contains($lead->error, 'no matching supplier_project')
|
||||
);
|
||||
if ($isTerminalError) {
|
||||
// Capture original error BEFORE update — $lead->update() mutates
|
||||
// the in-memory model, so $lead->error after update() returns the
|
||||
// suffixed value, breaking debug logs (review fix).
|
||||
// быстрый коммит
|
||||
$originalError = $lead->error;
|
||||
$lead->update([
|
||||
'processed_at' => now(),
|
||||
'error' => $originalError.' [fast-failed by RouteSupplierLeadJob]',
|
||||
]);
|
||||
Log::info('supplier_lead.fast_failed_terminal_error', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'original_error' => $originalError,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$projectField = (string) ($lead->raw_payload['project'] ?? '');
|
||||
[$platform, $signalType, $identifier] = $this->parseProjectField($projectField);
|
||||
|
||||
@@ -236,7 +262,48 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
->whereKey($project->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
$effectiveLimit = $lockedProject->effective_daily_limit_today ?? $lockedProject->daily_limit_target;
|
||||
|
||||
// R-09 (Task 2.6, spec §4.2.4): recheck is_active под lock'ом.
|
||||
// matchEligibleProjects читает snapshot за активную дату (фиксированный
|
||||
// на 18:00 МСК); клиент мог нажать «пауза» в окне между matchEligible и
|
||||
// этой транзакцией. Snapshot всё ещё говорит "доставлять", но live state
|
||||
// — не доставляем (контракт «paused under lock = stop»).
|
||||
if (! $lockedProject->is_active) {
|
||||
Log::info('supplier_lead.project_paused_under_lock', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'project_id' => $lockedProject->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// R-04 + R-06 (Task 2.6, spec §4.2.4): лимит из snapshot, не live.
|
||||
// Slepok-инвариант — лимит зафиксирован на 18:00 МСК; live daily_limit_target
|
||||
// (или effective_daily_limit_today) мог быть уменьшен после слепка, но это
|
||||
// не должно прерывать поток уже зафиксированного слепка поставщика.
|
||||
$msk = Carbon::now('Europe/Moscow');
|
||||
$activeDate = $msk->hour >= 21
|
||||
? $msk->copy()->addDay()->toDateString()
|
||||
: $msk->toDateString();
|
||||
$snapshot = DB::connection('pgsql_supplier')
|
||||
->table('project_routing_snapshots')
|
||||
->where('snapshot_date', $activeDate)
|
||||
->where('project_id', $lockedProject->id)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
if ($snapshot === null) {
|
||||
Log::info('supplier_lead.no_snapshot_skipped', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'project_id' => $lockedProject->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'active_date' => $activeDate,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
$effectiveLimit = (int) $snapshot->daily_limit;
|
||||
|
||||
if ($lockedProject->delivered_today >= $effectiveLimit) {
|
||||
Log::info('supplier_lead.project_at_limit_skipped', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
@@ -350,6 +417,14 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
$project->increment('delivered_today');
|
||||
$project->increment('delivered_in_month');
|
||||
|
||||
// Task 2.6: атомарный инкремент snapshot.delivered_count
|
||||
// (для CSV business-drift reconcile — Task 2.5 closure cont'd).
|
||||
DB::connection('pgsql_supplier')
|
||||
->table('project_routing_snapshots')
|
||||
->where('snapshot_date', $activeDate)
|
||||
->where('project_id', $project->id)
|
||||
->increment('delivered_count');
|
||||
|
||||
ActivityLog::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Daily 18:02 МСК snapshot — фиксирует состояние всех eligible Лидерра-проектов
|
||||
* на завтрашний день (slepok №NЛ по канону спека §0).
|
||||
* Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.2.
|
||||
*/
|
||||
final class SnapshotProjectRoutingJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, Queueable, InteractsWithQueue, SerializesModels;
|
||||
|
||||
public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$snapshotDate = Carbon::tomorrow('Europe/Moscow')->toDateString();
|
||||
$weekdayBit = 1 << (Carbon::tomorrow('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
// NB: Без внешнего transaction() — атомарность гарантирует INSERT ... ON CONFLICT
|
||||
// на уровне PG. Внешний transaction() ломается при тестах под DatabaseTransactions
|
||||
// + SharesSupplierPdo (общий PDO pgsql/pgsql_supplier → PG ругается «active transaction»).
|
||||
$exists = DB::connection(self::DB_CONNECTION)
|
||||
->table('project_routing_snapshots')
|
||||
->where('snapshot_date', $snapshotDate)
|
||||
->exists();
|
||||
if ($exists) {
|
||||
Log::info('snapshot.already_exists', ['date' => $snapshotDate]);
|
||||
return;
|
||||
}
|
||||
|
||||
$count = DB::connection(self::DB_CONNECTION)->insert(<<<SQL
|
||||
INSERT INTO project_routing_snapshots (
|
||||
snapshot_date, project_id, tenant_id,
|
||||
daily_limit, delivery_days_mask, regions,
|
||||
signal_type, signal_identifier, sms_senders, sms_keyword,
|
||||
expected_volume
|
||||
)
|
||||
SELECT
|
||||
?::date,
|
||||
p.id, p.tenant_id,
|
||||
COALESCE(p.effective_daily_limit_today, p.daily_limit_target),
|
||||
p.delivery_days_mask,
|
||||
p.regions,
|
||||
p.signal_type, p.signal_identifier, p.sms_senders, p.sms_keyword,
|
||||
COALESCE(p.effective_daily_limit_today, p.daily_limit_target)
|
||||
FROM projects p
|
||||
INNER JOIN tenants t ON t.id = p.tenant_id
|
||||
WHERE p.is_active = true
|
||||
AND (p.delivery_days_mask & ?::int) <> 0
|
||||
AND p.preflight_blocked_at IS NULL
|
||||
AND t.frozen_by_balance_at IS NULL
|
||||
AND t.deleted_at IS NULL
|
||||
ON CONFLICT (snapshot_date, project_id) DO NOTHING
|
||||
SQL, [$snapshotDate, $weekdayBit]);
|
||||
|
||||
Log::info('snapshot.created', ['date' => $snapshotDate, 'rows' => $count]);
|
||||
}
|
||||
}
|
||||
@@ -59,19 +59,14 @@ class CleanupInactiveSupplierProjectsJob implements ShouldQueue
|
||||
{
|
||||
$client ??= app(SupplierPortalClient::class);
|
||||
|
||||
// Подзапрос — DISTINCT id'шники supplier_projects, на которые ссылается
|
||||
// хотя бы один Лидерра-project с is_active=true через любой из трёх FK.
|
||||
// Источник истинности активности — `project_supplier_links` pivot (Plan 3+).
|
||||
// Legacy FK `supplier_b{1,2,3}_project_id` оставлены для read-compat,
|
||||
// но не определяют активность.
|
||||
$activeIdsSubquery = <<<'SQL'
|
||||
SELECT DISTINCT id FROM (
|
||||
SELECT supplier_b1_project_id AS id FROM projects
|
||||
WHERE is_active = true AND supplier_b1_project_id IS NOT NULL
|
||||
UNION
|
||||
SELECT supplier_b2_project_id FROM projects
|
||||
WHERE is_active = true AND supplier_b2_project_id IS NOT NULL
|
||||
UNION
|
||||
SELECT supplier_b3_project_id FROM projects
|
||||
WHERE is_active = true AND supplier_b3_project_id IS NOT NULL
|
||||
) AS active_supplier_ids
|
||||
SELECT DISTINCT psl.supplier_project_id AS id
|
||||
FROM project_supplier_links psl
|
||||
INNER JOIN projects p ON p.id = psl.project_id
|
||||
WHERE p.is_active = true
|
||||
SQL;
|
||||
|
||||
// Phase A — re-activate (СНАЧАЛА для safety: до Phase C, чтобы недавно
|
||||
|
||||
@@ -204,6 +204,13 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
->where('id', $logId)
|
||||
->update($update);
|
||||
|
||||
// R-05 / §4.4.4 second pass — business-drift on project_routing_snapshots.
|
||||
// Detects tenants where supplier under-delivered against the slepok plan
|
||||
// (shortfall = (expected - delivered) / expected > 20%). Orthogonal to
|
||||
// webhook-loss drift above — same lead can be missing from CSV AND from
|
||||
// delivered_count (compounding R-05.1 + R-05.2).
|
||||
$this->detectAndAlertBusinessDrift($mailer, $windowStart, $windowEnd);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
// $logId === null — упал сам insertGetId, log-строки нет, обновлять нечего.
|
||||
if ($logId !== null) {
|
||||
@@ -251,4 +258,65 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* R-05 (Stage 4 §4.4.4) — business-drift second pass.
|
||||
*
|
||||
* Поверх существующего webhook-loss drift (R-05.1: «лид прилетел, мы webhook'а не
|
||||
* получили») ищем business-drift (R-05.2: «лид прилетел, мы доставили не тому/никому»):
|
||||
* для каждой пары (snapshot_date, tenant_id) считаем SUM(expected_volume) и
|
||||
* SUM(delivered_count) по `project_routing_snapshots`, при shortfall > 20% шлём
|
||||
* `TenantBusinessDriftAlertMail` админу.
|
||||
*
|
||||
* Окно — то же что у текущего CSV-reconcile run. Один email на тенанта на дату.
|
||||
*/
|
||||
private const BUSINESS_DRIFT_THRESHOLD = 0.20;
|
||||
|
||||
private function detectAndAlertBusinessDrift(
|
||||
Mailer $mailer,
|
||||
\Carbon\CarbonInterface $windowStart,
|
||||
\Carbon\CarbonInterface $windowEnd,
|
||||
): void {
|
||||
$from = $windowStart->toDateString();
|
||||
$to = $windowEnd->toDateString();
|
||||
|
||||
$rows = DB::connection(self::DB_CONNECTION)
|
||||
->table('project_routing_snapshots')
|
||||
->whereBetween('snapshot_date', [$from, $to])
|
||||
->groupBy('snapshot_date', 'tenant_id')
|
||||
->selectRaw('snapshot_date, tenant_id, SUM(expected_volume) AS expected, SUM(delivered_count) AS delivered')
|
||||
->havingRaw('SUM(expected_volume) > 0')
|
||||
->get();
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$expected = (int) $row->expected;
|
||||
$delivered = (int) $row->delivered;
|
||||
if ($expected <= 0) {
|
||||
continue;
|
||||
}
|
||||
$shortfall = ($expected - $delivered) / $expected;
|
||||
if ($shortfall <= self::BUSINESS_DRIFT_THRESHOLD) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mailer->to((string) config('services.supplier.alert_email'))
|
||||
->send(new \App\Mail\TenantBusinessDriftAlertMail(
|
||||
tenantId: (int) $row->tenant_id,
|
||||
snapshotDate: (string) $row->snapshot_date,
|
||||
expected: $expected,
|
||||
delivered: $delivered,
|
||||
shortfallRatio: $shortfall,
|
||||
windowStart: $windowStart,
|
||||
windowEnd: $windowEnd,
|
||||
));
|
||||
|
||||
Log::warning('csv_reconcile.business_drift_alert', [
|
||||
'tenant_id' => (int) $row->tenant_id,
|
||||
'snapshot_date' => (string) $row->snapshot_date,
|
||||
'expected' => $expected,
|
||||
'delivered' => $delivered,
|
||||
'shortfall' => $shortfall,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,18 +192,65 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
*/
|
||||
public function collectEligibleProjects(): Collection
|
||||
{
|
||||
// NB: whereIn-subquery вместо whereHas — whereHas строит relation-query
|
||||
// через default Eloquent connection (pgsql), а наш родительский Project::on
|
||||
// на pgsql_supplier; cross-connection JOIN ломал sync-тесты (8 fails).
|
||||
// FROM 'tenants' внутри subquery наследует connection родителя.
|
||||
return Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->whereIn('tenant_id', function ($q): void {
|
||||
// Task 2.9 (Spec §4.2.4b): читаем проекты ИЗ snapshot за завтра, не live
|
||||
// projects.is_active. Это закрывает race 18:02 (snapshot) → 18:05 (sync) —
|
||||
// клиент мог paus'нуть проект между двумя cron'ами, но мы должны докатить
|
||||
// зафиксированный slepok поставщику (slepok-инвариант).
|
||||
//
|
||||
// Snapshot уже отфильтрован по is_active=true, preflight_blocked_at IS NULL,
|
||||
// tenants.frozen_by_balance_at IS NULL (см. SnapshotProjectRoutingJob /
|
||||
// SnapshotBackfillCommand WHERE). Здесь повторяем frozen-фильтр на случай
|
||||
// если tenant заморожен между 18:02 и 18:05 (rare safety net).
|
||||
//
|
||||
// Переопределяем live поля проекта значениями snapshot'а: daily_limit_target,
|
||||
// delivery_days_mask, regions. Downstream код syncGroup() читает эти поля как
|
||||
// обычно — без изменений в логике группировки/распределения.
|
||||
$tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString();
|
||||
|
||||
// Eloquent JOIN — casts (PostgresIntArray для regions) применяются автоматически.
|
||||
// Raw DB::table возвращал regions как PostgreSQL-string '{1,2,3}' и ломал PostgresIntArray cast.
|
||||
$projects = Project::on(self::DB_CONNECTION)
|
||||
->join('project_routing_snapshots AS snap', 'snap.project_id', '=', 'projects.id')
|
||||
->whereIn('snap.tenant_id', function ($q): void {
|
||||
$q->select('id')->from('tenants')->whereNull('frozen_by_balance_at');
|
||||
})
|
||||
->orderBy('id')
|
||||
->where('snap.snapshot_date', $tomorrow)
|
||||
->select(
|
||||
'projects.*',
|
||||
'snap.daily_limit AS snap_daily_limit',
|
||||
'snap.delivery_days_mask AS snap_delivery_days_mask',
|
||||
'snap.regions AS snap_regions',
|
||||
)
|
||||
->orderBy('projects.id')
|
||||
->get();
|
||||
|
||||
// Override live fields with snapshot values — slepok semantic.
|
||||
// snap_regions приходит как PostgreSQL-array string ('{77,99}') через append
|
||||
// (не Eloquent-cast), парсим вручную.
|
||||
foreach ($projects as $project) {
|
||||
$project->daily_limit_target = (int) $project->getAttribute('snap_daily_limit');
|
||||
$project->delivery_days_mask = (int) $project->getAttribute('snap_delivery_days_mask');
|
||||
$project->regions = $this->parsePostgresIntArray((string) $project->getAttribute('snap_regions'));
|
||||
}
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит PostgreSQL int-array literal `'{1,2,3}'` или `'{}'` в PHP `[1,2,3]` / `[]`.
|
||||
* Используется для snap_regions (через raw select), который не подхватывается
|
||||
* Eloquent PostgresIntArray cast'ом (тот цастит только реальное regions column).
|
||||
*
|
||||
* @return list<int>
|
||||
*/
|
||||
private function parsePostgresIntArray(string $literal): array
|
||||
{
|
||||
$trimmed = trim($literal, "{} \t\n\r\0\x0B");
|
||||
if ($trimmed === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_map('intval', explode(',', $trimmed)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -107,13 +107,16 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKey($project, $platforms[0]);
|
||||
// R-17 (Stage 4 §4.4.1): unified agnostic key (was buildUniqueKey($p, $platform[0])
|
||||
// which diverged for SMS — B3 used sender alone while B2 used sender+keyword;
|
||||
// created orphan supplier_projects rows during sharing rebalance).
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
|
||||
|
||||
// GROUP recompute (multi-client): an online edit of ONE project must recompute the
|
||||
// WHOLE group sharing this identifier — otherwise it overwrites siblings' regions/
|
||||
// limit/days until the nightly batch. Mirrors SyncSupplierProjectsJob::syncGroup so
|
||||
// online and nightly produce identical supplier state.
|
||||
$agnostic = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
|
||||
$agnostic = $identifier;
|
||||
$groupProjects = Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->where('signal_type', (string) $project->signal_type)
|
||||
@@ -125,8 +128,9 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$groupActive = $groupProjects->isNotEmpty();
|
||||
$status = $groupActive ? 'active' : 'paused';
|
||||
|
||||
// eligible tomorrow → order/workdays (mirror nightly's eligibility window).
|
||||
$targetWeekday = Carbon::tomorrow('Europe/Moscow')->isoWeekday();
|
||||
// eligible target_date → order/workdays (mirror nightly's eligibility window).
|
||||
// R-18 (Stage 4 §4.4.2): see ::targetWeekdayForNow().
|
||||
$targetWeekday = self::targetWeekdayForNow();
|
||||
$eligible = $groupProjects->filter(
|
||||
fn (Project $gp) => ((int) $gp->delivery_days_mask & (1 << ($targetWeekday - 1))) !== 0
|
||||
)->values();
|
||||
@@ -384,8 +388,10 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
|
||||
$workdays = $this->workdaysFromMask((int) $project->delivery_days_mask);
|
||||
|
||||
// R-17 (Stage 4 §4.4.1): same agnostic key for all platforms in this batch run
|
||||
// (was per-platform divergence for SMS — created orphan rows).
|
||||
$uniqueKey = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
|
||||
foreach ($platforms as $platform) {
|
||||
$uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform);
|
||||
$column = 'supplier_'.strtolower($platform).'_project_id';
|
||||
|
||||
// Idempotency: local supplier_projects-запись уже есть?
|
||||
@@ -537,4 +543,24 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* R-18 (Stage 4 §4.4.2): ISO target weekday for online supplier sync.
|
||||
*
|
||||
* Slepok cut-off boundary is 21:00 МСК (matches supplier's snapshot fix-point), not midnight.
|
||||
* hour < 21 МСК → target = today + 1 day
|
||||
* hour >= 21 МСК → target = today + 2 days
|
||||
*
|
||||
* Before fix: `Carbon::tomorrow('Europe/Moscow')->isoWeekday()` flipped target at midnight
|
||||
* (Thu 23:59 → Fri; Fri 00:01 → Sat), mis-aligning portal sync with supplier's already-fixed
|
||||
* slepok. The post-21:00 portion of day N belongs to slepok dated N+1 (effective day N+2).
|
||||
*/
|
||||
public static function targetWeekdayForNow(): int
|
||||
{
|
||||
$msk = Carbon::now('Europe/Moscow');
|
||||
|
||||
return $msk->hour >= 21
|
||||
? $msk->copy()->addDays(2)->startOfDay()->isoWeekday()
|
||||
: $msk->copy()->addDay()->startOfDay()->isoWeekday();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Email алерт админу Лидерры о business-shortfall'е тенанта: snapshot ожидал
|
||||
* объём X, фактически доставили Y и (X-Y)/X > порога (20%).
|
||||
*
|
||||
* Отдельно от CsvDriftAlertMail — тот ловит webhook-loss (CSV vs БД),
|
||||
* этот — bizness-drift (snapshot.expected vs delivered).
|
||||
*
|
||||
* Stage 4 §4.4.4 R-05.
|
||||
*/
|
||||
final class TenantBusinessDriftAlertMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $tenantId,
|
||||
public readonly string $snapshotDate,
|
||||
public readonly int $expected,
|
||||
public readonly int $delivered,
|
||||
public readonly float $shortfallRatio,
|
||||
public readonly CarbonInterface $windowStart,
|
||||
public readonly CarbonInterface $windowEnd,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$pct = number_format($this->shortfallRatio * 100, 1, ',', ' ');
|
||||
|
||||
return new Envelope(
|
||||
subject: "Лидерра ↔ Поставщик: business-shortfall tenant #{$this->tenantId} за {$this->snapshotDate} ({$pct}%)",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.tenant_business_drift_alert');
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ use Illuminate\Support\Facades\DB;
|
||||
* @property string $deadline_at
|
||||
* @property string|null $completed_at
|
||||
* @property bool $processing_restricted
|
||||
*
|
||||
* @mixin IdeHelperPdSubjectRequest
|
||||
*/
|
||||
class PdSubjectRequest extends Model
|
||||
{
|
||||
|
||||
@@ -8,12 +8,15 @@ 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
|
||||
*
|
||||
* @mixin IdeHelperSupplierLeadDelivery
|
||||
*/
|
||||
class SupplierLeadDelivery extends Model
|
||||
{
|
||||
|
||||
@@ -25,6 +25,8 @@ use Illuminate\Support\Carbon;
|
||||
* @property int|null $resolved_by_user_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $resolved_at
|
||||
*
|
||||
* @mixin IdeHelperSupplierManualSyncQueue
|
||||
*/
|
||||
class SupplierManualSyncQueue extends Model
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Тенант — клиент SaaS-портала Лидерра.
|
||||
@@ -90,9 +91,67 @@ class Tenant extends Model
|
||||
*/
|
||||
public function requiredLeadsForTomorrow(): int
|
||||
{
|
||||
return (int) $this->projects()
|
||||
->where('is_active', true)
|
||||
->sum('daily_limit_target');
|
||||
// R-19 (Stage 4 §4.4.3): share-aware preflight. For each active project
|
||||
// count the tenant's PROPORTIONAL share of the supplier group order (not
|
||||
// the raw daily_limit_target), since the supplier caps the group at
|
||||
// max(max(limits), ceil(Σ/3)) and splits it across all clients sharing
|
||||
// the same signal_identifier. Legacy projects (signal_type=null —
|
||||
// webhook-only, no supplier sharing) still count their full limit.
|
||||
$projects = $this->projects()->where('is_active', true)->get();
|
||||
if ($projects->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$total = 0;
|
||||
foreach ($projects as $p) {
|
||||
// Webhook-only legacy projects don't participate in supplier sharing.
|
||||
if (! in_array($p->signal_type, ['site', 'call', 'sms'], true)) {
|
||||
$total += (int) $p->daily_limit_target;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$groupLimits = DB::connection('pgsql_supplier')
|
||||
->table('projects')
|
||||
->where('is_active', true)
|
||||
->where('signal_type', $p->signal_type)
|
||||
->where(function ($q) use ($p): void {
|
||||
if (in_array($p->signal_type, ['site', 'call'], true)) {
|
||||
$q->where('signal_identifier', $p->signal_identifier);
|
||||
} else {
|
||||
// sms: agnostic group is (first sender, keyword-or-NULL).
|
||||
$firstSender = (string) ($p->sms_senders[0] ?? '');
|
||||
$q->whereJsonContains('sms_senders', $firstSender);
|
||||
if ($p->sms_keyword !== null && $p->sms_keyword !== '') {
|
||||
$q->where('sms_keyword', $p->sms_keyword);
|
||||
} else {
|
||||
$q->whereNull('sms_keyword');
|
||||
}
|
||||
}
|
||||
})
|
||||
->pluck('daily_limit_target')
|
||||
->all();
|
||||
|
||||
if ($groupLimits === []) {
|
||||
// Edge: project not yet visible from pgsql_supplier view (cross-conn race).
|
||||
// Conservatively count full limit — avoids underestimating preflight.
|
||||
$total += (int) $p->daily_limit_target;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$intLimits = array_map('intval', $groupLimits);
|
||||
$sum = (int) array_sum($intLimits);
|
||||
$max = (int) max($intLimits);
|
||||
$groupOrder = max($max, (int) ceil($sum / 3));
|
||||
|
||||
if ($sum > 0) {
|
||||
$share = (int) ceil($groupOrder * ((int) $p->daily_limit_target / $sum));
|
||||
$total += $share;
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/** @return BelongsTo<TariffPlan, $this> */
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Audit;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Shared config hash-chain for 6 audit tables.
|
||||
*
|
||||
* Single source of truth for writer (db/schema.sql trigger audit_chain_hash()),
|
||||
* verify (App\Console\Commands\VerifyAuditChains) and rebuild
|
||||
* (App\Console\Commands\AuditRebuildChain).
|
||||
*
|
||||
* ADR-018: per-tenant via RLS scope for tenant tables,
|
||||
* global for BYPASSRLS tables.
|
||||
*
|
||||
* columns: list in ordinal_position order from db/schema.sql.
|
||||
* '__log_hash__' -- marker for log_hash position -> NULL::bytea in ROW().
|
||||
*
|
||||
* partition: SQL fragment for OVER (PARTITION BY ... ORDER BY id),
|
||||
* reproducing the RLS-scope of the trigger.
|
||||
* '' = global chain within partition (for BYPASSRLS tables).
|
||||
*/
|
||||
final class AuditChainConfig
|
||||
{
|
||||
/**
|
||||
* @var array<string, array{columns: list<string>, partition: string}>
|
||||
*/
|
||||
public const TABLES = [
|
||||
'auth_log' => [
|
||||
'columns' => [
|
||||
'id', 'actor_type', 'tenant_id', 'user_id', 'saas_admin_user_id',
|
||||
'email', 'event', 'ip_address', 'user_agent', 'failure_reason',
|
||||
'__log_hash__', 'created_at',
|
||||
],
|
||||
'partition' => '',
|
||||
],
|
||||
'activity_log' => [
|
||||
'columns' => [
|
||||
'id', 'tenant_id', 'user_id', 'deal_id', 'event',
|
||||
'old_value', 'new_value', 'context', 'ip_address', 'user_agent',
|
||||
'__log_hash__', 'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
'tenant_operations_log' => [
|
||||
'columns' => [
|
||||
'id', 'tenant_id', 'user_id', 'entity_type', 'entity_id',
|
||||
'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent',
|
||||
'__log_hash__', 'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
'balance_transactions' => [
|
||||
'columns' => [
|
||||
'id', 'tenant_id', 'type', 'amount_rub', 'amount_leads',
|
||||
'balance_rub_after', 'balance_leads_after', 'description',
|
||||
'related_type', 'related_id', 'user_id', 'admin_user_id',
|
||||
'__log_hash__', 'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
'pd_processing_log' => [
|
||||
'columns' => [
|
||||
'id', 'tenant_id', 'subject_type', 'subject_id', 'action',
|
||||
'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address',
|
||||
'__log_hash__', 'created_at',
|
||||
],
|
||||
'partition' => 'PARTITION BY tenant_id',
|
||||
],
|
||||
'saas_admin_audit_log' => [
|
||||
'columns' => [
|
||||
'id', 'admin_user_id', 'action', 'target_type', 'target_id',
|
||||
'target_tenant_id', 'payload_before', 'payload_after', 'reason',
|
||||
'ip_address', 'user_agent', 'requires_approval', 'approved_by', 'approved_at',
|
||||
'__log_hash__', 'created_at',
|
||||
],
|
||||
'partition' => '',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Build ROW(col1, col2, ..., NULL::bytea, ..., coln) with NULL::bytea at log_hash position.
|
||||
*
|
||||
* @throws InvalidArgumentException if table is not registered in TABLES
|
||||
*/
|
||||
public static function rowExpression(string $table): string
|
||||
{
|
||||
if (! isset(self::TABLES[$table])) {
|
||||
throw new InvalidArgumentException(
|
||||
"Table '{$table}' is not registered in AuditChainConfig::TABLES"
|
||||
);
|
||||
}
|
||||
|
||||
$parts = [];
|
||||
foreach (self::TABLES[$table]['columns'] as $col) {
|
||||
$parts[] = ($col === '__log_hash__') ? 'NULL::bytea' : "t.{$col}";
|
||||
}
|
||||
|
||||
return 'ROW('.implode(', ', $parts).')';
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,17 @@ final class LedgerService
|
||||
);
|
||||
$priceKopecks = (int) $tier->price_per_lead_kopecks;
|
||||
|
||||
// R-03 (Stage 3 §4.3.1): frozen tenant must not receive new charges even
|
||||
// if balance_rub > 0. Throwing here triggers the same auto-pause flow as
|
||||
// InsufficientBalance — RouteSupplierLeadJob::handleInsufficientBalance
|
||||
// flips projects.is_active=false and queues ZeroBalancePausedMail rate-limited.
|
||||
if ($lockedTenant->frozen_by_balance_at !== null) {
|
||||
throw new InsufficientBalanceException(
|
||||
priceKopecks: $priceKopecks,
|
||||
balanceRub: (string) $lockedTenant->balance_rub,
|
||||
);
|
||||
}
|
||||
|
||||
// bcmath: balance_rub × 100 ≥ priceKopecks — единственный путь списания.
|
||||
// Billing v2 Spec A: prepaid-лиды убраны, balance_leads НЕ читается и НЕ изменяется.
|
||||
$balanceKopecks = bcmul((string) $lockedTenant->balance_rub, '100', 0);
|
||||
|
||||
+107
-45
@@ -9,13 +9,22 @@ use App\Models\SupplierProject;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
|
||||
*
|
||||
* Eligibility — структурно через pivot project_supplier_links: проект eligible,
|
||||
* если связан с пришедшим supplier_project (= источник × субъект) + активен +
|
||||
* сегодня рабочий день + есть остаток лимита + у тенанта есть баланс.
|
||||
* Eligibility — структурно через snapshot `project_routing_snapshots` за активную
|
||||
* дату слепка (slepok-инвариант): до 21:00 МСК активен snapshot сегодняшней даты,
|
||||
* с 21:00 МСК — завтрашней. Все эффективные параметры маршрутизации
|
||||
* (daily_limit, delivery_days_mask, regions, signal_type/signal_identifier и т.д.)
|
||||
* берутся из snapshot. Из live `projects` — только `delivered_today` (счётчик
|
||||
* остатка лимита, обновляется в течение дня) и из `tenants` — `balance_rub`
|
||||
* (live auto-pause при нулевом балансе).
|
||||
*
|
||||
* Это закрывает R-01..R-04, R-06..R-08, R-15 (spec §1.3) — клиент Лидерры,
|
||||
* который paus'нул проект ПОСЛЕ зафиксированного слепка поставщика, всё равно
|
||||
* получает свои оплаченные лиды по уже зафиксированному slepok'у.
|
||||
*
|
||||
* Регион сопоставляется самим supplier_project (тег = субъект) — phone-prefix
|
||||
* фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
|
||||
@@ -24,7 +33,7 @@ use Illuminate\Support\Facades\DB;
|
||||
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) — в
|
||||
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
|
||||
* Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3.
|
||||
*/
|
||||
class LeadRouter
|
||||
{
|
||||
@@ -44,67 +53,120 @@ class LeadRouter
|
||||
*/
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
|
||||
{
|
||||
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
// Активная дата слепка вычисляется в PHP — детерминирована для всего запроса,
|
||||
// тестируема через Carbon::setTestNow, исключает дрейф между PHP- и DB-часами.
|
||||
$activeDate = $this->activeSnapshotDate();
|
||||
|
||||
// Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
|
||||
// match с Лидерра-проектами, потому что project_supplier_links для DIRECT-row'ов
|
||||
// не создаются (новые DIRECT supplier_projects создаются автоматически при
|
||||
// получении webhook'а без B-префикса; explicit psl-link для них не настраивается).
|
||||
// match с Лидерра-проектами через snapshot (project_supplier_links для
|
||||
// DIRECT-row'ов не создаются — DIRECT supplier_projects создаются автоматически
|
||||
// при получении webhook'а без B-префикса).
|
||||
if ($supplierProject->platform === 'DIRECT') {
|
||||
$directSql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE projects.signal_type = ?
|
||||
AND LOWER(projects.signal_identifier) = LOWER(?)
|
||||
AND projects.is_active = true
|
||||
AND (projects.delivery_days_mask & ?) <> 0
|
||||
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
|
||||
SELECT DISTINCT ON (snap.tenant_id)
|
||||
projects.*,
|
||||
snap.daily_limit AS snapshot_daily_limit
|
||||
FROM project_routing_snapshots snap
|
||||
INNER JOIN projects ON projects.id = snap.project_id
|
||||
WHERE snap.snapshot_date = ?::date
|
||||
AND snap.signal_type = ?
|
||||
AND LOWER(snap.signal_identifier) = LOWER(?)
|
||||
AND projects.delivered_today < snap.daily_limit
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = projects.tenant_id
|
||||
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
|
||||
WHERE tenants.id = snap.tenant_id
|
||||
AND tenants.balance_rub > 0
|
||||
-- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
|
||||
AND tenants.frozen_by_balance_at IS NULL
|
||||
)
|
||||
ORDER BY
|
||||
projects.tenant_id,
|
||||
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
ORDER BY snap.tenant_id,
|
||||
(snap.daily_limit - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
$directRows = DB::connection('pgsql_supplier')->select(
|
||||
$directSql,
|
||||
[$supplierProject->signal_type, $supplierProject->unique_key, $todayBit]
|
||||
[$activeDate, $supplierProject->signal_type, $supplierProject->unique_key]
|
||||
);
|
||||
|
||||
$this->logIfNoSnapshot($directRows, $supplierProject, $activeDate);
|
||||
|
||||
return Project::hydrate($directRows)->values();
|
||||
}
|
||||
|
||||
// Existing B1/B2/B3 path — explicit project_supplier_links pivot.
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM project_supplier_links psl
|
||||
WHERE psl.project_id = projects.id
|
||||
AND psl.supplier_project_id = ?
|
||||
)
|
||||
AND projects.is_active = true
|
||||
AND (projects.delivery_days_mask & ?) <> 0
|
||||
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = projects.tenant_id
|
||||
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
|
||||
)
|
||||
ORDER BY
|
||||
projects.tenant_id,
|
||||
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SELECT DISTINCT ON (snap.tenant_id)
|
||||
projects.*,
|
||||
snap.daily_limit AS snapshot_daily_limit
|
||||
FROM project_routing_snapshots snap
|
||||
INNER JOIN projects ON projects.id = snap.project_id
|
||||
WHERE snap.snapshot_date = ?::date
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM project_supplier_links psl
|
||||
WHERE psl.project_id = snap.project_id
|
||||
AND psl.supplier_project_id = ?
|
||||
)
|
||||
AND projects.delivered_today < snap.daily_limit
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = snap.tenant_id
|
||||
AND tenants.balance_rub > 0
|
||||
-- R-03: frozen tenant must not receive new leads (Stage 3 §4.3.1)
|
||||
AND tenants.frozen_by_balance_at IS NULL
|
||||
)
|
||||
ORDER BY snap.tenant_id,
|
||||
(snap.daily_limit - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
$rows = DB::connection('pgsql_supplier')->select($sql, [$activeDate, $supplierProject->id]);
|
||||
|
||||
$rows = DB::connection('pgsql_supplier')->select($sql, [$supplierProject->id, $todayBit]);
|
||||
$this->logIfNoSnapshot($rows, $supplierProject, $activeDate);
|
||||
|
||||
return Project::hydrate($rows)->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Активная дата слепка по правилу slepok-инварианта:
|
||||
* до 21:00 МСК — сегодняшняя дата;
|
||||
* с 21:00 МСК — завтрашняя.
|
||||
*
|
||||
* Spec §4.2.3.
|
||||
*/
|
||||
private function activeSnapshotDate(): string
|
||||
{
|
||||
$msk = Carbon::now('Europe/Moscow');
|
||||
|
||||
return $msk->hour >= 21
|
||||
? $msk->copy()->addDay()->toDateString()
|
||||
: $msk->toDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-loud: пишет в лог если по активной дате слепка вообще нет ни одной строки
|
||||
* snapshot'а — это значит, что cron `SnapshotProjectRoutingJob` не отработал.
|
||||
* (Если строки есть, но ни одна не сматчилась — это валидный 0-результат, не алерт.)
|
||||
*
|
||||
* @param array<int, object> $rows
|
||||
*/
|
||||
private function logIfNoSnapshot(array $rows, SupplierProject $supplierProject, string $activeDate): void
|
||||
{
|
||||
if ($rows !== []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$snapshotEmpty = DB::connection('pgsql_supplier')
|
||||
->table('project_routing_snapshots')
|
||||
->where('snapshot_date', $activeDate)
|
||||
->doesntExist();
|
||||
|
||||
if ($snapshotEmpty) {
|
||||
Log::error('lead_router.no_snapshot_for_active_date', [
|
||||
'active_date' => $activeDate,
|
||||
'supplier_project_id' => $supplierProject->id,
|
||||
'platform' => $supplierProject->platform,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ class MonthlyPartitionManager
|
||||
'balance_transactions' => 'created_at',
|
||||
'pd_processing_log' => 'created_at',
|
||||
'saas_admin_audit_log' => 'created_at',
|
||||
// Slepok routing (Этап 2, 27.05.2026)
|
||||
'project_routing_snapshots' => 'snapshot_date',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -106,7 +106,26 @@ class ProjectService
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
}
|
||||
|
||||
return $project->fresh();
|
||||
// Task 2.8 (Spec §4.2.5): для каждого изменённого slepok-sensitive поля
|
||||
// вычислить applies_from — момент, с которого правка реально вступит в силу
|
||||
// (slepok-инвариант: до 18:00 МСК → сегодня 21:00 МСК, после → завтра 21:00 МСК).
|
||||
// Берём максимум среди затронутых полей. NULL = применяется немедленно.
|
||||
$appliesFrom = null;
|
||||
foreach (SupplierSnapshotGuard::SLEPOK_SENSITIVE_FIELDS as $field) {
|
||||
if (! array_key_exists($field, $data)) {
|
||||
continue;
|
||||
}
|
||||
$candidate = $this->snapshotGuard->appliesFrom($project, $field);
|
||||
if ($candidate !== null && ($appliesFrom === null || $candidate->gt($appliesFrom))) {
|
||||
$appliesFrom = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$fresh = $project->fresh();
|
||||
// Dynamic attribute — не в БД, сериализуется ProjectResource (Task 2.11).
|
||||
$fresh->applies_from = $appliesFrom;
|
||||
|
||||
return $fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,6 +62,59 @@ class SupplierSnapshotGuard
|
||||
return $effectiveNow->lt($graceUntil);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slepok-sensitive поля проекта — изменения этих полей попадают в slepok №NЛ
|
||||
* (фиксируется в 18:00 МСК) и начинают действовать с N.21:00 МСК.
|
||||
*
|
||||
* Spec §4.2.5 — Task 2.7.
|
||||
*/
|
||||
public const SLEPOK_SENSITIVE_FIELDS = [
|
||||
'is_active',
|
||||
'daily_limit_target',
|
||||
'delivery_days_mask',
|
||||
'regions',
|
||||
'signal_identifier',
|
||||
'sms_senders',
|
||||
'sms_keyword',
|
||||
];
|
||||
|
||||
/**
|
||||
* Возвращает момент, с которого правка `$field` вступит в силу:
|
||||
* правка до 18:00 МСК → сегодня в 21:00 МСК;
|
||||
* правка с 18:00 МСК и позже → завтра в 21:00 МСК.
|
||||
*
|
||||
* Возвращает null когда правка применяется немедленно:
|
||||
* поле не slepok-sensitive (см. SLEPOK_SENSITIVE_FIELDS), либо
|
||||
* проект не связан с поставщиком (нет project_supplier_links → нет slepok-риска).
|
||||
*
|
||||
* Используется ProjectService (Task 2.8) для прикрепления к UI-ответу
|
||||
* метки «изменения вступят в силу с DD.MM HH:MM».
|
||||
*
|
||||
* Spec §4.2.5.
|
||||
*/
|
||||
public function appliesFrom(Project $project, string $field): ?CarbonImmutable
|
||||
{
|
||||
if (! in_array($field, self::SLEPOK_SENSITIVE_FIELDS, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$hasLinks = DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->exists();
|
||||
if (! $hasLinks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nowMsk = CarbonImmutable::now('Europe/Moscow');
|
||||
$todayCutoff = $nowMsk->setTime(18, 0, 0);
|
||||
|
||||
if ($nowMsk->gte($todayCutoff)) {
|
||||
return $nowMsk->addDay()->setTime(21, 0, 0);
|
||||
}
|
||||
|
||||
return $nowMsk->setTime(21, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param 'delete'|'change_source' $action
|
||||
*/
|
||||
|
||||
@@ -178,9 +178,11 @@ class SupplierProjectImporter
|
||||
]);
|
||||
$createdProjects++;
|
||||
|
||||
// R-17 (Stage 4 §4.4.1): unified agnostic key — was per-platform divergence
|
||||
// for SMS (B3 used sender alone, B2 sender+keyword) creating orphan rows.
|
||||
$uniqueKey = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
|
||||
foreach ($item['platforms'] as $pl) {
|
||||
$platform = (string) $pl['platform'];
|
||||
$uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform);
|
||||
|
||||
/** @var SupplierProject $sp */
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->firstOrCreate(
|
||||
|
||||
@@ -19,37 +19,14 @@ use App\Models\Project;
|
||||
final class SupplierProjectGrouping
|
||||
{
|
||||
/**
|
||||
* Строит unique_key для пары (project, platform):
|
||||
* site/call → signal_identifier (домен / телефон)
|
||||
* sms B2 → sender + '+' + keyword
|
||||
* sms B3 → sender
|
||||
*
|
||||
* Для ночного батч-джоба используйте buildUniqueKeyNoplatform() — он
|
||||
* выбирает B2-ключ автоматически при наличии keyword.
|
||||
*/
|
||||
public static function buildUniqueKey(Project $project, string $platform): string
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return (string) $project->signal_identifier;
|
||||
}
|
||||
|
||||
// sms
|
||||
$sender = (string) ($project->sms_senders[0] ?? '');
|
||||
|
||||
if ($platform === 'B2') {
|
||||
return $sender.'+'.($project->sms_keyword ?? '');
|
||||
}
|
||||
|
||||
// B3
|
||||
return $sender;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique identifier key без привязки к конкретной платформе
|
||||
* (для группировки в ночном батч-джобе):
|
||||
* Unique identifier key — единая агностическая формула для всех платформ
|
||||
* (Stage 4 §4.4.1 R-17, ранее разделялась на platform-specific buildUniqueKey:
|
||||
* B3 использовал sender alone, B2 sender+keyword, что создавало orphan
|
||||
* supplier_projects при rebalance шеринга — мы не могли сопоставить B2/B3
|
||||
* как одну группу):
|
||||
* site/call → signal_identifier
|
||||
* sms+keyword → sender+keyword (B2 ключ)
|
||||
* sms без keyword → sender (B3 ключ)
|
||||
* sms+keyword → sender+keyword
|
||||
* sms без keyword → sender
|
||||
*/
|
||||
public static function buildUniqueKeyAgnostic(Project $project): string
|
||||
{
|
||||
@@ -95,7 +72,6 @@ final class SupplierProjectGrouping
|
||||
public static function subjectsOf(Project $project): array
|
||||
{
|
||||
$regions = array_values((array) $project->regions);
|
||||
// @phpstan-ignore-next-line identical.alwaysFalse — PostgresIntArray PHPDoc non-empty, runtime can be empty
|
||||
if (count($regions) === 0) {
|
||||
return [null];
|
||||
}
|
||||
|
||||
+39
-6
@@ -8,6 +8,7 @@ use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
@@ -33,12 +34,43 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
// Reduce verbosity of constraint-violation logging (SQLSTATE 23xxx):
|
||||
// data-validity errors do not need a full stack trace в laravel.log.
|
||||
// Incident 2026-05-29: 420k повторов B1+SMS check_violation накопили
|
||||
// 8.7 GB stack traces → disk full → 4h prod downtime.
|
||||
// Solution: log a warning summary с sqlstate, return false to stop
|
||||
// default reporting (which would write full stack trace).
|
||||
// Ref: docs/incidents/2026-05-29-disk-full-pg-recovery.md §5
|
||||
$exceptions->reportable(function (QueryException $e) {
|
||||
$sqlState = $e->errorInfo[0] ?? '';
|
||||
if (is_string($sqlState) && str_starts_with($sqlState, '23')) {
|
||||
Log::warning('db.constraint_violation', [
|
||||
'sqlstate' => $sqlState,
|
||||
'message' => mb_substr($e->getMessage(), 0, 200),
|
||||
]);
|
||||
|
||||
return false; // skip default reporting (no stack trace в laravel.log)
|
||||
}
|
||||
|
||||
return null; // continue default reporting для non-constraint QueryExceptions
|
||||
});
|
||||
|
||||
$exceptions->render(function (QueryException $e, Request $request) {
|
||||
Log::error('db.query_exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'sql' => $e->getSql(),
|
||||
'path' => $request->path(),
|
||||
]);
|
||||
$sqlState = $e->errorInfo[0] ?? '';
|
||||
$isConstraintViolation = is_string($sqlState) && str_starts_with($sqlState, '23');
|
||||
|
||||
if (! $isConstraintViolation) {
|
||||
// Default verbose log для non-constraint QueryExceptions (table missing,
|
||||
// syntax error, etc. — these are bugs needing investigation).
|
||||
Log::error('db.query_exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'sql' => $e->getSql(),
|
||||
'path' => $request->path(),
|
||||
]);
|
||||
}
|
||||
// Constraint violations уже залогированы в reportable() выше как warning,
|
||||
// дублировать не нужно.
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'Не удалось сохранить. Проверьте данные или попробуйте ещё раз.',
|
||||
@@ -52,13 +84,14 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
// Without this render, Laravel's default ValidationException handler returns
|
||||
// 302 redirect to /, which strips POST body — losing supplier leads.
|
||||
// Confirmed 2026-05-25: 76 of 234 webhook hits today got 302 instead of 422.
|
||||
$exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
|
||||
$exceptions->render(function (ValidationException $e, Request $request) {
|
||||
if ($request->is('api/webhook/supplier/*')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return null; // default render for other routes
|
||||
});
|
||||
})->create();
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
// SET ROLE crm_migrator для прода (postgres superuser может SET ROLE).
|
||||
// На dev/testing crm_migrator не имеет GRANT на public schema → пропускаем.
|
||||
try {
|
||||
DB::statement('SET ROLE crm_migrator');
|
||||
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
|
||||
if (!$canCreate || !$canCreate->ok) {
|
||||
DB::statement('RESET ROLE');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// На окружениях без роли — продолжаем как postgres superuser.
|
||||
}
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE TABLE project_routing_snapshots (
|
||||
snapshot_date DATE NOT NULL,
|
||||
project_id BIGINT NOT NULL,
|
||||
tenant_id BIGINT NOT NULL,
|
||||
daily_limit INT NOT NULL CHECK (daily_limit >= 0),
|
||||
delivery_days_mask INT NOT NULL CHECK (delivery_days_mask BETWEEN 0 AND 127),
|
||||
regions INT[] NOT NULL DEFAULT '{}',
|
||||
signal_type TEXT NOT NULL CHECK (signal_type IN ('call','site','sms')),
|
||||
signal_identifier TEXT,
|
||||
sms_senders JSONB,
|
||||
sms_keyword TEXT,
|
||||
expected_volume INT NOT NULL CHECK (expected_volume >= 0),
|
||||
delivered_count INT NOT NULL DEFAULT 0 CHECK (delivered_count >= 0),
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (snapshot_date, project_id),
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
|
||||
-- NB: НЕ ссылаемся на projects(id) — проект может быть удалён,
|
||||
-- а snapshot должен пережить (хвост слепка ещё летит).
|
||||
) PARTITION BY RANGE (snapshot_date);
|
||||
|
||||
CREATE INDEX project_routing_snapshots_tenant_date_idx
|
||||
ON project_routing_snapshots (tenant_id, snapshot_date);
|
||||
CREATE INDEX project_routing_snapshots_signal_idx
|
||||
ON project_routing_snapshots (snapshot_date, signal_type, lower(signal_identifier));
|
||||
|
||||
ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY project_routing_snapshots_tenant_isolation
|
||||
ON project_routing_snapshots
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE ON project_routing_snapshots TO crm_app_user;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON project_routing_snapshots TO crm_supplier_worker;
|
||||
|
||||
-- Партиция для текущего месяца (создаётся также через partitions:create-months).
|
||||
CREATE TABLE project_routing_snapshots_y2026_m05
|
||||
PARTITION OF project_routing_snapshots
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE project_routing_snapshots_y2026_m06
|
||||
PARTITION OF project_routing_snapshots
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
SQL);
|
||||
|
||||
// Регистрация в retention (system_settings).
|
||||
$exists = DB::table('system_settings')
|
||||
->where('key', 'partition_retention_months_project_routing_snapshots')
|
||||
->exists();
|
||||
if (! $exists) {
|
||||
DB::table('system_settings')->insert([
|
||||
'key' => 'partition_retention_months_project_routing_snapshots',
|
||||
'value' => '3',
|
||||
'type' => 'int',
|
||||
'description' => 'Retention в месяцах для project_routing_snapshots (90 дней)',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
try {
|
||||
DB::statement('SET ROLE crm_migrator');
|
||||
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
|
||||
if (!$canCreate || !$canCreate->ok) {
|
||||
DB::statement('RESET ROLE');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// На окружениях без роли — продолжаем как postgres superuser.
|
||||
}
|
||||
DB::statement('DROP TABLE IF EXISTS project_routing_snapshots CASCADE');
|
||||
DB::table('system_settings')->where('key', 'partition_retention_months_project_routing_snapshots')->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Adds per-partition advisory lock to audit_chain_hash() trigger function.
|
||||
*
|
||||
* Root cause: concurrent INSERT workers (e.g. supplier-webhook handlers) all
|
||||
* read the same prev_hash before any of them commits → multiple rows derive
|
||||
* their hash from the same predecessor → hash chain branches → validator finds
|
||||
* mismatches (Finding 1 from Stage-5 Day-1 monitoring).
|
||||
*
|
||||
* Fix: derive a bigint lock key from the physical partition OID (TG_RELID).
|
||||
* pg_advisory_xact_lock() serialises concurrent INSERTs into the SAME partition
|
||||
* without blocking INSERTs to other partitions (distinct OIDs → distinct keys).
|
||||
* The lock is automatically released at transaction end.
|
||||
*
|
||||
* Hash formula: unchanged (verbatim from db/schema.sql:3107-3127):
|
||||
* digest(COALESCE(prev_hash, ''::bytea) || NEW::text::bytea, 'sha256')
|
||||
*
|
||||
* Ref: docs/superpowers/plans/2026-05-29-audit-chain-race-fix.md Task 2
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION public.audit_chain_hash() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
prev_hash BYTEA;
|
||||
lock_key BIGINT;
|
||||
BEGIN
|
||||
-- Derive a partition-specific advisory lock key from the physical
|
||||
-- table OID (TG_RELID). Each child partition has a distinct OID,
|
||||
-- so concurrent INSERTs to DIFFERENT partitions do not block each
|
||||
-- other, while concurrent INSERTs to the SAME partition are
|
||||
-- serialised — preventing the race that branches the hash chain.
|
||||
lock_key := ('x' || lpad(to_hex(TG_RELID::int), 16, '0'))::bit(64)::bigint;
|
||||
PERFORM pg_advisory_xact_lock(lock_key);
|
||||
|
||||
-- Берём log_hash последней строки этой таблицы. NULL для первой записи.
|
||||
-- TG_TABLE_NAME — имя таблицы, через которое триггер сработал; используем
|
||||
-- format/EXECUTE для полиморфности.
|
||||
EXECUTE format(
|
||||
'SELECT log_hash FROM %I ORDER BY id DESC LIMIT 1',
|
||||
TG_TABLE_NAME
|
||||
) INTO prev_hash;
|
||||
|
||||
-- log_hash = sha256(prev_hash || NEW::text). Если prev_hash NULL — берём
|
||||
-- пустую байтовую строку (первая запись цепочки).
|
||||
NEW.log_hash := digest(
|
||||
COALESCE(prev_hash, ''::bytea) || NEW::text::bytea,
|
||||
'sha256'
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Restore verbatim original from db/schema.sql:3107-3127 (without advisory lock).
|
||||
DB::statement(<<<'SQL'
|
||||
CREATE OR REPLACE FUNCTION public.audit_chain_hash() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
prev_hash BYTEA;
|
||||
BEGIN
|
||||
-- Берём log_hash последней строки этой таблицы. NULL для первой записи.
|
||||
-- TG_TABLE_NAME — имя таблицы, через которое триггер сработал; используем
|
||||
-- format/EXECUTE для полиморфности.
|
||||
EXECUTE format(
|
||||
'SELECT log_hash FROM %I ORDER BY id DESC LIMIT 1',
|
||||
TG_TABLE_NAME
|
||||
) INTO prev_hash;
|
||||
|
||||
-- log_hash = sha256(prev_hash || NEW::text). Если prev_hash NULL — берём
|
||||
-- пустую байтовую строку (первая запись цепочки).
|
||||
NEW.log_hash := digest(
|
||||
COALESCE(prev_hash, ''::bytea) || NEW::text::bytea,
|
||||
'sha256'
|
||||
);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
SQL);
|
||||
}
|
||||
};
|
||||
+4
-1
@@ -41,6 +41,9 @@ deptrac:
|
||||
Request: [Rule, Model]
|
||||
Resource: [Model]
|
||||
Rule: [Model]
|
||||
Mail: [Model]
|
||||
# Mail может зависеть от Service value objects (PreflightResult и аналоги) —
|
||||
# это legit dependency: template needs data DTO от Service для рендера.
|
||||
# Decision: ADR-005 amend 2026-05-29 (incident-followup cleanup).
|
||||
Mail: [Model, Service]
|
||||
Model: []
|
||||
Provider: [Controller, Service, Job, Console, Repository, Model, Mail, Middleware, Request, Resource, Rule, Exception]
|
||||
|
||||
Generated
+439
-5
@@ -5,6 +5,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"keytar": "*",
|
||||
"lucide-vue-next": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -39,6 +40,9 @@
|
||||
"vue-tsc": "^3.2.8",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"vuetify": "^3.12.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@acemir/cssom": {
|
||||
@@ -4222,6 +4226,27 @@
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/bidi-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||
@@ -4242,6 +4267,18 @@
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
@@ -4275,6 +4312,31 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||
@@ -4381,6 +4443,13 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -4652,6 +4721,32 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -4733,7 +4828,7 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -4858,6 +4953,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
@@ -5270,6 +5375,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -5570,6 +5685,13 @@
|
||||
"node": ">=18.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/fs-extra": {
|
||||
"version": "11.3.5",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
|
||||
@@ -5699,6 +5821,13 @@
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
@@ -6167,6 +6296,27 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -6194,11 +6344,18 @@
|
||||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-docker": {
|
||||
@@ -6560,6 +6717,25 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/keytar": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",
|
||||
"integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^4.3.0",
|
||||
"prebuild-install": "^7.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keytar/node_modules/node-addon-api": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",
|
||||
"integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -7290,6 +7466,19 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@@ -7310,7 +7499,7 @@
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
@@ -7333,6 +7522,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/mri": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
|
||||
@@ -7386,6 +7582,13 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
@@ -7393,6 +7596,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.92.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.92.0.tgz",
|
||||
"integrity": "sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
@@ -7454,6 +7670,16 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/oniguruma-parser": {
|
||||
"version": "0.12.2",
|
||||
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
|
||||
@@ -7843,6 +8069,34 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@@ -7897,6 +8151,17 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -7938,6 +8203,47 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/rc/node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -8322,6 +8628,27 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/sass": {
|
||||
"version": "1.99.0",
|
||||
"resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz",
|
||||
@@ -8731,7 +9058,7 @@
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -8813,6 +9140,53 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-concat": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
|
||||
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
@@ -8933,6 +9307,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
@@ -9095,6 +9479,36 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -9239,6 +9653,19 @@
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@@ -9455,7 +9882,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
@@ -10106,6 +10533,13 @@
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
|
||||
@@ -51,5 +51,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-vue-next": "^1.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"keytar": "^7.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
+362
-2
@@ -51,7 +51,7 @@ parameters:
|
||||
-
|
||||
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 5
|
||||
count: 6
|
||||
path: app/Http/Controllers/Api/DealController.php
|
||||
|
||||
-
|
||||
@@ -84,6 +84,24 @@ parameters:
|
||||
count: 1
|
||||
path: app/Http/Middleware/SetTenantContext.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Http\\Resources\\ProjectResource\:\:\$applies_from\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Http/Resources/ProjectResource.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$array \(non\-empty\-list\<int\>\) of array_values is already a list, call has no effect\.$#'
|
||||
identifier: arrayValues.list
|
||||
count: 1
|
||||
path: app/Jobs/Supplier/SyncSupplierProjectsJob.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$column of method Illuminate\\Database\\Eloquent\\Builder\<App\\Models\\Project\>\:\:where\(\) expects array\<int\|model property of App\\Models\\Project, mixed\>\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\<App\\Models\\Project\>\)\: Illuminate\\Database\\Eloquent\\Builder\<App\\Models\\Project\>\)\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\<App\\Models\\Project\>\)\: void\)\|Illuminate\\Contracts\\Database\\Query\\Expression\|model property of App\\Models\\Project, ''snap\.snapshot_date'' given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: app/Jobs/Supplier/SyncSupplierProjectsJob.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
@@ -102,6 +120,12 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/NotificationService.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Project\:\:\$applies_from\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: app/Services/Project/ProjectService.php
|
||||
|
||||
-
|
||||
message: '#^Match expression does not handle remaining value\: string$#'
|
||||
identifier: match.unhandled
|
||||
@@ -120,6 +144,90 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/Supplier/Channel/AjaxProjectChannel.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenDefineFunctions not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenFinalClasses not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenNormalClasses not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenPrivateMethods not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenTraits not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\SyntaxCheck not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Metrics\\Architecture\\Classes not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\Commenting\\UselessFunctionDocCommentSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\Namespaces\\AlphabeticallySortedUsesSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DeclareStrictTypesSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DisallowMixedTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ParameterTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\PropertyTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ReturnTypeHintSniff not found\.$#'
|
||||
identifier: class.notFound
|
||||
count: 1
|
||||
path: config/insights.php
|
||||
|
||||
-
|
||||
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
|
||||
identifier: method.childReturnType
|
||||
@@ -156,6 +264,12 @@ parameters:
|
||||
count: 1
|
||||
path: database/factories/UserFactory.php
|
||||
|
||||
-
|
||||
message: '#^Offset ''SnapshotProjectRout…'' on null in isset\(\) does not exist\.$#'
|
||||
identifier: isset.offset
|
||||
count: 1
|
||||
path: routes/console.php
|
||||
|
||||
-
|
||||
message: '#^Offset ''projects\:reset…'' on null in isset\(\) does not exist\.$#'
|
||||
identifier: isset.offset
|
||||
@@ -444,6 +558,18 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/ApiKeyControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Audit/AuditChainRaceConditionTest.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>cnt" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 1
|
||||
path: tests/Feature/Audit/AuditRebuildChainTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -720,6 +846,36 @@ parameters:
|
||||
count: 6
|
||||
path: tests/Feature/Auth/UpdateProfileTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
path: tests/Feature/Billing/BalanceStatusTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/BalanceStatusTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/BalanceStatusTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 6
|
||||
path: tests/Feature/Billing/BalanceStatusTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/BalanceStatusTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -750,10 +906,16 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Billing/BillingOverviewControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Billing/BillingPreflightInitialSweepTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#'
|
||||
identifier: property.notFound
|
||||
count: 8
|
||||
count: 9
|
||||
path: tests/Feature/Billing/LedgerServiceTest.php
|
||||
|
||||
-
|
||||
@@ -768,6 +930,12 @@ parameters:
|
||||
count: 6
|
||||
path: tests/Feature/Billing/PricingTierRepositoryTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Billing/ProjectPreflightTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -876,6 +1044,30 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Console/SnapshotBackfillCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 2
|
||||
path: tests/Feature/Console/SnapshotBackfillCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Console/SnapshotRebuildCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 2
|
||||
path: tests/Feature/Console/SnapshotRebuildCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1296,6 +1488,12 @@ parameters:
|
||||
count: 5
|
||||
path: tests/Feature/EndpointAuthHardeningTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Project\:\:\$applies_from\.$#'
|
||||
identifier: property.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Http/Resources/ProjectResourceAppliesFromTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1308,6 +1506,18 @@ parameters:
|
||||
count: 2
|
||||
path: tests/Feature/Http/Webhook/SupplierWebhookTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:call\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1422,6 +1632,12 @@ parameters:
|
||||
count: 8
|
||||
path: tests/Feature/Incidents/IncidentsWatchFailuresExpandedTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 5
|
||||
path: tests/Feature/Incidents/SingleLeadStormTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1434,12 +1650,48 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Integration/SupplierLeadFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php
|
||||
|
||||
-
|
||||
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 2
|
||||
path: tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Jobs/RouteSupplierLeadJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 1
|
||||
path: tests/Feature/Jobs/SnapshotProjectRoutingJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 4
|
||||
path: tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php
|
||||
|
||||
-
|
||||
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 1
|
||||
path: tests/Feature/LeadRouter/FrozenFilterTest.php
|
||||
|
||||
-
|
||||
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 4
|
||||
path: tests/Feature/LeadRouter/SnapshotRoutingTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -2016,6 +2268,24 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/Security/WebhookUrlChangeAuditTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Models\\Project\:\:\$applies_from\.$#'
|
||||
identifier: property.notFound
|
||||
count: 5
|
||||
path: tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php
|
||||
|
||||
-
|
||||
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 4
|
||||
path: tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php
|
||||
|
||||
-
|
||||
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
|
||||
identifier: arguments.count
|
||||
count: 5
|
||||
path: tests/Feature/Services/Project/SupplierSnapshotGuardAppliesFromTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -2064,12 +2334,48 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
|
||||
identifier: property.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/CsvWebhookRaceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$sp\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
path: tests/Feature/Supplier/CsvWebhookRaceTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 8
|
||||
path: tests/Feature/Supplier/CsvWebhookRaceTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/CsvWebhookRaceTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Supplier/DirectPlatformTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/DirectPlatformTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -2148,12 +2454,30 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/SupplierProjectImporterTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Supplier/SupplierRekeyOrphansCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method App\\Services\\Supplier\\PlaywrightBridge\:\:shouldReceive\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$sharedProject\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
path: tests/Feature/Supplier/SupplierWebhookFastFailTest.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>error" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/SupplierWebhookFastFailTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -2274,6 +2598,42 @@ parameters:
|
||||
count: 6
|
||||
path: tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
|
||||
identifier: method.alreadyNarrowedType
|
||||
count: 3
|
||||
path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#2 \$snapshotGuard of class App\\Services\\Project\\ProjectService constructor expects App\\Services\\Project\\SupplierSnapshotGuard, Mockery\\MockInterface given\.$#'
|
||||
identifier: argument.type
|
||||
count: 3
|
||||
path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:with\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Unit/Services/Project/SupplierSnapshotGuardTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
|
||||
identifier: method.alreadyNarrowedType
|
||||
count: 1
|
||||
path: tests/Unit/Services/Project/SupplierSnapshotGuardTest.php
|
||||
|
||||
-
|
||||
message: '#^Property App\\Models\\IdeHelperProject\:\:\$paused_at \(Illuminate\\Support\\Carbon\|null\) does not accept Carbon\\CarbonImmutable\.$#'
|
||||
identifier: assign.propertyType
|
||||
count: 2
|
||||
path: tests/Unit/Services/Project/SupplierSnapshotGuardTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method App\\Services\\Supplier\\ProcessFactory\:\:shouldReceive\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
|
||||
@@ -7,7 +7,7 @@ import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
|
||||
const props = defineProps<{ project: Project | null }>();
|
||||
const emit = defineEmits<{ close: []; saved: [] }>();
|
||||
const emit = defineEmits<{ close: []; saved: [appliesFrom: string | null] }>();
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
@@ -100,8 +100,11 @@ async function onSave(): Promise<void> {
|
||||
payload.sms_senders = form.sms_senders;
|
||||
payload.sms_keyword = form.sms_keyword;
|
||||
}
|
||||
await axios.patch(`/api/projects/${props.project.id}`, payload);
|
||||
emit('saved');
|
||||
const { data } = await axios.patch(`/api/projects/${props.project.id}`, payload);
|
||||
// Backend кладёт applies_from когда правка задела slepok-чувствительные поля
|
||||
// (см. ProjectService::updateAndExposeAppliesFrom / Task 2.8).
|
||||
const appliesFrom: string | null = data?.data?.applies_from ?? null;
|
||||
emit('saved', appliesFrom);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
|
||||
if (err.response?.status === 422 && err.response.data?.errors) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Форматирует сообщение для тоста после сохранения slepok-чувствительных
|
||||
* правок проекта.
|
||||
*
|
||||
* Бизнес-инвариант: applies_from = N.21:00 МСК — час, когда поставщик
|
||||
* фиксирует свой slepok (см. docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md).
|
||||
* Поэтому в UI всегда показываем «21:00 МСК», а календарную дату берём
|
||||
* по часовому поясу Москвы — независимо от локали браузера клиента.
|
||||
*/
|
||||
const moscowDateFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||
timeZone: 'Europe/Moscow',
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
export function formatAppliesFromMessage(appliesFrom: string | null | undefined): string {
|
||||
if (!appliesFrom) {
|
||||
return 'Сохранено.';
|
||||
}
|
||||
const date = moscowDateFmt.format(new Date(appliesFrom));
|
||||
return `Сохранено. Изменения вступят в силу ${date} в 21:00 МСК.`;
|
||||
}
|
||||
@@ -173,8 +173,17 @@
|
||||
@close="onDrawerClose"
|
||||
@saved="onDrawerSaved"
|
||||
/>
|
||||
<NewProjectDialog v-model="createOpen" mode="create" @saved="store.fetch()" />
|
||||
<EditProjectDialog v-model="editOpen" :project="editing" @saved="store.fetch()" />
|
||||
<NewProjectDialog v-model="createOpen" mode="create" @saved="onProjectSaved" />
|
||||
<EditProjectDialog v-model="editOpen" :project="editing" @saved="onProjectSaved" />
|
||||
|
||||
<v-snackbar
|
||||
v-model="savedSnackbarOpen"
|
||||
color="success"
|
||||
:timeout="appliesFromShown ? 7000 : 3500"
|
||||
data-testid="projects-saved-snackbar"
|
||||
>
|
||||
{{ savedSnackbarMessage }}
|
||||
</v-snackbar>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -187,12 +196,25 @@ import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
|
||||
import NewProjectDialog from './projects/NewProjectDialog.vue';
|
||||
import EditProjectDialog from './projects/EditProjectDialog.vue';
|
||||
import { REGIONS } from '../constants/regions';
|
||||
import { formatAppliesFromMessage } from '../composables/appliesFromMessage';
|
||||
|
||||
const store = useProjectsStore();
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const editing = ref<Project | null>(null);
|
||||
|
||||
// Тост «Сохранено» после правки проекта. Если правка задела slepok-чувствительные
|
||||
// поля (regions / delivery_days_mask / daily_limit_target / источник), backend
|
||||
// возвращает applies_from = N.21:00 МСК — показываем расширенное сообщение.
|
||||
const savedSnackbarOpen = ref(false);
|
||||
const savedSnackbarMessage = ref('');
|
||||
const appliesFromShown = ref(false);
|
||||
function showSavedSnackbar(appliesFrom: string | null): void {
|
||||
savedSnackbarMessage.value = formatAppliesFromMessage(appliesFrom);
|
||||
appliesFromShown.value = appliesFrom !== null;
|
||||
savedSnackbarOpen.value = true;
|
||||
}
|
||||
|
||||
// Информационный баннер о сроке внесения изменений (синхронизация с поставщиком в 18:00 МСК).
|
||||
// Закрытие запоминается, чтобы не показывать повторно.
|
||||
const CUTOFF_BANNER_KEY = 'projects.cutoffBannerDismissed';
|
||||
@@ -211,10 +233,15 @@ const singleSelectedProject = computed<Project | null>(() => {
|
||||
function onDrawerClose(): void {
|
||||
store.clearSelection();
|
||||
}
|
||||
function onDrawerSaved(): void {
|
||||
function onDrawerSaved(appliesFrom: string | null): void {
|
||||
// #4: после Save/Pause/Delete панель и галочка должны исчезнуть.
|
||||
store.clearSelection();
|
||||
void store.fetch();
|
||||
showSavedSnackbar(appliesFrom);
|
||||
}
|
||||
function onProjectSaved(appliesFrom: string | null): void {
|
||||
void store.fetch();
|
||||
showSavedSnackbar(appliesFrom);
|
||||
}
|
||||
|
||||
const typeFilters = [
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
mode="edit"
|
||||
:project="project"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
@saved="$emit('saved')"
|
||||
@saved="(appliesFrom) => $emit('saved', appliesFrom)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -13,5 +13,8 @@ import NewProjectDialog from './NewProjectDialog.vue';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
|
||||
defineProps<{ modelValue: boolean; project: Project | null }>();
|
||||
defineEmits(['update:modelValue', 'saved']);
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
saved: [appliesFrom: string | null];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
@@ -215,7 +215,10 @@ const props = defineProps<{
|
||||
mode?: 'create' | 'edit';
|
||||
project?: Project | null;
|
||||
}>();
|
||||
const emit = defineEmits(['update:modelValue', 'saved']);
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
saved: [appliesFrom: string | null];
|
||||
}>();
|
||||
|
||||
// Plan 6: regions = subject codes (1..89) — backend dual-writes region_mask/region_mode.
|
||||
// Пустой массив = вся РФ.
|
||||
@@ -326,13 +329,17 @@ async function persist(extra: Record<string, unknown> = {}): Promise<void> {
|
||||
try {
|
||||
await ensureCsrfCookie();
|
||||
const body = { ...form, ...extra };
|
||||
let appliesFrom: string | null = null;
|
||||
if (props.mode === 'edit' && props.project) {
|
||||
await apiClient.patch(`/api/projects/${props.project.id}`, body);
|
||||
const { data } = await apiClient.patch(`/api/projects/${props.project.id}`, body);
|
||||
// Backend кладёт applies_from только когда правка задела slepok-чувствительные поля.
|
||||
appliesFrom = data?.data?.applies_from ?? null;
|
||||
} else {
|
||||
await apiClient.post('/api/projects', body);
|
||||
// Create НЕ генерирует applies_from (новый проект сразу попадает в snapshot).
|
||||
}
|
||||
overloadOpen.value = false;
|
||||
emit('saved');
|
||||
emit('saved', appliesFrom);
|
||||
close();
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head><meta charset="UTF-8"><title>Tenant business drift alert</title></head>
|
||||
<body style="font-family: Arial, sans-serif;">
|
||||
<h3>Business-shortfall тенанта Лидерры</h3>
|
||||
<p>Тенант <strong>#{{ $tenantId }}</strong>, дата слепка: <strong>{{ $snapshotDate }}</strong></p>
|
||||
<ul>
|
||||
<li>Ожидалось по слепку: <strong>{{ $expected }}</strong> лидов</li>
|
||||
<li>Доставлено фактически: <strong>{{ $delivered }}</strong> лидов</li>
|
||||
<li>Shortfall ratio: <strong>{{ number_format($shortfallRatio * 100, 1, ',', ' ') }}%</strong> (порог 20%)</li>
|
||||
</ul>
|
||||
<p>Окно сверки: <strong>{{ $windowStart->format('Y-m-d H:i') }} — {{ $windowEnd->format('Y-m-d H:i') }}</strong></p>
|
||||
<p>Проверь причину — поставщик не закрывает заказ, расхождение масок workdays или regions, либо проект потерял eligibility внутри slepok'а.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,6 +3,7 @@
|
||||
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
|
||||
use App\Jobs\Supplier\CsvReconcileJob;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Jobs\SnapshotProjectRoutingJob;
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Services\SchedulerHeartbeatTracker;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
@@ -102,6 +103,20 @@ Schedule::job(new RefreshSupplierSessionJob)
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', false, 'Job failed', null));
|
||||
// Spec 2026-05-26-slepok-routing-protection §4.2.2:
|
||||
// SnapshotProjectRoutingJob создаёт slepok №NЛ для дня N+1 в 18:02 МСК.
|
||||
// Запускается ПОСЛЕ billing:preflight-sweep (18:00) и ДО SyncSupplierProjectsJob (18:05).
|
||||
Schedule::job(new SnapshotProjectRoutingJob)
|
||||
->dailyAt('18:02')
|
||||
->timezone('Europe/Moscow')
|
||||
->before(fn () => $startTimes['SnapshotProjectRoutingJob'] = microtime(true))
|
||||
->onSuccess(function () use ($hb, &$startTimes): void {
|
||||
$name = 'SnapshotProjectRoutingJob';
|
||||
$ms = isset($startTimes[$name]) ? (int) ((microtime(true) - $startTimes[$name]) * 1000) : null;
|
||||
$hb->recordRunResult($name, true, null, $ms);
|
||||
})
|
||||
->onFailure(fn () => $hb->recordRunResult('SnapshotProjectRoutingJob', false, 'Job failed', null));
|
||||
|
||||
// Billing v2 Spec C: сдвинут 18:00 → 18:05, чтобы billing:preflight-sweep (18:00)
|
||||
// успел проставить frozen-флаги до формирования заказа поставщику.
|
||||
Schedule::job(new SyncSupplierProjectsJob)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Race-condition reproduction test for audit_chain_hash() trigger.
|
||||
*
|
||||
* Two tests:
|
||||
* 1. pcntl_fork-based concurrent INSERT test — skipped on Windows (no pcntl).
|
||||
* Expected: FAIL before migration (concurrent inserts branch the chain),
|
||||
* PASS after migration (advisory lock serialises inserts).
|
||||
*
|
||||
* 2. pg_locks advisory lock presence test — runs on Windows.
|
||||
* Asserts that within an INSERT transaction the advisory lock key derived
|
||||
* from the partition OID is held (proves the lock is actually acquired).
|
||||
*/
|
||||
|
||||
it(
|
||||
'audit_chain_hash trigger preserves sequential chain under concurrent INSERTs',
|
||||
function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
$startCount = DB::table('activity_log')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->count();
|
||||
|
||||
// Spawn 5 concurrent processes each inserting into activity_log for the same tenant.
|
||||
// Without advisory lock, concurrent reads of prev_hash return the same value
|
||||
// → multiple rows hash to the same prev → chain branch → validator fails.
|
||||
$pids = [];
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$pid = pcntl_fork();
|
||||
if ($pid === 0) {
|
||||
// Child: own DB connection, own transaction
|
||||
DB::reconnect();
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
DB::table('activity_log')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event' => 'deal.created',
|
||||
'context' => json_encode(['worker' => $i]),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
exit(0);
|
||||
}
|
||||
$pids[] = $pid;
|
||||
}
|
||||
foreach ($pids as $pid) {
|
||||
pcntl_waitpid($pid, $status);
|
||||
}
|
||||
|
||||
$rows = DB::table('activity_log')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->orderBy('id')
|
||||
->get(['id', 'log_hash']);
|
||||
|
||||
expect($rows->count())->toBe($startCount + 5);
|
||||
|
||||
// Run the chain validator; it should find no mismatches (after migration).
|
||||
$exitCode = $this->artisan('audit:verify-chains')->run();
|
||||
expect($exitCode)->toBe(0);
|
||||
}
|
||||
)->skip(! function_exists('pcntl_fork'), 'pcntl required for race-condition test (not available on Windows)');
|
||||
|
||||
it('audit_chain_hash holds pg_advisory_xact_lock on the partition OID during INSERT', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
// Resolve the OID of the current-month activity_log partition (or parent).
|
||||
$partitionName = 'activity_log_y'.date('Y').'_m'.date('m');
|
||||
$oid = DB::selectOne(
|
||||
"SELECT COALESCE(
|
||||
(SELECT c.oid FROM pg_class c WHERE c.relname = ?),
|
||||
(SELECT c.oid FROM pg_class c WHERE c.relname = 'activity_log')
|
||||
) AS oid",
|
||||
[$partitionName]
|
||||
)?->oid;
|
||||
|
||||
expect($oid)->not->toBeNull('Could not resolve partition/parent OID');
|
||||
|
||||
// Compute the lock key using the same formula as the trigger:
|
||||
// ('x' || lpad(to_hex(TG_RELID::int), 16, '0'))::bit(64)::bigint
|
||||
$lockKeyRow = DB::selectOne(
|
||||
"SELECT ('x' || lpad(to_hex(?::int), 16, '0'))::bit(64)::bigint AS lock_key",
|
||||
[(int) $oid]
|
||||
);
|
||||
$lockKey = $lockKeyRow?->lock_key;
|
||||
expect($lockKey)->not->toBeNull();
|
||||
|
||||
// Wrap an INSERT in a transaction and check pg_locks DURING that transaction.
|
||||
$lockHeld = false;
|
||||
DB::transaction(function () use ($tenant, $lockKey, &$lockHeld): void {
|
||||
DB::table('activity_log')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event' => 'deal.created',
|
||||
'context' => json_encode(['test' => 'advisory_lock_check']),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// pg_advisory_xact_lock releases at END of transaction — still held here.
|
||||
$held = DB::selectOne(
|
||||
'SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_locks
|
||||
WHERE locktype = \'advisory\'
|
||||
AND classid = (? >> 32)::int
|
||||
AND objid = (? & x\'ffffffff\'::bigint)::int
|
||||
AND granted = true
|
||||
AND pid = pg_backend_pid()
|
||||
) AS held',
|
||||
[(int) $lockKey, (int) $lockKey]
|
||||
);
|
||||
$lockHeld = (bool) ($held->held ?? false);
|
||||
});
|
||||
|
||||
expect($lockHeld)->toBeTrue(
|
||||
'pg_advisory_xact_lock was not observed in pg_locks during the INSERT transaction. '
|
||||
.'This means the migration has not been applied or the lock key formula is wrong.'
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
<?php
|
||||
|
||||
// Tests for audit:rebuild-chain command (Task 3).
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Tests for audit:rebuild-chain command.
|
||||
*
|
||||
* Verifies that:
|
||||
* 1. The command recomputes log_hash values using the same formula as audit_chain_hash():
|
||||
* digest(COALESCE(prev_hash, ''::bytea) || ROW(col1, ..., NULL::bytea, ..., coln)::text::bytea, 'sha256')
|
||||
* 2. The rebuilt hashes match what VerifyAuditChains expects (validates as intact).
|
||||
* 3. --dry-run does not modify hashes.
|
||||
* 4. Unknown partition names are rejected.
|
||||
*
|
||||
* Note: we use direct SQL verification (mirroring VerifyAuditChains logic)
|
||||
* rather than calling audit:verify-chains, because the full command checks ALL
|
||||
* partitions and a pre-existing mismatch in any other partition would cause
|
||||
* false failure. This keeps the test focused on our specific partition.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check chain integrity for a specific partition using the same SQL as VerifyAuditChains.
|
||||
* Returns the count of mismatched rows (0 = intact).
|
||||
*/
|
||||
function checkPartitionIntegrity(string $partition, string $partitionClause, string $rowExpr): int
|
||||
{
|
||||
$overClause = $partitionClause !== ''
|
||||
? "({$partitionClause} ORDER BY id)"
|
||||
: '(ORDER BY id)';
|
||||
|
||||
$sql = <<<SQL
|
||||
WITH ordered AS (
|
||||
SELECT
|
||||
id,
|
||||
log_hash AS stored_hash,
|
||||
LAG(log_hash) OVER {$overClause} AS prev_hash
|
||||
FROM {$partition}
|
||||
)
|
||||
SELECT count(*) AS cnt
|
||||
FROM ordered o
|
||||
WHERE o.stored_hash IS DISTINCT FROM
|
||||
digest(
|
||||
COALESCE(o.prev_hash, ''::bytea)
|
||||
|| (SELECT {$rowExpr}::text::bytea FROM {$partition} t WHERE t.id = o.id),
|
||||
'sha256'
|
||||
)
|
||||
SQL;
|
||||
|
||||
$result = DB::connection('pgsql_supplier')->selectOne($sql);
|
||||
|
||||
return (int) ($result?->cnt ?? 0);
|
||||
}
|
||||
|
||||
// Column list for activity_log (must match VerifyAuditChains::TABLE_CONFIG).
|
||||
const ACTIVITY_LOG_ROW_EXPR = 'ROW(t.id, t.tenant_id, t.user_id, t.deal_id, t.event, t.old_value, t.new_value, t.context, t.ip_address, t.user_agent, NULL::bytea, t.created_at)';
|
||||
|
||||
// Column list for balance_transactions (must match VerifyAuditChains::TABLE_CONFIG).
|
||||
const BALANCE_TX_ROW_EXPR = 'ROW(t.id, t.tenant_id, t.type, t.amount_rub, t.amount_leads, t.balance_rub_after, t.balance_leads_after, t.description, t.related_type, t.related_id, t.user_id, t.admin_user_id, NULL::bytea, t.created_at)';
|
||||
|
||||
it('audit:rebuild-chain repairs broken hash chain from given id in activity_log', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
// Insert 3 valid rows via normal flow (trigger writes correct hashes).
|
||||
DB::table('activity_log')->insert([
|
||||
['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.created', 'context' => null, 'created_at' => now()],
|
||||
['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.updated', 'context' => null, 'created_at' => now()->addMicrosecond()],
|
||||
['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.closed', 'context' => null, 'created_at' => now()->addMicroseconds(2)],
|
||||
]);
|
||||
|
||||
$rows = DB::table('activity_log')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->orderBy('id')
|
||||
->get(['id', 'log_hash', 'event']);
|
||||
|
||||
expect($rows)->toHaveCount(3);
|
||||
|
||||
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
|
||||
|
||||
// Verify initial state: chain is intact for our tenant's rows.
|
||||
$initialMismatches = checkPartitionIntegrity(
|
||||
$partition,
|
||||
'PARTITION BY tenant_id',
|
||||
ACTIVITY_LOG_ROW_EXPR,
|
||||
);
|
||||
expect($initialMismatches)->toBe(0, 'Initial chain should be intact');
|
||||
|
||||
// Manually corrupt row 2's log_hash (simulating race-condition branch).
|
||||
DB::statement("SET session_replication_role = 'replica'");
|
||||
DB::statement('UPDATE activity_log SET log_hash = \'\\xdeadbeef\'::bytea WHERE id = '.$rows[1]->id);
|
||||
DB::statement("SET session_replication_role = 'origin'");
|
||||
|
||||
// Verify: now there's a mismatch (row 2 + row 3 that depends on row 2).
|
||||
$mismatchesBefore = checkPartitionIntegrity(
|
||||
$partition,
|
||||
'PARTITION BY tenant_id',
|
||||
ACTIVITY_LOG_ROW_EXPR,
|
||||
);
|
||||
expect($mismatchesBefore)->toBeGreaterThan(0, 'Chain should have mismatch after corruption');
|
||||
|
||||
// Rebuild from the corrupted row onwards.
|
||||
$fromId = $rows[1]->id;
|
||||
|
||||
$exitRebuild = Artisan::call('audit:rebuild-chain', [
|
||||
'--partition' => $partition,
|
||||
'--from-id' => $fromId,
|
||||
'--force' => true,
|
||||
]);
|
||||
expect($exitRebuild)->toBe(0);
|
||||
|
||||
// Verify: chain is now intact again.
|
||||
$mismatchesAfter = checkPartitionIntegrity(
|
||||
$partition,
|
||||
'PARTITION BY tenant_id',
|
||||
ACTIVITY_LOG_ROW_EXPR,
|
||||
);
|
||||
expect($mismatchesAfter)->toBe(0, 'Chain should be intact after rebuild');
|
||||
|
||||
// Verify the hashes actually changed (the corrupt value was replaced).
|
||||
$rebuilt = DB::table('activity_log')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('id', '>=', $fromId)
|
||||
->orderBy('id')
|
||||
->pluck('log_hash');
|
||||
|
||||
foreach ($rebuilt as $hash) {
|
||||
// BYTEA columns returned as PHP stream resources via PDO pgsql driver.
|
||||
$bin = is_resource($hash) ? stream_get_contents($hash) : (string) $hash;
|
||||
expect(bin2hex($bin))->not->toBe('deadbeef')
|
||||
->and(strlen($bin))->toBe(32); // sha256 = 32 bytes
|
||||
}
|
||||
});
|
||||
|
||||
it('audit:rebuild-chain works for balance_transactions partition', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
DB::table('balance_transactions')->insert([
|
||||
['tenant_id' => $tenant->id, 'type' => 'topup', 'amount_rub' => 100, 'amount_leads' => 0, 'created_at' => now()],
|
||||
['tenant_id' => $tenant->id, 'type' => 'lead_charge', 'amount_rub' => -10, 'amount_leads' => 0, 'created_at' => now()->addMicrosecond()],
|
||||
]);
|
||||
|
||||
$rows = DB::table('balance_transactions')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->orderBy('id')
|
||||
->get(['id', 'log_hash']);
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
|
||||
$partition = 'balance_transactions_y'.now()->format('Y').'_m'.now()->format('m');
|
||||
|
||||
// Corrupt second row.
|
||||
DB::statement("SET session_replication_role = 'replica'");
|
||||
DB::statement('UPDATE balance_transactions SET log_hash = \'\\xbaadf00d\'::bytea WHERE id = '.$rows[1]->id);
|
||||
DB::statement("SET session_replication_role = 'origin'");
|
||||
|
||||
$mismatchesBefore = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', BALANCE_TX_ROW_EXPR);
|
||||
expect($mismatchesBefore)->toBeGreaterThan(0);
|
||||
|
||||
$exit = Artisan::call('audit:rebuild-chain', [
|
||||
'--partition' => $partition,
|
||||
'--from-id' => $rows[1]->id,
|
||||
'--force' => true,
|
||||
]);
|
||||
expect($exit)->toBe(0);
|
||||
|
||||
$mismatchesAfter = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', BALANCE_TX_ROW_EXPR);
|
||||
expect($mismatchesAfter)->toBe(0, 'Balance transaction chain should be intact after rebuild');
|
||||
});
|
||||
|
||||
it('audit:rebuild-chain --dry-run does not modify hashes', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
DB::table('activity_log')->insert([
|
||||
['tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'dry.run.test', 'context' => null, 'created_at' => now()],
|
||||
]);
|
||||
|
||||
$row = DB::table('activity_log')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->orderByDesc('id')
|
||||
->first(['id', 'log_hash']);
|
||||
|
||||
// Corrupt the hash.
|
||||
DB::statement("SET session_replication_role = 'replica'");
|
||||
DB::statement('UPDATE activity_log SET log_hash = \'\\xcafebabe\'::bytea WHERE id = '.$row->id);
|
||||
DB::statement("SET session_replication_role = 'origin'");
|
||||
|
||||
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
|
||||
|
||||
Artisan::call('audit:rebuild-chain', [
|
||||
'--partition' => $partition,
|
||||
'--from-id' => $row->id,
|
||||
'--dry-run' => true,
|
||||
]);
|
||||
|
||||
// Hash must remain corrupted — dry-run made no changes.
|
||||
// BYTEA columns are returned as PHP stream resources via PDO pgsql driver.
|
||||
$afterRaw = DB::table('activity_log')->where('id', $row->id)->value('log_hash');
|
||||
$afterBin = is_resource($afterRaw) ? stream_get_contents($afterRaw) : (string) $afterRaw;
|
||||
expect(bin2hex($afterBin))->toBe('cafebabe');
|
||||
});
|
||||
|
||||
it('audit:rebuild-chain rejects unknown partition names', function (): void {
|
||||
Artisan::call('audit:rebuild-chain', [
|
||||
'--partition' => 'deals_y2026_m05', // not an audit table
|
||||
'--from-id' => 1,
|
||||
'--force' => true,
|
||||
]);
|
||||
expect(Artisan::output())->toContain('поддерживаемым аудит-таблицам');
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// ADR-018 Task 3: failing tests для per-tenant rebuild (RED phase).
|
||||
// После Task 4 (per-tenant LAG OVER) — должны стать PASS.
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Column list for auth_log (must match AuditChainConfig::TABLES['auth_log']).
|
||||
const AUTH_LOG_ROW_EXPR = 'ROW(t.id, t.actor_type, t.tenant_id, t.user_id, t.saas_admin_user_id, t.email, t.event, t.ip_address, t.user_agent, t.failure_reason, NULL::bytea, t.created_at)';
|
||||
|
||||
it('audit:rebuild-chain produces per-tenant chain matching trigger semantics в activity_log', function (): void {
|
||||
$tenantA = Tenant::factory()->create();
|
||||
$tenantB = Tenant::factory()->create();
|
||||
|
||||
// Tenant A — 2 rows.
|
||||
DB::statement('SET app.current_tenant_id = '.$tenantA->id);
|
||||
DB::table('activity_log')->insert([
|
||||
['tenant_id' => $tenantA->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.a1', 'context' => null, 'created_at' => now()],
|
||||
['tenant_id' => $tenantA->id, 'user_id' => null, 'deal_id' => 1, 'event' => 'deal.a2', 'context' => null, 'created_at' => now()->addMicrosecond()],
|
||||
]);
|
||||
|
||||
// Tenant B — 2 rows (interleaved IDs with tenant A, но цепочка независимая per-tenant).
|
||||
DB::statement('SET app.current_tenant_id = '.$tenantB->id);
|
||||
DB::table('activity_log')->insert([
|
||||
['tenant_id' => $tenantB->id, 'user_id' => null, 'deal_id' => 2, 'event' => 'deal.b1', 'context' => null, 'created_at' => now()->addMicroseconds(2)],
|
||||
['tenant_id' => $tenantB->id, 'user_id' => null, 'deal_id' => 2, 'event' => 'deal.b2', 'context' => null, 'created_at' => now()->addMicroseconds(3)],
|
||||
]);
|
||||
|
||||
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
|
||||
$firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');
|
||||
|
||||
// NB: pre-rebuild sanity-check на trigger output опущен намеренно — в test env
|
||||
// `SharesSupplierPdo` trait + postgres superuser обходят RLS, и trigger пишет
|
||||
// global chain, а не per-tenant. На prod RLS активен и trigger пишет per-tenant
|
||||
// (валидация — live `audit:verify-chains` на проде, не в этом тесте).
|
||||
//
|
||||
// Что тестируется здесь: AFTER rebuild чейн должен match семантике своего
|
||||
// partition_clause (self-consistency). Pre-Task-4 rebuild делает global LAG →
|
||||
// verify с PARTITION BY tenant_id обнаруживает mismatch → RED. Post-Task-4
|
||||
// rebuild делает per-tenant LAG → verify с PARTITION BY tenant_id match → GREEN.
|
||||
|
||||
$exit = Artisan::call('audit:rebuild-chain', [
|
||||
'--partition' => $partition,
|
||||
'--from-id' => $firstId,
|
||||
'--force' => true,
|
||||
]);
|
||||
expect($exit)->toBe(0);
|
||||
|
||||
$postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
|
||||
expect($postMismatches)->toBe(0, 'Rebuild должен produce per-tenant chain matching PARTITION BY tenant_id semantics (ADR-018)');
|
||||
});
|
||||
|
||||
it('audit:rebuild-chain produces global chain for BYPASSRLS auth_log', function (): void {
|
||||
// auth_log пишется под BYPASSRLS pre-auth role. INSERT direct через pgsql_supplier.
|
||||
DB::connection('pgsql_supplier')->table('auth_log')->insert([
|
||||
['actor_type' => 'tenant_user', 'tenant_id' => null, 'event' => 'login', 'email' => 'a@x.com', 'created_at' => now()],
|
||||
['actor_type' => 'tenant_user', 'tenant_id' => null, 'event' => 'login', 'email' => 'b@x.com', 'created_at' => now()->addMicrosecond()],
|
||||
]);
|
||||
|
||||
$partition = 'auth_log_y'.now()->format('Y').'_m'.now()->format('m');
|
||||
$firstId = (int) DB::connection('pgsql_supplier')->table($partition)->min('id');
|
||||
|
||||
$preMismatches = checkPartitionIntegrity($partition, '', AUTH_LOG_ROW_EXPR);
|
||||
expect($preMismatches)->toBe(0, 'Trigger writes global chain correctly for auth_log');
|
||||
|
||||
$exit = Artisan::call('audit:rebuild-chain', [
|
||||
'--partition' => $partition,
|
||||
'--from-id' => $firstId,
|
||||
'--force' => true,
|
||||
]);
|
||||
expect($exit)->toBe(0);
|
||||
|
||||
$postMismatches = checkPartitionIntegrity($partition, '', AUTH_LOG_ROW_EXPR);
|
||||
expect($postMismatches)->toBe(0, 'Rebuild должен сохранить global chain для BYPASSRLS-таблицы');
|
||||
});
|
||||
|
||||
it('audit:rebuild-chain handles single-row partition (first row of tenant) корректно', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
DB::statement('SET app.current_tenant_id = '.$tenant->id);
|
||||
|
||||
DB::table('activity_log')->insert([
|
||||
'tenant_id' => $tenant->id, 'user_id' => null, 'deal_id' => 1,
|
||||
'event' => 'deal.solo', 'context' => null, 'created_at' => now(),
|
||||
]);
|
||||
|
||||
$partition = 'activity_log_y'.now()->format('Y').'_m'.now()->format('m');
|
||||
$firstId = (int) DB::connection('pgsql_supplier')->table($partition)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->min('id');
|
||||
|
||||
$exit = Artisan::call('audit:rebuild-chain', [
|
||||
'--partition' => $partition,
|
||||
'--from-id' => $firstId,
|
||||
'--force' => true,
|
||||
]);
|
||||
expect($exit)->toBe(0);
|
||||
|
||||
$postMismatches = checkPartitionIntegrity($partition, 'PARTITION BY tenant_id', ACTIVITY_LOG_ROW_EXPR);
|
||||
expect($postMismatches)->toBe(0, 'Single-row per-tenant partition должен остаться intact');
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
// Tests for audit:verify-chains command — regression guard for Task 2 refactor.
|
||||
// Verifies that the command uses AuditChainConfig::TABLES (shared config)
|
||||
// and that AuditChainConfig::rowExpression() works for all registered tables.
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Console\Commands\VerifyAuditChains;
|
||||
use App\Services\Audit\AuditChainConfig;
|
||||
|
||||
/**
|
||||
* Regression tests for VerifyAuditChains → AuditChainConfig refactor (ADR-018 Task 2).
|
||||
*
|
||||
* These tests do NOT require a DB connection — they verify the static config
|
||||
* integrity used by both VerifyAuditChains and AuditRebuildChain.
|
||||
*/
|
||||
it('AuditChainConfig::TABLES registers all six expected audit tables', function (): void {
|
||||
$tables = array_keys(AuditChainConfig::TABLES);
|
||||
|
||||
expect($tables)->toContain('auth_log')
|
||||
->toContain('activity_log')
|
||||
->toContain('tenant_operations_log')
|
||||
->toContain('balance_transactions')
|
||||
->toContain('pd_processing_log')
|
||||
->toContain('saas_admin_audit_log');
|
||||
|
||||
expect(count($tables))->toBe(6);
|
||||
});
|
||||
|
||||
it('AuditChainConfig::rowExpression builds ROW expression with NULL::bytea at log_hash position', function (): void {
|
||||
$expr = AuditChainConfig::rowExpression('auth_log');
|
||||
|
||||
expect($expr)->toStartWith('ROW(')
|
||||
->toContain('NULL::bytea')
|
||||
->not->toContain('t.__log_hash__');
|
||||
});
|
||||
|
||||
it('AuditChainConfig::rowExpression produces same result for all six tables', function (): void {
|
||||
foreach (array_keys(AuditChainConfig::TABLES) as $table) {
|
||||
$expr = AuditChainConfig::rowExpression($table);
|
||||
|
||||
expect($expr)
|
||||
->toStartWith('ROW(')
|
||||
->toContain('NULL::bytea')
|
||||
->not->toContain('t.__log_hash__');
|
||||
}
|
||||
});
|
||||
|
||||
it('AuditChainConfig::rowExpression throws for unknown table', function (): void {
|
||||
AuditChainConfig::rowExpression('nonexistent_table');
|
||||
})->throws(InvalidArgumentException::class);
|
||||
|
||||
it('VerifyAuditChains command class exists and is registered', function (): void {
|
||||
expect(class_exists(VerifyAuditChains::class))->toBeTrue();
|
||||
});
|
||||
|
||||
it('VerifyAuditChains does not have private TABLE_CONFIG const after ADR-018 refactor', function (): void {
|
||||
$reflection = new ReflectionClass(VerifyAuditChains::class);
|
||||
$constants = $reflection->getReflectionConstants();
|
||||
$names = array_map(fn ($c) => $c->getName(), $constants);
|
||||
|
||||
// After Task 2 refactor, TABLE_CONFIG should be removed (delegated to AuditChainConfig::TABLES)
|
||||
expect($names)->not->toContain('TABLE_CONFIG');
|
||||
});
|
||||
@@ -116,3 +116,54 @@ it('dispatches SyncSupplierProjectJob on unfreeze when supplier mode is online',
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
|
||||
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $project->id);
|
||||
});
|
||||
|
||||
// Stage 3 / Task 3.2 — R-13 (spec §4.3.2): freeze/unfreeze sync paused_at on tenant projects.
|
||||
// SupplierSnapshotGuard блокирует delete/change_source когда paused_at свежее grace-периода.
|
||||
// Без этой синхронизации frozen-тенант остаётся «голым» для guard'а — клиент мог бы удалить
|
||||
// проект во время заморозки и пропустить хвост слепка поставщика.
|
||||
|
||||
it('sets paused_at on tenant projects without paused_at when freezing', function () {
|
||||
Mail::fake();
|
||||
// 500₽ / 50₽ = 10 лидов; проект хочет 25 → заморозка.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 25,
|
||||
'paused_at' => null,
|
||||
]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
$fresh = $project->fresh();
|
||||
expect($fresh->paused_at)->not->toBeNull();
|
||||
// freeze-moment должен совпадать с tenant.frozen_by_balance_at для последующего unfreeze-matcher'а.
|
||||
expect($fresh->paused_at->timestamp)->toBe($tenant->fresh()->frozen_by_balance_at->timestamp);
|
||||
});
|
||||
|
||||
it('clears paused_at on auto-paused projects when unfreezing, preserves manual pauses', function () {
|
||||
Mail::fake();
|
||||
// Frozen вчера в 12:00; пауза до этого момента = ручная, после = авто.
|
||||
$frozenAt = now()->subDay();
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '2000.00',
|
||||
'frozen_by_balance_at' => $frozenAt,
|
||||
]);
|
||||
// Auto-paused в момент freeze (timestamp == frozenAt → попадает в >= filter).
|
||||
$autoPaused = Project::factory()->for($tenant)->create([
|
||||
'is_active' => false,
|
||||
'daily_limit_target' => 5,
|
||||
'paused_at' => $frozenAt,
|
||||
]);
|
||||
// Manual-paused за 2 дня до freeze (timestamp < frozenAt → НЕ попадает в >= filter).
|
||||
$manualPaused = Project::factory()->for($tenant)->create([
|
||||
'is_active' => false,
|
||||
'daily_limit_target' => 5,
|
||||
'paused_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
|
||||
expect($autoPaused->fresh()->paused_at)->toBeNull();
|
||||
expect($manualPaused->fresh()->paused_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
@@ -168,3 +168,29 @@ it('writes supplier_lead_costs (gap-fix: Plan 2/3 не писали в sharing-f
|
||||
expect((int) $cost->supplier_id)->toBe($supplier->id);
|
||||
expect((string) $cost->cost_rub)->toBe($supplier->cost_rub);
|
||||
});
|
||||
|
||||
// Stage 3 / Task 3.1 — R-03 (spec §4.3.1): a frozen tenant must be rejected at
|
||||
// charge time even when balance_rub > 0. Guard is BEFORE bcmath arithmetic so
|
||||
// no balance / charges state is touched on rejection. The same auto-pause flow
|
||||
// kicks in (InsufficientBalanceException → RouteSupplierLeadJob handler flips
|
||||
// projects.is_active=false and queues ZeroBalancePausedMail rate-limited).
|
||||
it('throws InsufficientBalanceException when tenant frozen_by_balance_at is set', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '500.00',
|
||||
'frozen_by_balance_at' => now(),
|
||||
]);
|
||||
$deal = makeDealForTenant($tenant);
|
||||
|
||||
expect(function () use ($tenant, $deal) {
|
||||
DB::transaction(function () use ($tenant, $deal) {
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail();
|
||||
$this->ledger->chargeForDelivery($locked, $deal);
|
||||
});
|
||||
})->toThrow(InsufficientBalanceException::class);
|
||||
|
||||
// No side effects on frozen reject — balance and charges untouched.
|
||||
$tenant->refresh();
|
||||
expect((string) $tenant->balance_rub)->toBe('500.00');
|
||||
expect(LeadCharge::where('tenant_id', $tenant->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('sums daily_limit_target of active projects for required leads', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
@@ -24,3 +29,55 @@ it('casts project preflight_blocked_at to datetime', function () {
|
||||
$project = Project::factory()->create(['preflight_blocked_at' => now()]);
|
||||
expect($project->preflight_blocked_at)->toBeInstanceOf(Carbon::class);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 4 / Task 4.4 — R-19 (spec §4.4.3): share-aware requiredLeadsForTomorrow.
|
||||
// Before fix: simple SUM(daily_limit_target). Overcharges preflight when a tenant
|
||||
// shares a call/site signal with other tenants — supplier order is capped at
|
||||
// max(max(limits), ceil(Σ/3)) and split proportionally, so a single tenant's
|
||||
// share is typically much smaller than its raw limit.
|
||||
// Formula per project:
|
||||
// group_limits = limits of all is_active projects sharing the same
|
||||
// (signal_type, agnostic signal — phone/domain/sms-sender+keyword)
|
||||
// group_order = max(max(group_limits), ceil(Σ group_limits / 3))
|
||||
// tenant_share = ceil(group_order × (project_limit / Σ group_limits))
|
||||
// Legacy projects (signal_type=null — webhook-only, no supplier share) → full limit.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('R-19 single call project (no sharing) — returns full daily_limit_target', function () {
|
||||
$phone = '7919'.Str::random(7); // unique per run to dodge any pre-existing leakage
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
Project::factory()->for($tenant)->asCallSignal($phone)->create([
|
||||
'is_active' => true, 'daily_limit_target' => 10,
|
||||
]);
|
||||
// groupLimits = [10] (only this project) → sum=10, max=10, order=max(10, ceil(10/3))=10,
|
||||
// share = ceil(10 × 10/10) = 10. Same as legacy.
|
||||
expect($tenant->fresh()->requiredLeadsForTomorrow())->toBe(10);
|
||||
});
|
||||
|
||||
it('R-19 3 tenants sharing same call source — each tenant gets proportional share, not full limit', function () {
|
||||
$sharedPhone = '7929'.Str::random(7); // unique shared identifier per run
|
||||
// 3 tenants, same call source $sharedPhone, each daily_limit_target=10.
|
||||
// group_order = max(max([10,10,10]), ceil(30/3)) = max(10, 10) = 10.
|
||||
// share per tenant = ceil(10 × 10/30) = ceil(3.33) = 4.
|
||||
// Legacy formula would give 10 (4 vs 10 = the bug R-19 fixes).
|
||||
$tenants = [];
|
||||
foreach (range(1, 3) as $i) {
|
||||
$t = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
Project::factory()->for($t)->asCallSignal($sharedPhone)->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
]);
|
||||
$tenants[] = $t;
|
||||
}
|
||||
expect($tenants[0]->fresh()->requiredLeadsForTomorrow())->toBe(4);
|
||||
});
|
||||
|
||||
it('R-19 legacy webhook projects (signal_type=null) — still summed as full limit (no shared group)', function () {
|
||||
// Regression-protection for existing TenantPreflightTest behavior.
|
||||
// Webhook-only projects don't participate in supplier sharing — their full limit counts.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 10]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
|
||||
expect($tenant->fresh()->requiredLeadsForTomorrow())->toBe(25);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
|
||||
it('creates snapshot for given date from current live state', function () {
|
||||
Carbon::setTestNow('2026-05-27 14:00:00', 'Europe/Moscow');
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->asCallSignal('79161234567')->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
]);
|
||||
|
||||
$this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])
|
||||
->assertSuccessful();
|
||||
|
||||
expect(\DB::table('project_routing_snapshots')->where('snapshot_date', '2026-05-27')->count())->toBe(1);
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('is idempotent — does not duplicate on re-run', function () {
|
||||
Carbon::setTestNow('2026-05-27 14:00:00', 'Europe/Moscow');
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->asCallSignal('79161234567')->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
]);
|
||||
|
||||
$this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])->assertSuccessful();
|
||||
$this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])->assertSuccessful();
|
||||
|
||||
expect(\DB::table('project_routing_snapshots')->count())->toBe(1);
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
it('rebuilds snapshot for given date from current live (recovery after cron failure)', function (): void {
|
||||
Carbon::setTestNow('2026-05-29 09:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->asCallSignal('79161234567')->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
]);
|
||||
|
||||
$this->artisan('snapshot:rebuild', ['--date' => '2026-05-29'])->assertSuccessful();
|
||||
|
||||
expect(
|
||||
DB::table('project_routing_snapshots')
|
||||
->where('snapshot_date', '2026-05-29')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->count()
|
||||
)->toBe(1);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('replaces existing snapshot (NOT idempotent skip — full rebuild)', function (): void {
|
||||
Carbon::setTestNow('2026-05-29 09:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
$project = Project::factory()->for($tenant)->asCallSignal('79161234567')->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
]);
|
||||
|
||||
// Уже есть snapshot за 2026-05-29 со stale daily_limit=3.
|
||||
DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => '2026-05-29',
|
||||
'project_id' => $project->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'daily_limit' => 3, // stale
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => '{}',
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79161234567',
|
||||
'expected_volume' => 3,
|
||||
'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->artisan('snapshot:rebuild', ['--date' => '2026-05-29'])->assertSuccessful();
|
||||
|
||||
// После rebuild — daily_limit обновлён до live=10.
|
||||
$row = DB::table('project_routing_snapshots')
|
||||
->where('snapshot_date', '2026-05-29')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->first();
|
||||
expect((int) $row->daily_limit)->toBe(10);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
/**
|
||||
* Tests for reduced verbosity of QueryException logging when triggered by
|
||||
* a constraint violation (SQLSTATE 23xxx). After incident 2026-05-29, the
|
||||
* default Laravel error report (full stack trace) caused laravel.log to
|
||||
* accumulate 8.7 GB during a webhook storm. Constraint violations are
|
||||
* data-validity errors — they need a warning summary, not a stack trace.
|
||||
*
|
||||
* Ref: docs/incidents/2026-05-29-disk-full-pg-recovery.md §5
|
||||
*/
|
||||
it('logs constraint violation (SQLSTATE 23505) as WARNING with sqlstate code, no stack trace', function () {
|
||||
Log::spy();
|
||||
|
||||
Route::get('/_test/boom-23505', function () {
|
||||
$pdoException = new PDOException('SQLSTATE[23505]: Unique violation: duplicate key value violates unique constraint "uniq_user_email"');
|
||||
$pdoException->errorInfo = ['23505', 7, 'Unique violation'];
|
||||
|
||||
throw new QueryException('pgsql', 'INSERT INTO users ...', [], $pdoException);
|
||||
});
|
||||
|
||||
/* @phpstan-ignore-next-line method.notFound */
|
||||
$this->getJson('/_test/boom-23505');
|
||||
|
||||
// Constraint violation → warning channel, with sqlstate context
|
||||
/* @phpstan-ignore-next-line staticMethod.notFound */
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(function ($message, $context) {
|
||||
return $message === 'db.constraint_violation'
|
||||
&& ($context['sqlstate'] ?? '') === '23505';
|
||||
})
|
||||
->atLeast()->once();
|
||||
|
||||
// Default behaviour (full error log) is NOT called for constraint violations
|
||||
/* @phpstan-ignore-next-line staticMethod.notFound */
|
||||
Log::shouldNotHaveReceived('error', [
|
||||
Mockery::on(fn ($msg) => $msg === 'db.query_exception'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('still logs non-constraint QueryException (SQLSTATE 42P01) as ERROR with full SQL', function () {
|
||||
Log::spy();
|
||||
|
||||
Route::get('/_test/boom-42P01', function () {
|
||||
$pdoException = new PDOException('SQLSTATE[42P01]: relation "missing_table" does not exist');
|
||||
$pdoException->errorInfo = ['42P01', 7, 'Undefined table'];
|
||||
|
||||
throw new QueryException('pgsql', 'SELECT * FROM missing_table', [], $pdoException);
|
||||
});
|
||||
|
||||
/* @phpstan-ignore-next-line method.notFound */
|
||||
$this->getJson('/_test/boom-42P01');
|
||||
|
||||
// Non-constraint → default error logging preserved
|
||||
/* @phpstan-ignore-next-line staticMethod.notFound */
|
||||
Log::shouldHaveReceived('error')
|
||||
->withArgs(function ($message, $context) {
|
||||
return $message === 'db.query_exception'
|
||||
&& isset($context['sql']);
|
||||
})
|
||||
->atLeast()->once();
|
||||
});
|
||||
|
||||
it('logs constraint violation (SQLSTATE 23514) for check_constraint as WARNING', function () {
|
||||
Log::spy();
|
||||
|
||||
Route::get('/_test/boom-23514', function () {
|
||||
$pdoException = new PDOException('SQLSTATE[23514]: Check violation: new row for relation "supplier_projects" violates check constraint "chk_supplier_projects_b1_not_for_sms"');
|
||||
$pdoException->errorInfo = ['23514', 7, 'Check violation'];
|
||||
|
||||
throw new QueryException('pgsql', 'INSERT INTO supplier_projects ...', [], $pdoException);
|
||||
});
|
||||
|
||||
/* @phpstan-ignore-next-line method.notFound */
|
||||
$this->getJson('/_test/boom-23514');
|
||||
|
||||
/* @phpstan-ignore-next-line staticMethod.notFound */
|
||||
Log::shouldHaveReceived('warning')
|
||||
->withArgs(function ($message, $context) {
|
||||
return $message === 'db.constraint_violation'
|
||||
&& ($context['sqlstate'] ?? '') === '23514';
|
||||
})
|
||||
->atLeast()->once();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('ProjectResource includes applies_from as ISO8601 when set', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
$project->applies_from = CarbonImmutable::parse('2026-05-29 21:00:00', 'Europe/Moscow');
|
||||
|
||||
$resource = (new ProjectResource($project))->toArray(request());
|
||||
|
||||
expect($resource)->toHaveKey('applies_from');
|
||||
expect($resource['applies_from'])->toBe('2026-05-29T21:00:00+03:00');
|
||||
});
|
||||
|
||||
it('ProjectResource applies_from is null when not set', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
// applies_from не установлен
|
||||
|
||||
$resource = (new ProjectResource($project))->toArray(request());
|
||||
|
||||
expect($resource)->toHaveKey('applies_from');
|
||||
expect($resource['applies_from'])->toBeNull();
|
||||
});
|
||||
@@ -127,3 +127,21 @@ test('listPartitions возвращает созданные партиции',
|
||||
expect($partitions)->toContain('auth_log_y2024_m04')
|
||||
->toContain('auth_log_y2024_m05');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slepok routing: project_routing_snapshots (snapshot_date key, Этап 2, 27.05.2026)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('PARTITIONED_TABLES включает project_routing_snapshots с ключом snapshot_date', function (): void {
|
||||
expect(MonthlyPartitionManager::PARTITIONED_TABLES)
|
||||
->toHaveKey('project_routing_snapshots')
|
||||
->and(MonthlyPartitionManager::PARTITIONED_TABLES['project_routing_snapshots'])
|
||||
->toBe('snapshot_date');
|
||||
});
|
||||
|
||||
test('ensureMonth создаёт партицию project_routing_snapshots (snapshot_date)', function (): void {
|
||||
$manager = app(MonthlyPartitionManager::class);
|
||||
$manager->ensureMonth('project_routing_snapshots', Carbon::parse('2024-07-01'));
|
||||
|
||||
expect(partitionExists('project_routing_snapshots_y2024_m07'))->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
/**
|
||||
* Task 3 — plan 2026-05-29-supplier-webhook-fast-fail-and-stuck-cleanup.md
|
||||
*
|
||||
* Tests the single-lead-storm detection in incidents:watch-failures command.
|
||||
* A single supplier_lead_id generating >= threshold-single-lead failures within
|
||||
* the watch window should create a severity=high incident with root_cause
|
||||
* containing 'single-lead-storm'.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
// ---------- helpers --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Insert failed_webhook_jobs rows for a given supplier_lead_id.
|
||||
* Uses default DB::table() (pgsql connection) — same pattern as
|
||||
* IncidentsWatchFailuresExpandedTest's makeFailedWebhookJobExp().
|
||||
* SharesSupplierPdo ensures the command (pgsql_supplier) sees this data.
|
||||
*/
|
||||
function makeStormWebhookRows(int $supplierLeadId, int $count): void
|
||||
{
|
||||
$rows = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$rows[] = [
|
||||
'raw_payload' => json_encode(['supplier_lead_id' => $supplierLeadId]),
|
||||
'exception' => 'DomainException: B1 platform does not support SMS signals',
|
||||
'retry_count' => 3,
|
||||
'failed_at' => now()->subMinutes(rand(1, 9))->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
// Insert in chunks to stay under query size limits
|
||||
foreach (array_chunk($rows, 200) as $chunk) {
|
||||
DB::table('failed_webhook_jobs')->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure there is at least one active saas_admin_user (required by command).
|
||||
* Mirrors ensureAdminExp() pattern in IncidentsWatchFailuresExpandedTest.
|
||||
*/
|
||||
function ensureAdminForStormTest(): int
|
||||
{
|
||||
$id = DB::table('saas_admin_users')->where('is_active', true)->whereNull('deleted_at')->value('id');
|
||||
if ($id !== null) {
|
||||
return (int) $id;
|
||||
}
|
||||
|
||||
return (int) DB::table('saas_admin_users')->insertGetId([
|
||||
'email' => 'storm-watch-test@liderra.ru',
|
||||
'full_name' => 'Storm Watch Test Admin',
|
||||
'password_hash' => '$2y$12$placeholder',
|
||||
'role' => 'dev_oncall',
|
||||
'is_active' => true,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------- setup ----------------------------------------------------------
|
||||
|
||||
beforeEach(function (): void {
|
||||
ensureAdminForStormTest();
|
||||
// Clean only the tables the command reads/writes.
|
||||
// Do NOT delete saas_admin_users (may have FK refs from other tables).
|
||||
DB::table('failed_webhook_jobs')->delete();
|
||||
DB::table('incidents_log')->whereNull('resolved_at')->delete();
|
||||
});
|
||||
|
||||
// ---------- tests ----------------------------------------------------------
|
||||
|
||||
it('detects single-lead-storm when one supplier_lead_id has >= 1000 failures in window', function (): void {
|
||||
makeStormWebhookRows(9999, 1001);
|
||||
|
||||
$this->artisan('incidents:watch-failures', [
|
||||
'--threshold-single-lead' => 1000,
|
||||
'--window' => 10,
|
||||
'--threshold' => 99999, // disable generic webhook spike to isolate
|
||||
])->assertSuccessful();
|
||||
|
||||
$incident = DB::table('incidents_log')
|
||||
->where('root_cause', 'LIKE', '%single-lead-storm%')
|
||||
->first();
|
||||
|
||||
expect($incident)->not->toBeNull('should create incident for storm');
|
||||
expect($incident->severity)->toBe('high');
|
||||
expect($incident->root_cause)->toContain('9999');
|
||||
});
|
||||
|
||||
it('does NOT create storm incident when failures are spread across many leads', function (): void {
|
||||
// 100 different supplier_lead_ids × 5 failures = 500 total, none reaches threshold
|
||||
for ($i = 1; $i <= 100; $i++) {
|
||||
makeStormWebhookRows($i, 5);
|
||||
}
|
||||
|
||||
$this->artisan('incidents:watch-failures', [
|
||||
'--threshold-single-lead' => 1000,
|
||||
'--window' => 10,
|
||||
'--threshold' => 99999, // disable generic webhook spike
|
||||
])->assertSuccessful();
|
||||
|
||||
$stormIncidents = DB::table('incidents_log')
|
||||
->where('root_cause', 'LIKE', '%single-lead-storm%')
|
||||
->count();
|
||||
|
||||
expect($stormIncidents)->toBe(0, 'no storm when failures spread across leads');
|
||||
});
|
||||
|
||||
it('uses default threshold of 1000 when --threshold-single-lead is not provided', function (): void {
|
||||
makeStormWebhookRows(7777, 1001);
|
||||
|
||||
$this->artisan('incidents:watch-failures', [
|
||||
'--threshold' => 99999, // disable generic webhook spike
|
||||
])->assertSuccessful();
|
||||
|
||||
$incident = DB::table('incidents_log')
|
||||
->where('root_cause', 'LIKE', '%single-lead-storm%')
|
||||
->first();
|
||||
|
||||
expect($incident)->not->toBeNull('default threshold=1000 should detect 1001 failures');
|
||||
expect($incident->severity)->toBe('high');
|
||||
});
|
||||
|
||||
it('deduplicates: does not create duplicate storm incident within dedup window', function (): void {
|
||||
makeStormWebhookRows(8888, 1001);
|
||||
|
||||
// Run twice — should only create 1 incident (dedup window default 60 min)
|
||||
$this->artisan('incidents:watch-failures', [
|
||||
'--threshold-single-lead' => 1000,
|
||||
'--threshold' => 99999,
|
||||
])->assertSuccessful();
|
||||
|
||||
$this->artisan('incidents:watch-failures', [
|
||||
'--threshold-single-lead' => 1000,
|
||||
'--threshold' => 99999,
|
||||
])->assertSuccessful();
|
||||
|
||||
$count = DB::table('incidents_log')
|
||||
->where('root_cause', 'LIKE', '%single-lead-storm:8888%')
|
||||
->count();
|
||||
|
||||
expect($count)->toBe(1, 'dedup should prevent duplicate incident');
|
||||
});
|
||||
@@ -54,6 +54,7 @@ it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function ():
|
||||
// v8.26 (Plan 1-2): LeadRouter eligibility — через pivot project_supplier_links,
|
||||
// не legacy supplier_b1_project_id. Без pivot-связи проект не eligible → 0 сделок.
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project, null, 'site', 'vashinvestor.ru');
|
||||
}
|
||||
|
||||
// 4-й tenant — paused (is_active=false). Связь в pivot есть, чтобы проверялся
|
||||
@@ -67,6 +68,10 @@ it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function ():
|
||||
'is_active' => false,
|
||||
]);
|
||||
linkProjectToSupplier($pausedProject, $supplier);
|
||||
// NB: snapshot для paused-проекта НЕ создаём — SnapshotBackfillCommand в prod
|
||||
// фильтрует `WHERE p.is_active = true`. Это и есть Task 2.5 R-01 защита от
|
||||
// обратного case'а: paused after slepok всё равно НЕ получит лиды (потому что
|
||||
// snapshot fix'нут до его paused).
|
||||
|
||||
$vid = 432176649;
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
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 Carbon\Carbon;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
});
|
||||
|
||||
function runSnapshotRouteJob(int $supplierLeadId): void
|
||||
{
|
||||
(new RouteSupplierLeadJob($supplierLeadId))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
it('uses snapshot daily_limit, not live daily_limit_target (R-04/R-06)', function (): void {
|
||||
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 100, // live limit big
|
||||
'delivered_today' => 4,
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
$sp = SupplierProject::factory()->create(['signal_type' => 'site']);
|
||||
linkProjectToSupplier($project, $sp);
|
||||
// Snapshot имеет МАЛЕНЬКИЙ daily_limit=5 — после доставки 1 deal'a должно стать 5.
|
||||
createRoutingSnapshotFromProject(
|
||||
$project,
|
||||
date: '2026-05-28',
|
||||
signalType: 'site',
|
||||
signalIdentifier: $sp->unique_key,
|
||||
dailyLimit: 5,
|
||||
);
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'raw_payload' => ['project' => $sp->platform.'_'.$sp->unique_key, 'phones' => ['79161234567']],
|
||||
'phone' => '79161234567',
|
||||
'vid' => 1001,
|
||||
]);
|
||||
|
||||
runSnapshotRouteJob($lead->id);
|
||||
|
||||
expect(DB::table('deals')->where('tenant_id', $tenant->id)->count())->toBe(1);
|
||||
|
||||
// delivered_today инкрементнут на live, delivered_count на snapshot.
|
||||
expect((int) DB::table('projects')->where('id', $project->id)->value('delivered_today'))->toBe(5);
|
||||
|
||||
$snap = DB::table('project_routing_snapshots')
|
||||
->where('snapshot_date', '2026-05-28')
|
||||
->where('project_id', $project->id)
|
||||
->first();
|
||||
expect((int) $snap->delivered_count)->toBe(1);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('rejects lead when is_active becomes false under lock (R-09)', function (): void {
|
||||
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
|
||||
// Live state: is_active=true для snapshot:backfill, но потом клиент нажмёт «пауза»
|
||||
// (между matchEligibleProjects и handle). Имитируем это inline UPDATE'ом.
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 0,
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
$sp = SupplierProject::factory()->create(['signal_type' => 'site']);
|
||||
linkProjectToSupplier($project, $sp);
|
||||
// Snapshot НЕ paused (fixed до момента pause'а).
|
||||
createRoutingSnapshotFromProject(
|
||||
$project,
|
||||
date: '2026-05-28',
|
||||
signalType: 'site',
|
||||
signalIdentifier: $sp->unique_key,
|
||||
dailyLimit: 10,
|
||||
);
|
||||
|
||||
// ↓ Имитация: клиент paused проект в окне между matchEligible и handle.
|
||||
DB::table('projects')->where('id', $project->id)->update(['is_active' => false]);
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'raw_payload' => ['project' => $sp->platform.'_'.$sp->unique_key, 'phones' => ['79161234567']],
|
||||
'phone' => '79161234567',
|
||||
'vid' => 1002,
|
||||
]);
|
||||
|
||||
runSnapshotRouteJob($lead->id);
|
||||
|
||||
// Snapshot говорит «доставлять», но live is_active=false под lock'ом — НЕ доставляем.
|
||||
expect(DB::table('deals')->where('tenant_id', $tenant->id)->count())->toBe(0);
|
||||
expect((int) DB::table('projects')->where('id', $project->id)->value('delivered_today'))->toBe(0);
|
||||
|
||||
$snap = DB::table('project_routing_snapshots')
|
||||
->where('snapshot_date', '2026-05-28')
|
||||
->where('project_id', $project->id)
|
||||
->first();
|
||||
expect((int) $snap->delivered_count)->toBe(0);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -85,6 +85,7 @@ it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', funct
|
||||
'delivered_in_month' => 0,
|
||||
]));
|
||||
linkProjectToSupplier($projects->last(), $supplier);
|
||||
createRoutingSnapshotFromProject($projects->last());
|
||||
}
|
||||
|
||||
$vid = 432176649;
|
||||
@@ -140,6 +141,7 @@ it('charges balance_rub for tenant after routing', function (): void {
|
||||
'is_active' => true,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$vid = 99;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
@@ -208,6 +210,7 @@ it('same phone pre-existing does not suppress new delivery (Spec B)', function (
|
||||
'delivered_in_month' => 0,
|
||||
]));
|
||||
linkProjectToSupplier($projects->last(), $supplier);
|
||||
createRoutingSnapshotFromProject($projects->last());
|
||||
}
|
||||
|
||||
// Tenant #0 имеет pre-existing deal с тем же phone — под новым правилом НЕ подавляет.
|
||||
@@ -276,6 +279,7 @@ it('idempotent on retry — second handle() returns early, no ghost duplicate de
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$vid = 7777;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
@@ -345,6 +349,7 @@ it('handles partial failure: one project throws, others continue routing', funct
|
||||
'delivered_today' => 0,
|
||||
]));
|
||||
linkProjectToSupplier($projects->last(), $supplier);
|
||||
createRoutingSnapshotFromProject($projects->last());
|
||||
}
|
||||
|
||||
// Soft-delete tenant #1 — Tenant::firstOrFail() в createDealCopyForProject упадёт.
|
||||
@@ -389,6 +394,7 @@ it('routes B1 lead whose project name embeds a domain in free text (carmoney/car
|
||||
'is_active' => true,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$vid = random_int(100000, 999999);
|
||||
$lead = SupplierLead::factory()->create([
|
||||
@@ -508,6 +514,7 @@ it('caps deal creation at 3 recipients and tags deal with subject from payload',
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($p, $sp);
|
||||
createRoutingSnapshotFromProject($p);
|
||||
}
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
@@ -555,6 +562,7 @@ it('merges webhook into csv-recovered deal even when received_at differs (Phase
|
||||
'is_active' => true,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
// CSV-recovered deal: source_crm_id=NULL, received_at в прошлом.
|
||||
$csvReceivedAt = now()->subMinutes(15);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\SnapshotProjectRoutingJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// NB: Job не оборачивает свою INSERT в DB::transaction() — иначе под
|
||||
// DatabaseTransactions+SharesSupplierPdo (общий PDO между pgsql и pgsql_supplier)
|
||||
// PostgreSQL возвращает «There is already an active transaction». Атомарность
|
||||
// гарантирует INSERT ... ON CONFLICT DO NOTHING на уровне PG (идемпотентность).
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Carbon::setTestNow('2026-05-27 15:02:00', 'UTC'); // 18:02 MSK = 15:02 UTC
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('creates snapshot for tomorrow with active projects only', function () {
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
$active = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
'preflight_blocked_at' => null,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79161234567',
|
||||
]);
|
||||
$inactive = Project::factory()->for($tenant)->create([
|
||||
'is_active' => false,
|
||||
'delivery_days_mask' => 127,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79169999999',
|
||||
]);
|
||||
(new SnapshotProjectRoutingJob)->handle();
|
||||
$rows = \DB::table('project_routing_snapshots')
|
||||
->where('snapshot_date', '2026-05-28')->get();
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows->first()->project_id)->toBe($active->id);
|
||||
});
|
||||
|
||||
it('excludes frozen tenants', function () {
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
|
||||
Project::factory()->for($tenant)->create([
|
||||
'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10, 'signal_type' => 'call', 'signal_identifier' => '79161234567',
|
||||
]);
|
||||
(new SnapshotProjectRoutingJob)->handle();
|
||||
expect(\DB::table('project_routing_snapshots')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('excludes preflight_blocked projects', function () {
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->create([
|
||||
'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10, 'signal_type' => 'call', 'signal_identifier' => '79161234567',
|
||||
'preflight_blocked_at' => now(),
|
||||
]);
|
||||
(new SnapshotProjectRoutingJob)->handle();
|
||||
expect(\DB::table('project_routing_snapshots')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('excludes projects whose days_mask does not match tomorrow', function () {
|
||||
// 2026-05-28 — четверг (isoWeekday=4 → bit 1<<3 = 8). Тест: mask=4 (только среда).
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 4, // только среда
|
||||
'daily_limit_target' => 10,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79161234567',
|
||||
]);
|
||||
(new SnapshotProjectRoutingJob)->handle();
|
||||
expect(\DB::table('project_routing_snapshots')->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('uses effective_daily_limit_today as daily_limit when set (R-11/OPEN-5 variant A)', function () {
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->create([
|
||||
'is_active' => true, 'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
'effective_daily_limit_today' => 3, // override
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79161234567',
|
||||
]);
|
||||
(new SnapshotProjectRoutingJob)->handle();
|
||||
$row = \DB::table('project_routing_snapshots')->first();
|
||||
expect($row->daily_limit)->toBe(3);
|
||||
});
|
||||
|
||||
it('is idempotent — second run does not duplicate', function () {
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->create([
|
||||
'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10, 'signal_type' => 'call', 'signal_identifier' => '79161234567',
|
||||
]);
|
||||
(new SnapshotProjectRoutingJob)->handle();
|
||||
(new SnapshotProjectRoutingJob)->handle();
|
||||
expect(\DB::table('project_routing_snapshots')->count())->toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Helper: вставка snapshot за tomorrow MSK.
|
||||
*/
|
||||
function insertTomorrowSnapshot(
|
||||
Project $project,
|
||||
string $signalType = 'call',
|
||||
?string $signalIdentifier = '79161234567',
|
||||
int $dailyLimit = 10,
|
||||
int $deliveryDaysMask = 127,
|
||||
string $regions = '{}',
|
||||
): void {
|
||||
$tomorrow = Carbon::tomorrow('Europe/Moscow')->toDateString();
|
||||
DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => $tomorrow,
|
||||
'project_id' => $project->id,
|
||||
'tenant_id' => $project->tenant_id,
|
||||
'daily_limit' => $dailyLimit,
|
||||
'delivery_days_mask' => $deliveryDaysMask,
|
||||
'regions' => $regions,
|
||||
'signal_type' => $signalType,
|
||||
'signal_identifier' => $signalIdentifier,
|
||||
'sms_senders' => null,
|
||||
'sms_keyword' => null,
|
||||
'expected_volume' => $dailyLimit,
|
||||
'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('reads from snapshot for tomorrow, picks up live-paused project (race 18:02→18:05)', function (): void {
|
||||
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
// ↓ Клиент paus'нул проект между 18:02 (snapshot) и 18:05 (sync).
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => false, // live state — paused
|
||||
'daily_limit_target' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
insertTomorrowSnapshot($project);
|
||||
|
||||
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
|
||||
|
||||
// Изолируем от leftover dev data в pgsql_supplier (SharesSupplierPdo шарит PDO,
|
||||
// но не транзакции — данные между тестами остаются).
|
||||
$ours = $projects->where('id', $project->id);
|
||||
expect($ours)->toHaveCount(1);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('skips project that has NO snapshot for tomorrow (live is_active=true ignored)', function (): void {
|
||||
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true, // live state — active
|
||||
'daily_limit_target' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
// НЕТ snapshot за tomorrow — sync должен пропустить.
|
||||
|
||||
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
|
||||
|
||||
$ours = $projects->where('id', $project->id);
|
||||
expect($ours)->toHaveCount(0);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('overrides daily_limit_target / regions / delivery_days_mask with snapshot values', function (): void {
|
||||
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 100, // live = 100
|
||||
'delivery_days_mask' => 127, // live = mon-sun
|
||||
]);
|
||||
// Snapshot имеет другой лимит / маску.
|
||||
insertTomorrowSnapshot(
|
||||
$project,
|
||||
dailyLimit: 7, // snapshot = 7
|
||||
deliveryDaysMask: 31, // mon-fri only
|
||||
regions: '{77}', // только Москва
|
||||
);
|
||||
|
||||
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
|
||||
|
||||
$ours = $projects->where('id', $project->id);
|
||||
expect($ours)->toHaveCount(1);
|
||||
$p = $ours->first();
|
||||
expect((int) $p->daily_limit_target)->toBe(7);
|
||||
expect((int) $p->delivery_days_mask)->toBe(31);
|
||||
expect((array) $p->regions)->toBe([77]);
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('skips frozen tenants regardless of snapshot presence', function (): void {
|
||||
Carbon::setTestNow('2026-05-27 18:04:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => now()->subDay()]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
insertTomorrowSnapshot($project);
|
||||
|
||||
$projects = (new SyncSupplierProjectsJob)->collectEligibleProjects();
|
||||
|
||||
$ours = $projects->where('id', $project->id);
|
||||
expect($ours)->toHaveCount(0); // frozen tenant — sync должен пропустить
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\LeadRouter;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
// Clear tenant context — LeadRouter operates without it (sharing across tenants).
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
});
|
||||
|
||||
it('does not match project for tenant with only balance_leads (no balance_rub)', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '0.00',
|
||||
'balance_leads' => 999, // legacy — должно игнорироваться после фикса
|
||||
]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
$sp = SupplierProject::factory()->create();
|
||||
\DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(0); // balance_rub=0 → не eligible
|
||||
});
|
||||
|
||||
it('matches project for tenant with balance_rub > 0 (balance_leads ignored)', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '500.00',
|
||||
'balance_leads' => 0,
|
||||
]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
$sp = SupplierProject::factory()->create();
|
||||
\DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(1);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\LeadRouter;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow'); // pre-21:00 MSK window
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 1 — B-platform: frozen tenant must NOT receive leads (R-03 §4.3.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
it('does not match B-platform project for frozen tenant (frozen_by_balance_at IS NOT NULL)', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '500.00',
|
||||
'frozen_by_balance_at' => now(), // frozen — R-03
|
||||
]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
$sp = SupplierProject::factory()->create(['platform' => 'B1']);
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => '2026-05-28',
|
||||
'project_id' => $project->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'daily_limit' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => '{}',
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => null,
|
||||
'sms_keyword' => null,
|
||||
'expected_volume' => 10,
|
||||
'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(0); // R-03: frozen tenant must not receive leads
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 2 — DIRECT-platform: frozen tenant must NOT receive leads
|
||||
// ---------------------------------------------------------------------------
|
||||
it('does not match DIRECT-platform project for frozen tenant (frozen_by_balance_at IS NOT NULL)', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '500.00',
|
||||
'frozen_by_balance_at' => now(), // frozen — R-03
|
||||
]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
|
||||
// DIRECT supplier_project matches via signal_type + unique_key
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'DIRECT',
|
||||
'signal_type' => 'call',
|
||||
'unique_key' => 'direct-test-frozen-001',
|
||||
]);
|
||||
|
||||
// Snapshot must carry signal_type + signal_identifier matching sp->unique_key
|
||||
DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => '2026-05-28',
|
||||
'project_id' => $project->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'daily_limit' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => '{}',
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => 'direct-test-frozen-001', // matches sp->unique_key
|
||||
'sms_senders' => null,
|
||||
'sms_keyword' => null,
|
||||
'expected_volume' => 10,
|
||||
'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(0); // R-03: frozen tenant must not receive leads
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Case 3 (control) — B-platform, not frozen: MUST receive leads
|
||||
// ---------------------------------------------------------------------------
|
||||
it('matches B-platform project for non-frozen tenant (frozen_by_balance_at IS NULL)', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '500.00',
|
||||
'frozen_by_balance_at' => null, // NOT frozen — should match
|
||||
]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
$sp = SupplierProject::factory()->create(['platform' => 'B1']);
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => '2026-05-28',
|
||||
'project_id' => $project->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'daily_limit' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => '{}',
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => null,
|
||||
'sms_keyword' => null,
|
||||
'expected_volume' => 10,
|
||||
'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(1); // control: non-frozen tenant with balance IS eligible
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\LeadRouter;
|
||||
use Carbon\Carbon;
|
||||
|
||||
it('uses snapshot before 21:00 MSK, snapshot_date = today', function () {
|
||||
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => false, // ЖИВОЕ состояние — paused
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 100,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
$sp = SupplierProject::factory()->create();
|
||||
\DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform, 'subject_code' => null,
|
||||
]);
|
||||
// SNAPSHOT за сегодня имеет проект → роутер должен вернуть, несмотря на is_active=false
|
||||
\DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
|
||||
'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
|
||||
'signal_type' => 'call', 'expected_volume' => 10, 'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(1); // ← это R-01 closure
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('uses snapshot after 21:00 MSK, snapshot_date = tomorrow', function () {
|
||||
Carbon::setTestNow('2026-05-28 22:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true, 'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 100, 'delivered_today' => 0,
|
||||
]);
|
||||
$sp = SupplierProject::factory()->create();
|
||||
\DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform, 'subject_code' => null,
|
||||
]);
|
||||
// Snapshot за СЕГОДНЯ (2026-05-28) НЕТ.
|
||||
// Snapshot за ЗАВТРА (2026-05-29) есть.
|
||||
\DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => '2026-05-29', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
|
||||
'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
|
||||
'signal_type' => 'call', 'expected_volume' => 10, 'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(1); // после 21:00 МСК активен завтрашний snapshot
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('returns 0 if no snapshot exists for active date', function () {
|
||||
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00']);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true, 'delivery_days_mask' => 127, 'daily_limit_target' => 10,
|
||||
]);
|
||||
$sp = SupplierProject::factory()->create();
|
||||
\DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform, 'subject_code' => null,
|
||||
]);
|
||||
// НЕТ snapshot за 2026-05-28.
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(0); // fail-loud, не fallback на live
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('limit comes from snapshot, not live projects.daily_limit_target', function () {
|
||||
Carbon::setTestNow('2026-05-28 12:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00']);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true, 'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 100, // живой лимит
|
||||
'delivered_today' => 7,
|
||||
]);
|
||||
$sp = SupplierProject::factory()->create();
|
||||
\DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform, 'subject_code' => null,
|
||||
]);
|
||||
\DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
|
||||
'daily_limit' => 5, // ← snapshot лимит МЕНЬШЕ чем delivered_today=7
|
||||
'delivery_days_mask' => 127, 'regions' => '{}',
|
||||
'signal_type' => 'call', 'expected_volume' => 5, 'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(0); // delivered_today=7 >= snapshot.daily_limit=5
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('has project_routing_snapshots table with composite PK', function () {
|
||||
expect(Schema::hasTable('project_routing_snapshots'))->toBeTrue();
|
||||
expect(Schema::hasColumns('project_routing_snapshots', [
|
||||
'snapshot_date', 'project_id', 'tenant_id',
|
||||
'daily_limit', 'delivery_days_mask', 'regions',
|
||||
'signal_type', 'signal_identifier', 'sms_senders', 'sms_keyword',
|
||||
'expected_volume', 'delivered_count', 'created_at',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects negative daily_limit / expected_volume / delivered_count', function () {
|
||||
DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => '2026-05-28',
|
||||
'project_id' => 1,
|
||||
'tenant_id' => 1,
|
||||
'daily_limit' => -1, // CHECK violation
|
||||
'delivery_days_mask' => 0,
|
||||
'regions' => '{}',
|
||||
'signal_type' => 'call',
|
||||
'expected_volume' => 0,
|
||||
'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
})->throws(\Illuminate\Database\QueryException::class);
|
||||
|
||||
it('enforces composite PK (snapshot_date, project_id)', function () {
|
||||
$tenant = \App\Models\Tenant::factory()->create();
|
||||
$project = \App\Models\Project::factory()->for($tenant)->create();
|
||||
DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
|
||||
'daily_limit' => 10, 'delivery_days_mask' => 127, 'regions' => '{}',
|
||||
'signal_type' => 'call', 'expected_volume' => 10, 'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Дубль — должен упасть на PK violation
|
||||
expect(fn () => DB::table('project_routing_snapshots')->insert([
|
||||
'snapshot_date' => '2026-05-28', 'project_id' => $project->id, 'tenant_id' => $tenant->id,
|
||||
'daily_limit' => 20, 'delivery_days_mask' => 127, 'regions' => '{}',
|
||||
'signal_type' => 'call', 'expected_volume' => 20, 'delivered_count' => 0,
|
||||
'created_at' => now(),
|
||||
]))->toThrow(\Illuminate\Database\QueryException::class);
|
||||
});
|
||||
@@ -86,6 +86,7 @@ it('writes pd_processing_log created (supplier) when deal created via RouteSuppl
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project, null, 'site', 'pd-test.ru');
|
||||
|
||||
$vid = 77741;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
|
||||
@@ -22,7 +22,7 @@ beforeEach(function (): void {
|
||||
// `linkProjectToSupplier` helper now lives in tests/Pest.php — single source.
|
||||
|
||||
it('returns project linked via pivot to the supplier_project', function (): void {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100, 'balance_rub' => '1000.00']);
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
@@ -32,6 +32,7 @@ it('returns project linked via pivot to the supplier_project', function (): void
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($project, $sp);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
@@ -62,13 +63,16 @@ it('excludes inactive project, project at limit, and zero-balance tenant', funct
|
||||
$t1 = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$inactive = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => false, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($inactive, $sp);
|
||||
createRoutingSnapshotFromProject($inactive);
|
||||
|
||||
$atLimit = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => true, 'daily_limit_target' => 5, 'delivered_today' => 5, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($atLimit, $sp);
|
||||
createRoutingSnapshotFromProject($atLimit);
|
||||
|
||||
$t0 = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => 0]);
|
||||
$broke = Project::factory()->create(['tenant_id' => $t0->id, 'is_active' => true, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($broke, $sp);
|
||||
createRoutingSnapshotFromProject($broke);
|
||||
|
||||
expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
|
||||
});
|
||||
@@ -84,6 +88,7 @@ it('skips paused project (is_active=false)', function (): void {
|
||||
'is_active' => false,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
|
||||
@@ -106,6 +111,7 @@ it('skips project where today is not in delivery_days_mask', function (): void {
|
||||
'delivery_days_mask' => $maskWithoutToday,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
|
||||
@@ -124,6 +130,7 @@ it('skips project where delivered_today >= effective_daily_limit_today', functio
|
||||
'delivered_today' => 5,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
|
||||
@@ -131,7 +138,7 @@ it('skips project where delivered_today >= effective_daily_limit_today', functio
|
||||
|
||||
it('falls back to daily_limit_target when effective_daily_limit_today is null', function (): void {
|
||||
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100, 'balance_rub' => '1000.00']);
|
||||
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
@@ -143,6 +150,7 @@ it('falls back to daily_limit_target when effective_daily_limit_today is null',
|
||||
'delivered_today' => 5,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project, dailyLimit: $project->daily_limit_target);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
expect($router->matchEligibleProjects($supplier))->toHaveCount(1);
|
||||
@@ -161,6 +169,7 @@ it('skips project where tenant has zero in BOTH balance_leads AND balance_rub (P
|
||||
'is_active' => true,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
|
||||
@@ -179,6 +188,7 @@ it('includes project when balance_leads=0 BUT balance_rub > 0 (Plan 4 dual-balan
|
||||
'is_active' => true,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
$eligible = $router->matchEligibleProjects($supplier);
|
||||
@@ -191,7 +201,7 @@ it('orders results by created_at ASC (deterministic, spec §6 step 4)', function
|
||||
|
||||
$projectsCreated = collect();
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100, 'balance_rub' => '1000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
@@ -200,6 +210,7 @@ it('orders results by created_at ASC (deterministic, spec §6 step 4)', function
|
||||
'created_at' => now()->subDays(3 - $i),
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
createRoutingSnapshotFromProject($project);
|
||||
$projectsCreated->push($project);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('returns applies_from when changing daily_limit_target before 18:00 MSK', function (): void {
|
||||
Carbon::setTestNow('2026-05-28 14:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create(['daily_limit_target' => 10]);
|
||||
$sp = SupplierProject::factory()->create();
|
||||
linkProjectToSupplier($project, $sp);
|
||||
|
||||
$result = app(ProjectService::class)->update($project, ['daily_limit_target' => 5]);
|
||||
|
||||
expect($result->applies_from)->toBeInstanceOf(CarbonImmutable::class);
|
||||
expect($result->applies_from->format('Y-m-d H:i'))->toBe('2026-05-28 21:00');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('returns applies_from = tomorrow 21:00 MSK when edit after 18:00 MSK', function (): void {
|
||||
Carbon::setTestNow('2026-05-28 19:30:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create(['daily_limit_target' => 10]);
|
||||
$sp = SupplierProject::factory()->create();
|
||||
linkProjectToSupplier($project, $sp);
|
||||
|
||||
$result = app(ProjectService::class)->update($project, ['daily_limit_target' => 7]);
|
||||
|
||||
expect($result->applies_from->format('Y-m-d H:i'))->toBe('2026-05-29 21:00');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('returns applies_from = null when only non-slepok fields changed (e.g. name)', function (): void {
|
||||
Carbon::setTestNow('2026-05-28 14:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
$sp = SupplierProject::factory()->create();
|
||||
linkProjectToSupplier($project, $sp);
|
||||
|
||||
$result = app(ProjectService::class)->update($project, ['name' => 'Renamed project']);
|
||||
|
||||
expect($result->applies_from)->toBeNull();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('returns applies_from = null when project has no supplier links', function (): void {
|
||||
Carbon::setTestNow('2026-05-28 14:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create(['daily_limit_target' => 10]);
|
||||
// нет linkProjectToSupplier — нет slepok-риска
|
||||
|
||||
$result = app(ProjectService::class)->update($project, ['daily_limit_target' => 5]);
|
||||
|
||||
expect($result->applies_from)->toBeNull();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Project\SupplierSnapshotGuard;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('returns N.21:00 MSK for edit before 18:00 MSK on slepok-sensitive field', function (): void {
|
||||
Carbon::setTestNow('2026-05-28 14:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
$sp = SupplierProject::factory()->create();
|
||||
linkProjectToSupplier($project, $sp);
|
||||
|
||||
$applies = (new SupplierSnapshotGuard)->appliesFrom($project, 'daily_limit_target');
|
||||
|
||||
expect($applies)->toBeInstanceOf(CarbonImmutable::class);
|
||||
expect($applies->format('Y-m-d H:i'))->toBe('2026-05-28 21:00');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('returns (N+1).21:00 MSK for edit after 18:00 MSK on slepok-sensitive field', function (): void {
|
||||
Carbon::setTestNow('2026-05-28 19:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
$sp = SupplierProject::factory()->create();
|
||||
linkProjectToSupplier($project, $sp);
|
||||
|
||||
$applies = (new SupplierSnapshotGuard)->appliesFrom($project, 'daily_limit_target');
|
||||
|
||||
expect($applies)->toBeInstanceOf(CarbonImmutable::class);
|
||||
expect($applies->format('Y-m-d H:i'))->toBe('2026-05-29 21:00');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('returns null for non-slepok-sensitive field (e.g. name)', function (): void {
|
||||
Carbon::setTestNow('2026-05-28 14:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
$sp = SupplierProject::factory()->create();
|
||||
linkProjectToSupplier($project, $sp);
|
||||
|
||||
$applies = (new SupplierSnapshotGuard)->appliesFrom($project, 'name');
|
||||
|
||||
expect($applies)->toBeNull();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('returns null when project has no pivot links to supplier (no slepok-risk)', function (): void {
|
||||
Carbon::setTestNow('2026-05-28 14:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
// НЕТ linkProjectToSupplier — проект не связан с поставщиком.
|
||||
|
||||
$applies = (new SupplierSnapshotGuard)->appliesFrom($project, 'daily_limit_target');
|
||||
|
||||
expect($applies)->toBeNull();
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
it('covers all 7 slepok-sensitive fields', function (string $field): void {
|
||||
Carbon::setTestNow('2026-05-28 14:00:00', 'Europe/Moscow');
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
$sp = SupplierProject::factory()->create();
|
||||
linkProjectToSupplier($project, $sp);
|
||||
|
||||
$applies = (new SupplierSnapshotGuard)->appliesFrom($project, $field);
|
||||
|
||||
expect($applies)->toBeInstanceOf(CarbonImmutable::class);
|
||||
expect($applies->format('Y-m-d H:i'))->toBe('2026-05-28 21:00');
|
||||
|
||||
Carbon::setTestNow();
|
||||
})->with([
|
||||
'is_active',
|
||||
'daily_limit_target',
|
||||
'delivery_days_mask',
|
||||
'regions',
|
||||
'signal_identifier',
|
||||
'sms_senders',
|
||||
'sms_keyword',
|
||||
]);
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
|
||||
it('does not mark inactive supplier_project that has pivot link to active project', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
// легаси FK НЕ заполнены (Plan 3+ архитектура):
|
||||
'supplier_b1_project_id' => null,
|
||||
'supplier_b2_project_id' => null,
|
||||
'supplier_b3_project_id' => null,
|
||||
]);
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'inactive_since' => null,
|
||||
]);
|
||||
\DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
|
||||
(new CleanupInactiveSupplierProjectsJob)->handle(app(\App\Services\Supplier\SupplierPortalClient::class));
|
||||
|
||||
expect($sp->fresh()->inactive_since)->toBeNull();
|
||||
});
|
||||
|
||||
it('marks supplier_project inactive when no pivot link exists', function () {
|
||||
$sp = SupplierProject::factory()->create(['inactive_since' => null]);
|
||||
// нет project_supplier_links
|
||||
|
||||
(new CleanupInactiveSupplierProjectsJob)->handle(app(\App\Services\Supplier\SupplierPortalClient::class));
|
||||
|
||||
expect($sp->fresh()->inactive_since)->not->toBeNull();
|
||||
});
|
||||
@@ -134,7 +134,7 @@ it('no missing leads — status=ok, no recovery, no alert', function (): void {
|
||||
expect((int) $log->matched_count)->toBe(10);
|
||||
expect((int) $log->recovered_count)->toBe(0);
|
||||
|
||||
Mail::assertNothingSent();
|
||||
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
@@ -197,7 +197,7 @@ it('1 missing of 100 (drift 1%) — recovery without alert', function (): void {
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect($log->status)->toBe('ok');
|
||||
expect((int) $log->recovered_count)->toBe(1);
|
||||
Mail::assertNothingSent();
|
||||
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
||||
});
|
||||
|
||||
it('dedup is keyed by (phone, project) — same phone on different project is NOT a duplicate', function (): void {
|
||||
@@ -296,7 +296,7 @@ it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows
|
||||
expect((float) $log->drift_ratio)->toBe(0.0);
|
||||
expect($log->status)->toBe('ok');
|
||||
|
||||
Mail::assertNothingSent();
|
||||
Mail::assertNotSent(CsvDriftAlertMail::class); // scoped — TenantBusinessDriftAlertMail may fire on leaked snapshots
|
||||
});
|
||||
|
||||
it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recovered=3, drift по реальным', function (): void {
|
||||
@@ -338,3 +338,78 @@ it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recover
|
||||
expect((float) $log->drift_ratio)->toBeGreaterThan(0.0);
|
||||
expect($log->status)->toBe('ok');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 4 / Task 4.5 — R-05 (spec §4.4.4): business-drift second pass.
|
||||
// After existing webhook-loss drift detection, CsvReconcileJob runs a second
|
||||
// pass on project_routing_snapshots: per (snapshot_date, tenant_id) groups
|
||||
// where (expected - delivered) / expected > 20% → TenantBusinessDriftAlertMail.
|
||||
// This is orthogonal to webhook-loss drift (R-05.1) — same lead can be:
|
||||
// - delivered & webhook OK (no alerts)
|
||||
// - delivered & webhook miss (R-05.1 CsvDriftAlertMail)
|
||||
// - not delivered at all (R-05.2 TenantBusinessDriftAlertMail — this task)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function insertSnapshotForTenant(int $tenantId, string $date, int $expected, int $delivered): void
|
||||
{
|
||||
$tenant = \App\Models\Tenant::find($tenantId) ?? \App\Models\Tenant::factory()->create();
|
||||
$project = \App\Models\Project::factory()
|
||||
->for($tenant)
|
||||
->asCallSignal('7977'.\Illuminate\Support\Str::random(7))
|
||||
->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => max($expected, 1),
|
||||
]);
|
||||
\Illuminate\Support\Facades\DB::connection('pgsql_supplier')
|
||||
->table('project_routing_snapshots')
|
||||
->insert([
|
||||
'snapshot_date' => $date,
|
||||
'project_id' => $project->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'daily_limit' => max($expected, 1),
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => '{}',
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => $project->signal_identifier,
|
||||
'sms_senders' => null,
|
||||
'sms_keyword' => null,
|
||||
'expected_volume' => $expected,
|
||||
'delivered_count' => $delivered,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('R-05 business-drift: tenant with shortfall > 20% → TenantBusinessDriftAlertMail sent', function (): void {
|
||||
$tenant = \App\Models\Tenant::factory()->create();
|
||||
// Yesterday's snapshot: expected 10, delivered 2 → shortfall 80% (>20% threshold).
|
||||
$yesterday = \Carbon\Carbon::yesterday('Europe/Moscow')->toDateString();
|
||||
insertSnapshotForTenant($tenant->id, $yesterday, 10, 2);
|
||||
|
||||
// Empty CSV — primary drift pass is trivially OK; we exercise only the second pass.
|
||||
fakeReportFlow(csvBody([]));
|
||||
runCsvReconcile();
|
||||
|
||||
Mail::assertSent(\App\Mail\TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
|
||||
return $mail->tenantId === $tenant->id
|
||||
&& $mail->expected === 10
|
||||
&& $mail->delivered === 2
|
||||
&& $mail->shortfallRatio >= 0.79
|
||||
&& $mail->shortfallRatio <= 0.81;
|
||||
});
|
||||
});
|
||||
|
||||
it('R-05 business-drift: tenant with shortfall <= 20% → NO TenantBusinessDriftAlertMail', function (): void {
|
||||
$tenant = \App\Models\Tenant::factory()->create();
|
||||
// Yesterday's snapshot: expected 10, delivered 9 → shortfall 10% (<=20% threshold).
|
||||
$yesterday = \Carbon\Carbon::yesterday('Europe/Moscow')->toDateString();
|
||||
insertSnapshotForTenant($tenant->id, $yesterday, 10, 9);
|
||||
|
||||
fakeReportFlow(csvBody([]));
|
||||
runCsvReconcile();
|
||||
|
||||
// Scoped assertion: prior-run leaked snapshots may fire mails for other tenants;
|
||||
// this test only owns one tenant, so assert no mail was sent for IT.
|
||||
Mail::assertNotSent(\App\Mail\TenantBusinessDriftAlertMail::class, function ($mail) use ($tenant) {
|
||||
return $mail->tenantId === $tenant->id;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,8 @@ it('RouteSupplierLeadJob delivers DIRECT lead to matching project via signal_ide
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
createRoutingSnapshotFromProject($project, signalType: 'site', signalIdentifier: 'client.carmoney.ru');
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'platform' => 'DIRECT',
|
||||
'phone' => '79991234567',
|
||||
|
||||
@@ -68,6 +68,7 @@ function prepareSharingFlow(int $tenantsCount, array $balances): array
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplierProject);
|
||||
createRoutingSnapshotFromProject($project, null, 'site', 'example.com', 10);
|
||||
$tenants[] = $tenant;
|
||||
$projects[] = $project;
|
||||
}
|
||||
|
||||
@@ -78,35 +78,35 @@ test('failed_webhook_jobs INSERT с tenant_id=NULL проходит под pgsql
|
||||
});
|
||||
|
||||
test("LeadRouter видит проекты всех tenant'ов под pgsql_supplier без SET LOCAL (WARN #2)", function (): void {
|
||||
// 3 tenant × 2 проекта = 6 проектов, все привязаны к одному supplier_project.
|
||||
// 6 tenant × 1 проект = 6 проектов, все привязаны к одному supplier_project.
|
||||
// БЕЗ SET LOCAL app.current_tenant_id (он уже '0' из beforeEach) — под обычной
|
||||
// ролью RLS отбросил бы всё; под pgsql_supplier (BYPASSRLS) видны все 6.
|
||||
// NB: LeadRouter возвращает DISTINCT ON (tenant_id) — один проект на тенанта,
|
||||
// поэтому используем 6 тенантов × 1 проект чтобы expectation «6» оставалась.
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'plan3-task3-warn2.example.com',
|
||||
]);
|
||||
|
||||
$tenants = Tenant::factory()->count(3)->create(['balance_leads' => 100]);
|
||||
$tenants = Tenant::factory()->count(6)->create(['balance_leads' => 100, 'balance_rub' => 500]);
|
||||
foreach ($tenants as $tenant) {
|
||||
for ($i = 0; $i < 2; $i++) {
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'plan3-task3-warn2.example.com',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $supplier->id,
|
||||
'platform' => $supplier->platform,
|
||||
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
|
||||
'subject_code' => $supplier->subject_code,
|
||||
]);
|
||||
}
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'plan3-task3-warn2.example.com',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $supplier->id,
|
||||
'platform' => $supplier->platform,
|
||||
'subject_code' => $supplier->subject_code,
|
||||
]);
|
||||
createRoutingSnapshotFromProject($project, null, 'site', 'plan3-task3-warn2.example.com', 10);
|
||||
}
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
|
||||
@@ -260,3 +260,42 @@ test('deriveName uses sms sender as fallback when tag is empty', function (): vo
|
||||
|
||||
expect($plan['planned'][0]['name'])->toBe('79001112222');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 4 / Task 4.1 — R-17 (spec §4.4.1): unified buildUniqueKey.
|
||||
// Before fix buildUniqueKey($p, 'B2') = sender+keyword while buildUniqueKey($p, 'B3')
|
||||
// = sender alone → orphan supplier_projects rows on rebalance (B2 row keyed under
|
||||
// sender+keyword, B3 row keyed under sender → can't be reconciled as same group).
|
||||
// After fix all platforms use buildUniqueKeyAgnostic = sender+keyword for SMS with
|
||||
// keyword (sender alone only when keyword is null/empty).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('R-17 commit creates SMS supplier_projects with UNIFORM unique_key=sender+keyword (no B3 divergence)', function (): void {
|
||||
Http::fake();
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$sender = '7903'.fake()->numerify('#######');
|
||||
$keyword = 'TASKR17_'.\Illuminate\Support\Str::random(5);
|
||||
|
||||
// SMS group with keyword: only B2 + B3 (no B1 — CHECK constraint chk_supplier_projects_b1_not_for_sms).
|
||||
// Content format: 'sender+keyword' for B2 (src='bl'), 'sender' for B3 (src='mt') — supplier portal convention.
|
||||
$importer = importerWithRows([
|
||||
['id' => '9101', 'src' => 'bl', 'type' => 'sms', 'content' => $sender.'+'.$keyword, 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => ['1','2','3','4','5']],
|
||||
['id' => '9102', 'src' => 'mt', 'type' => 'sms', 'content' => $sender, 'tag' => 'СМС', 'lim' => '5', 'status' => true, 'regions' => '', 'workdays' => ['1','2','3','4','5']],
|
||||
]);
|
||||
$plan = $importer->buildPlan($tenant->id);
|
||||
$importer->commit($plan, $tenant->id);
|
||||
|
||||
$expected = $sender.'+'.$keyword;
|
||||
|
||||
// Both B2 and B3 supplier_projects must share the SAME unique_key (= sender+keyword).
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('signal_type', 'sms')
|
||||
->whereIn('platform', ['B2', 'B3'])
|
||||
->where(function ($q) use ($expected, $sender) {
|
||||
$q->where('unique_key', $expected)->orWhere('unique_key', $sender);
|
||||
})
|
||||
->get();
|
||||
expect($sps)->toHaveCount(2);
|
||||
expect($sps->pluck('unique_key')->unique()->values()->all())->toBe([$expected]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\DeleteSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 4 / Task 4.2 — R-17 migration (spec §4.4.1): one-time artisan command
|
||||
// to clean up orphan supplier_projects rows created by the now-removed
|
||||
// buildUniqueKey divergence.
|
||||
//
|
||||
// Before R-17 fix: SMS projects with keyword produced two diverging unique_keys:
|
||||
// B2 row: unique_key='sender+keyword'
|
||||
// B3 row: unique_key='sender' (no keyword) — ORPHAN after unification
|
||||
//
|
||||
// After fix all platforms use unique_key='sender+keyword'. Existing orphans
|
||||
// (B3 rows keyed under sender alone) need migration:
|
||||
// - no sibling at 'sender+keyword' for same tenant → UPDATE row's unique_key
|
||||
// - has sibling → mark for deletion (dispatch DeleteSupplierProjectJob, which
|
||||
// also removes the donor from supplier portal + cascades pivot cleanup)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('R-17 migrate: orphan SMS row with no sibling → UPDATE unique_key to sender+keyword', function (): void {
|
||||
$sender = '7913'.fake()->numerify('#######');
|
||||
$keyword = 'KW'.Str::random(5);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->asSmsSignal([$sender], $keyword)->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 5,
|
||||
]);
|
||||
|
||||
// Pre-existing orphan: B3 supplier_project keyed under sender alone (legacy buildUniqueKey).
|
||||
$orphanId88001 = DB::connection('pgsql_supplier')->table('supplier_projects')->insertGetId([
|
||||
'platform' => 'B3',
|
||||
'signal_type' => 'sms',
|
||||
'unique_key' => $sender, // orphan key (no '+keyword')
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => '88001',
|
||||
'current_limit' => 5,
|
||||
'current_workdays' => json_encode([1, 2, 3, 4, 5]),
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $orphanId88001,
|
||||
'platform' => 'B3',
|
||||
]);
|
||||
|
||||
$exitCode = $this->artisan('supplier:rekey-orphans')->run();
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
// Orphan now has unified key.
|
||||
$sp = SupplierProject::on('pgsql_supplier')->where('supplier_external_id', '88001')->first();
|
||||
expect($sp)->not->toBeNull();
|
||||
expect($sp->unique_key)->toBe($sender.'+'.$keyword);
|
||||
});
|
||||
|
||||
it('R-17 migrate: orphan SMS row WITH sibling at sender+keyword → dispatch DeleteSupplierProjectJob for orphan', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$sender = '7923'.fake()->numerify('#######');
|
||||
$keyword = 'KW'.Str::random(5);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->asSmsSignal([$sender], $keyword)->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 5,
|
||||
]);
|
||||
|
||||
// Sibling B2 row at unified key.
|
||||
$siblingId = DB::connection('pgsql_supplier')->table('supplier_projects')->insertGetId([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'sms',
|
||||
'unique_key' => $sender.'+'.$keyword,
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => '88002',
|
||||
'current_limit' => 5,
|
||||
'current_workdays' => json_encode([1, 2, 3, 4, 5]),
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $siblingId,
|
||||
'platform' => 'B2',
|
||||
]);
|
||||
|
||||
// Orphan B3 row under sender alone.
|
||||
$orphanId = DB::connection('pgsql_supplier')->table('supplier_projects')->insertGetId([
|
||||
'platform' => 'B3',
|
||||
'signal_type' => 'sms',
|
||||
'unique_key' => $sender, // orphan
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => '88003',
|
||||
'current_limit' => 5,
|
||||
'current_workdays' => json_encode([1, 2, 3, 4, 5]),
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $orphanId,
|
||||
'platform' => 'B3',
|
||||
]);
|
||||
|
||||
$exitCode = $this->artisan('supplier:rekey-orphans')->run();
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
Queue::assertPushed(DeleteSupplierProjectJob::class, function ($job) use ($orphanId) {
|
||||
return in_array($orphanId, $job->supplierProjectIds, true);
|
||||
});
|
||||
});
|
||||
|
||||
it('R-17 migrate: --dry-run reports orphans without modifying anything', function (): void {
|
||||
Queue::fake();
|
||||
|
||||
$sender = '7933'.fake()->numerify('#######');
|
||||
$keyword = 'KW'.Str::random(5);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->asSmsSignal([$sender], $keyword)->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 5,
|
||||
]);
|
||||
|
||||
$dryOrphanId = DB::connection('pgsql_supplier')->table('supplier_projects')->insertGetId([
|
||||
'platform' => 'B3',
|
||||
'signal_type' => 'sms',
|
||||
'unique_key' => $sender, // orphan
|
||||
'subject_code' => null,
|
||||
'supplier_external_id' => '88004',
|
||||
'current_limit' => 5,
|
||||
'current_workdays' => json_encode([1, 2, 3, 4, 5]),
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
DB::connection('pgsql_supplier')->table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $dryOrphanId,
|
||||
'platform' => 'B3',
|
||||
]);
|
||||
|
||||
$exitCode = $this->artisan('supplier:rekey-orphans', ['--dry-run' => true])->run();
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
// Unchanged.
|
||||
$sp = SupplierProject::on('pgsql_supplier')->where('supplier_external_id', '88004')->first();
|
||||
expect($sp->unique_key)->toBe($sender);
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
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 Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
/**
|
||||
* Task 2 plan 2026-05-29-supplier-webhook-fast-fail-and-stuck-cleanup.md
|
||||
*
|
||||
* Tests the fast-fail guard in RouteSupplierLeadJob::handle():
|
||||
* if supplier_lead.error contains a terminal pattern ('does not support',
|
||||
* 'platform mismatch', 'no matching supplier_project') and processed_at IS NULL,
|
||||
* the job marks processed and exits without writing to failed_webhook_jobs.
|
||||
*
|
||||
* Correction 1/2: uses RouteSupplierLeadJob (not ProcessSupplierWebhookJob).
|
||||
* Correction 3: fast-fail inserted between the 2 existing idempotency guards
|
||||
* and parseProjectField call.
|
||||
*/
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
// ---------- helpers --------------------------------------------------------
|
||||
|
||||
function dispatchHandleSync(int $leadId): void
|
||||
{
|
||||
$job = new RouteSupplierLeadJob($leadId);
|
||||
$job->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
function countFailedWebhookJobs(): int
|
||||
{
|
||||
return (int) DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->count();
|
||||
}
|
||||
|
||||
// ---------- setup ----------------------------------------------------------
|
||||
|
||||
beforeEach(function (): void {
|
||||
// Ensure pgsql_supplier sees the same transaction via shared PDO.
|
||||
DB::connection('pgsql_supplier')->table('failed_webhook_jobs')->delete();
|
||||
// Create one shared SupplierProject so all tests in this file share it —
|
||||
// avoids unique constraint violations from repeated factory calls.
|
||||
$this->sharedProject = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'call',
|
||||
'unique_key' => 'fast-fail-test-'.uniqid(),
|
||||
]);
|
||||
});
|
||||
|
||||
// ---------- tests ----------------------------------------------------------
|
||||
|
||||
it('fast-fails when supplier_lead has terminal "does not support" error and processed_at IS NULL', function (): void {
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => $this->sharedProject->id,
|
||||
'platform' => 'B1',
|
||||
'error' => 'B1 platform does not support SMS signals (supplier limitation: chk_supplier_projects_b1_not_for_sms)',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
$beforeFails = countFailedWebhookJobs();
|
||||
|
||||
dispatchHandleSync($lead->id);
|
||||
|
||||
$afterFails = countFailedWebhookJobs();
|
||||
expect($afterFails)->toBe($beforeFails, 'fast-fail must not write to failed_webhook_jobs');
|
||||
|
||||
$fresh = $lead->fresh();
|
||||
expect($fresh?->processed_at)->not->toBeNull('fast-fail must mark processed_at');
|
||||
expect($fresh?->error)->toContain('[fast-failed by RouteSupplierLeadJob]');
|
||||
});
|
||||
|
||||
it('fast-fails when error contains "platform mismatch"', function (): void {
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => $this->sharedProject->id,
|
||||
'platform' => 'B2',
|
||||
'error' => 'Routing failed: platform mismatch for this lead type',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
$beforeFails = countFailedWebhookJobs();
|
||||
dispatchHandleSync($lead->id);
|
||||
|
||||
expect(countFailedWebhookJobs())->toBe($beforeFails);
|
||||
expect($lead->fresh()?->processed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('fast-fails when error contains "no matching supplier_project"', function (): void {
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => $this->sharedProject->id,
|
||||
'platform' => 'B3',
|
||||
'error' => 'no matching supplier_project found for identifier ваши_деньги',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
$beforeFails = countFailedWebhookJobs();
|
||||
dispatchHandleSync($lead->id);
|
||||
|
||||
expect(countFailedWebhookJobs())->toBe($beforeFails);
|
||||
expect($lead->fresh()?->processed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT fast-fail when lead error is null (normal new lead)', function (): void {
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => $this->sharedProject->id,
|
||||
'platform' => 'B1',
|
||||
'error' => null,
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
// Normal path will throw (no matching supplier_project in test env) — that's OK.
|
||||
// The important thing: no fast-fail terminal mark has been set on the lead.
|
||||
try {
|
||||
dispatchHandleSync($lead->id);
|
||||
} catch (Throwable) {
|
||||
// expected
|
||||
}
|
||||
|
||||
$fresh = $lead->fresh();
|
||||
$wasFastFailed = $fresh?->processed_at !== null
|
||||
&& str_contains($fresh?->error ?? '', '[fast-failed by RouteSupplierLeadJob]');
|
||||
expect($wasFastFailed)->toBeFalse('must not fast-fail a lead with no prior error');
|
||||
});
|
||||
|
||||
it('does NOT fast-fail when lead already has processed_at set (idempotency guard fires first)', function (): void {
|
||||
$processedAt = now()->subMinutes(5);
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => $this->sharedProject->id,
|
||||
'error' => 'B1 platform does not support SMS signals',
|
||||
'processed_at' => $processedAt,
|
||||
]);
|
||||
|
||||
// Should return early due to processed_at guard, not the fast-fail guard.
|
||||
dispatchHandleSync($lead->id);
|
||||
|
||||
// processed_at must remain unchanged (not overwritten by fast-fail)
|
||||
$fresh = $lead->fresh();
|
||||
expect($fresh?->processed_at?->toDateTimeString())
|
||||
->toBe($processedAt->toDateTimeString(), 'processed_at must not change when already set');
|
||||
// error must not get the fast-fail suffix
|
||||
expect($fresh?->error)->not->toContain('[fast-failed by RouteSupplierLeadJob]');
|
||||
});
|
||||
|
||||
it('does NOT fast-fail for transient connection errors not matching terminal patterns', function (): void {
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => $this->sharedProject->id,
|
||||
'platform' => 'B1',
|
||||
'error' => 'Connection refused to PostgreSQL at 127.0.0.1',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
try {
|
||||
dispatchHandleSync($lead->id);
|
||||
} catch (Throwable) {
|
||||
// expected — transient errors may rethrow
|
||||
}
|
||||
|
||||
$fresh = $lead->fresh();
|
||||
$wasFastFailed = $fresh?->processed_at !== null
|
||||
&& str_contains($fresh?->error ?? '', '[fast-failed by RouteSupplierLeadJob]');
|
||||
expect($wasFastFailed)->toBeFalse('transient errors must not trigger fast-fail');
|
||||
});
|
||||
@@ -17,10 +17,12 @@ uses(SharesSupplierPdo::class);
|
||||
|
||||
it('excludes projects of frozen tenants from supplier order', function () {
|
||||
$frozenTenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
|
||||
Project::factory()->for($frozenTenant)->create(['is_active' => true, 'daily_limit_target' => 50]);
|
||||
$frozenProject = Project::factory()->for($frozenTenant)->create(['is_active' => true, 'daily_limit_target' => 50]);
|
||||
insertSnapshotForTomorrow($frozenProject);
|
||||
|
||||
$activeTenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($activeTenant)->create(['is_active' => true, 'daily_limit_target' => 30]);
|
||||
$activeProject = Project::factory()->for($activeTenant)->create(['is_active' => true, 'daily_limit_target' => 30]);
|
||||
insertSnapshotForTomorrow($activeProject);
|
||||
|
||||
$eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects();
|
||||
|
||||
@@ -32,6 +34,7 @@ it('excludes projects of frozen tenants from supplier order', function () {
|
||||
it('excludes individually preflight-blocked projects', function () {
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
$okProject = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 30, 'preflight_blocked_at' => null]);
|
||||
insertSnapshotForTomorrow($okProject);
|
||||
$blocked = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 20, 'preflight_blocked_at' => now()]);
|
||||
|
||||
$eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects();
|
||||
|
||||
@@ -88,6 +88,10 @@ it('online create DIVIDES the limit across B1/B2/B3 so supplier total == project
|
||||
// The portal does NOT divide — each B-project honours its own limit independently.
|
||||
// Fix: split the limit so Σ per-platform == project limit (18 → 6/6/6).
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
||||
], now()->addHours(6));
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
@@ -136,6 +140,10 @@ it('online mode passes real workdays from delivery_days_mask (not hardcoded [1..
|
||||
// Regression: до фикса хардкодилось [1,2,3,4,5,6,7] независимо от delivery_days_mask.
|
||||
// delivery_days_mask=31 = 0b0011111 = Пн-Пт (ISO дни 1-5). Workdays поставщика должны быть [1,2,3,4,5].
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
||||
], now()->addHours(6));
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
@@ -185,6 +193,10 @@ it('online mode update-path: existing supplier_projects.current_workdays is refr
|
||||
// Regression: forceFill ранее не включал current_workdays — после первого create со
|
||||
// старым хардкод-[1..7] последующий ресинк не подтягивал реальные дни.
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
||||
], now()->addHours(6));
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
@@ -284,6 +296,10 @@ it('online mode re-creates donor on portal when its external_id no longer exists
|
||||
// external_id на портале (listProjects), и пересоздавать недостающих in-place
|
||||
// (НЕ удаляя записи — на них могут висеть лиды/списания).
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
||||
], now()->addHours(6));
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
@@ -525,6 +541,10 @@ it('online create: transient failure on one platform throws so the job retries (
|
||||
// platform is skipped for a TRANSIENT reason (not escalation/window-defer), throw so the
|
||||
// Laravel retry (backoff) re-runs and partial-set recovery fills the missing platform.
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
||||
], now()->addHours(6));
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
@@ -560,6 +580,10 @@ it('online create: escalation/window-defer of one platform does NOT throw (legit
|
||||
// with their own recovery (manual queue / nightly batch). Retrying would not help and
|
||||
// would only spam failed_jobs — so they must NOT trigger the retry throw.
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
// Re-put supplier session AFTER setTestNow override — beforeEach put it with TTL relative to Mon 10:00 МСК, which is expired at our future test time.
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'sess123', 'csrf' => 'csrf123', 'refreshed_at' => now()->toIso8601String(),
|
||||
], now()->addHours(6));
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
@@ -634,3 +658,33 @@ it('runs every projects query on the pgsql_supplier (BYPASSRLS) connection', fun
|
||||
expect($projectConnections)->not->toBeEmpty();
|
||||
expect(array_values(array_unique($projectConnections)))->toBe(['pgsql_supplier']);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stage 4 / Task 4.3 — R-18 (spec §4.4.2): fixed target_date in online sync.
|
||||
// Before fix: Carbon::tomorrow('Europe/Moscow')->isoWeekday() flipped target at
|
||||
// midnight (Thu 23:59 МСК → Fri; Fri 00:01 МСК → Sat). After fix: 21:00 МСК is
|
||||
// the slepok cut-off boundary, matching supplier's snapshot fix-point.
|
||||
// hour < 21 МСК → target = today + 1 day
|
||||
// hour >= 21 МСК → target = today + 2 days
|
||||
// 2026-05-25 = Mon (ISO 1), 2026-05-26 = Tue (ISO 2), 2026-05-27 = Wed (ISO 3).
|
||||
// Pure unit test via SyncSupplierProjectJob::targetWeekdayForNow() — bypasses
|
||||
// factory/DB quirks of full sync downstream-effect assertions.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('R-18 targetWeekdayForNow: hour < 21 МСК → target = today + 1 day (Mon 20:00 МСК → Tue ISO 2)', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-25 20:00:00', 'Europe/Moscow'));
|
||||
expect(SyncSupplierProjectJob::targetWeekdayForNow())->toBe(2); // Tue (ISO 2)
|
||||
});
|
||||
|
||||
it('R-18 targetWeekdayForNow: hour >= 21 МСК → target = today + 2 days (Mon 22:00 МСК → Wed ISO 3)', function (): void {
|
||||
// Discriminator: OLD code (Carbon::tomorrow) gives Tue (2); NEW code gives Wed (3).
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-25 22:00:00', 'Europe/Moscow'));
|
||||
expect(SyncSupplierProjectJob::targetWeekdayForNow())->toBe(3); // Wed (ISO 3)
|
||||
});
|
||||
|
||||
it('R-18 targetWeekdayForNow: no midnight flicker — Mon 22:00 and Tue 00:01 point to same Wed', function (): void {
|
||||
// OLD: Mon 22:00 → tomorrow=Tue (ISO 2); Tue 00:01 → tomorrow=Wed (ISO 3) — FLIPS at midnight.
|
||||
// NEW: Mon 22:00 → addDays(2)=Wed (ISO 3); Tue 00:01 → addDay=Wed (ISO 3) — CONSISTENT.
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-26 00:01:00', 'Europe/Moscow'));
|
||||
expect(SyncSupplierProjectJob::targetWeekdayForNow())->toBe(3); // Wed (ISO 3)
|
||||
});
|
||||
|
||||
@@ -63,6 +63,7 @@ test('single-group: regions=[82,83] site → merged regions tag=РФ → 3 suppl
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [82, 83],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project, regions: '{82,83}');
|
||||
|
||||
// One save (merged regions=[82,83] → tag='РФ') + one listProjects
|
||||
Http::fake([
|
||||
@@ -121,6 +122,7 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
@@ -162,7 +164,7 @@ test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 suppl
|
||||
test('order: 2 projects same source×subject → computeOrder([10,20])=20 split across B1/B2/B3 = 7/7/6', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
$project1 = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -171,8 +173,9 @@ test('order: 2 projects same source×subject → computeOrder([10,20])=20 split
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project1);
|
||||
|
||||
Project::factory()->create([
|
||||
$project2 = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -181,6 +184,7 @@ test('order: 2 projects same source×subject → computeOrder([10,20])=20 split
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project2);
|
||||
|
||||
// saveProjectMultiFlag called once (both projects share same group)
|
||||
Http::fake([
|
||||
@@ -216,7 +220,7 @@ test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner
|
||||
// The owner reported (and we verified live 2026-05-21): call limit 18 → 18/18/18 on the
|
||||
// portal = supplier could deliver up to 54. The portal does NOT divide. Fix splits 18 → 6/6/6.
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'call',
|
||||
@@ -225,6 +229,7 @@ test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'message' => '', 'id' => '4000'], 200),
|
||||
@@ -252,7 +257,7 @@ test('limit is DIVIDED across B1/B2/B3 so supplier total == project limit (owner
|
||||
test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'sms',
|
||||
@@ -263,6 +268,7 @@ test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', functi
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
@@ -293,7 +299,7 @@ test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', functi
|
||||
test('sms without keyword → platform B3 only (1 supplier_project)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'sms',
|
||||
@@ -304,6 +310,7 @@ test('sms without keyword → platform B3 only (1 supplier_project)', function (
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project, signalType: 'sms', signalIdentifier: null);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
@@ -335,7 +342,7 @@ test('sms without keyword → platform B3 only (1 supplier_project)', function (
|
||||
test('idempotent: repeat run with no changes → updateProject not duplicate', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -344,6 +351,7 @@ test('idempotent: repeat run with no changes → updateProject not duplicate', f
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
// First run: create
|
||||
Http::fake([
|
||||
@@ -395,7 +403,7 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow'));
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -404,6 +412,7 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
Http::fake();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
@@ -416,7 +425,7 @@ test('sticky auth error throws and sends critical alert email', function (): voi
|
||||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -425,6 +434,7 @@ test('sticky auth error throws and sends critical alert email', function (): voi
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401),
|
||||
@@ -443,7 +453,7 @@ test('aborts after 50 consecutive transient failures and sends alert', function
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
for ($i = 1; $i <= 60; $i++) {
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -452,6 +462,7 @@ test('aborts after 50 consecutive transient failures and sends alert', function
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
}
|
||||
|
||||
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);
|
||||
@@ -466,7 +477,7 @@ test('aborts after 50 consecutive transient failures and sends alert', function
|
||||
test('writes supplier_sync_log row for each successful action', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
@@ -475,6 +486,7 @@ test('writes supplier_sync_log row for each successful action', function (): voi
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
@@ -516,7 +528,7 @@ test('nightly: re-creates donor on portal when its external_id no longer exists
|
||||
// external_id in our DB → updateProject is a silent no-op → donor never re-created.
|
||||
// Nightly reconciler must detect missing donors (listProjects) and re-create in-place.
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'call',
|
||||
@@ -525,6 +537,7 @@ test('nightly: re-creates donor on portal when its external_id no longer exists
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
insertSnapshotForTomorrow($project);
|
||||
|
||||
foreach (['B1', 'B2', 'B3'] as $platform) {
|
||||
SupplierProject::on('pgsql_supplier')->forceCreate([
|
||||
|
||||
@@ -38,4 +38,11 @@ describe('DealsFilters', () => {
|
||||
});
|
||||
expect(w.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('поле поиска имеет доступное имя (label) для скринридера', () => {
|
||||
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
|
||||
const label = w.find('[data-testid="filter-search-phone"] label');
|
||||
expect(label.exists()).toBe(true);
|
||||
expect(label.text()).toContain('Поиск по телефону');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,4 +47,11 @@ describe('KanbanColumn.vue', () => {
|
||||
expect(wrapper.emitted('openDeal')).toBeTruthy();
|
||||
expect(wrapper.emitted('openDeal')?.[0]).toEqual([dealsForNew[0].id]);
|
||||
});
|
||||
|
||||
// Контраст column-total на ивори чинится в scoped CSS (var(--accent) → нейтральный #4a463f),
|
||||
// jsdom scoped-стили не вычисляет → числовую проверку контраста делает Pa11y. Здесь — структурный якорь.
|
||||
it('column-total отрисован для пустой колонки', () => {
|
||||
const wrapper = factory({ status, deals: [] });
|
||||
expect(wrapper.find('.column-total').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,4 +49,14 @@ describe('ProjectCard', () => {
|
||||
});
|
||||
expect(wrapper.text()).toContain('На паузе');
|
||||
});
|
||||
|
||||
it('чип типа сигнала — flat-вариант с классом signal-chip (a11y контраст)', () => {
|
||||
const wrapper = mount(ProjectCard, {
|
||||
global: { plugins: [vuetify] },
|
||||
props: { project: baseProject, selected: false },
|
||||
});
|
||||
const chip = wrapper.find('.signal-chip');
|
||||
expect(chip.exists()).toBe(true);
|
||||
expect(chip.classes()).toContain('v-chip--variant-flat');
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user