Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0269534e5 | |||
| 05938df4f2 | |||
| 42ebe2e7c6 | |||
| d8955f57e0 | |||
| 16105cae5c | |||
| 4e38309f6b | |||
| 55a1bc0534 | |||
| 90f1f7ec85 | |||
| 254fb525ec | |||
| a22402fe9d | |||
| 75f4604c92 | |||
| 00930a850e | |||
| ddf192c41e | |||
| 3b65daa5fd |
@@ -41,7 +41,7 @@ Symptom: `queue:work` стартует, через ~60 секунд процес
|
||||
|
||||
### Квирк 107 — `config:cache` не из-под `www-data` → 500 на всём портале (24.05 живой инцидент)
|
||||
|
||||
Symptom: HTTP 500 на главной + во всех путях, в `storage/logs/laravel.log` пусто или «file not found» для cache. Cause: PHP-FPM под `www-data` **не может прочитать** `bootstrap/cache/config.php` (напр. owner=root без доступа группе) → fallback на defaults → APP_KEY=NULL и DB=sqlite. **Критерий — читаемость www-data, а не строгий владелец:** штатный `ubuntu:www-data` mode `775` читаем группой и НЕ вызывает 500 (проверено 25.06: портал HTTP 200). Фикс при NOT_READABLE: `sudo -u www-data php artisan config:cache` (пере-кэш под www-data) либо `sudo chmod 775 bootstrap/cache/config.php` + группа www-data.
|
||||
Symptom: HTTP 500 на главной + во всех путях, в `storage/logs/laravel.log` пусто или «file not found» для cache. Cause: владелец `bootstrap/cache/config.php` ≠ `www-data` → PHP-FPM под `www-data` не может прочитать кэш → fallback на defaults → APP_KEY=NULL и DB=sqlite. Фикс: `sudo -u www-data php artisan config:cache`.
|
||||
|
||||
### Квирк 108 — NTFS junction для worktree node_modules
|
||||
|
||||
@@ -51,30 +51,22 @@ Symptom: HTTP 500 на главной + во всех путях, в `storage/lo
|
||||
|
||||
Каждая проверка — это одна SSH-команда + ожидаемый формат вывода + критерий зелёного. Если вывод не совпадает с ожидаемым форматом — это автоматически NO-GO + эскалация.
|
||||
|
||||
### П1 — `bootstrap/cache/config.php` читаемость www-data и свежесть (Квирк 107, самый важный)
|
||||
|
||||
**ВАЖНО (исправлено 25.06.2026):** реальный корень инцидента 24.05 — PHP-FPM под `www-data`
|
||||
**не смог ПРОЧИТАТЬ** cache-файл (был owner=root без доступа группе). Поэтому критерий —
|
||||
**читаемость www-data**, а НЕ строгое «владелец == www-data». redeploy.sh штатно оставляет
|
||||
config.php как `ubuntu:www-data` mode `775` — www-data читает его через группу, портал
|
||||
работает (HTTP 200). Прежняя строгая проверка владельца давала **ложный NO-GO** (квирк
|
||||
«лечили» зря несколько раз). Проверяем то, что реально важно: может ли www-data читать.
|
||||
### П1 — `bootstrap/cache/config.php` владелец и свежесть (Квирк 107, самый важный)
|
||||
|
||||
```bash
|
||||
ssh -o ConnectTimeout=10 liderra "sudo -u www-data test -r /var/www/liderra/app/bootstrap/cache/config.php && echo READABLE || echo NOT_READABLE; stat -c '%U:%G %a %Y' /var/www/liderra/app/bootstrap/cache/config.php 2>/dev/null; stat -c '%Y' /var/www/liderra/app/.env 2>/dev/null"
|
||||
ssh -o ConnectTimeout=10 liderra "stat -c '%U %Y' /var/www/liderra/app/bootstrap/cache/config.php 2>/dev/null; stat -c '%Y' /var/www/liderra/app/.env 2>/dev/null"
|
||||
```
|
||||
|
||||
Ожидаемый формат — 3 строки (1-я — вердикт читаемости, 2-я — владелец:группа режим mtime, 3-я — mtime .env):
|
||||
Ожидаемый формат — 2 строки:
|
||||
|
||||
```
|
||||
READABLE
|
||||
ubuntu:www-data 775 1234567890
|
||||
www-data 1234567890
|
||||
1234567880
|
||||
```
|
||||
|
||||
Зелёный = (1) `READABLE` (www-data может прочитать config.php) И (2) mtime config.php ≥ mtime .env.
|
||||
Зелёный = (1) владелец `www-data` И (2) mtime config.php ≥ mtime .env.
|
||||
|
||||
Красный = `NOT_READABLE` (www-data НЕ может прочитать — настоящий риск 500) ИЛИ mtime config.php < mtime .env (квирк 104 — stale cache) ИЛИ файл config.php отсутствует. Цитировать квирк 107 в reason. NB: владелец `ubuntu` сам по себе **НЕ** красный, если файл читаем группой www-data.
|
||||
Красный = владелец ≠ `www-data` ИЛИ mtime config.php < mtime .env ИЛИ файл config.php отсутствует. Цитировать квирк 107 в reason.
|
||||
|
||||
### П2 — `.env` line endings (квирк 105)
|
||||
|
||||
@@ -181,7 +173,7 @@ ssh liderra "cd /var/www/liderra/app && php artisan migrate:status 2>&1 | grep -
|
||||
=== PROD-DEPLOY-VALIDATOR RAPORT ===
|
||||
Brief: <из входных данных>
|
||||
Проверки:
|
||||
П1 config.php читаем www-data [GREEN / RED] — <вывод | причина>
|
||||
П1 config:cache владелец [GREEN / RED] — <вывод | причина>
|
||||
П2 .env line endings [GREEN / RED] — <вывод | причина>
|
||||
П3 свободное место [GREEN / RED] — <вывод | причина>
|
||||
П4 свежесть бэкапа БД [GREEN / RED] — <вывод | причина>
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
---
|
||||
name: reviewer-agent
|
||||
description: |
|
||||
Independent reviewer of routing decisions for Лидерра brain governance.
|
||||
Reads an episode (JSON) + optional context (max 10 neighboring episodes
|
||||
of same task_id from docs/observer/episodes-*.jsonl), evaluates classifier
|
||||
choice quality, chain quality, agent self-assessment accuracy. Returns
|
||||
structured JSON review.
|
||||
|
||||
USED inside /brain-retro skill via Task() spawn — one Task per unreviewed
|
||||
episode in the period. NEVER edits files. NEVER commits. NEVER touches
|
||||
nodes.yaml / episodes / нормативку.
|
||||
|
||||
Escalates to controller if episode is malformed or schema unknown.
|
||||
|
||||
Reviewer-agent is part of LLM-first router overhaul (see spec
|
||||
docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md
|
||||
§4.6 v2.1). Replaces direct Opus API call (v2.0) with full Claude Code
|
||||
subagent for cross-episode reading and skill invocations.
|
||||
tools: Read, Grep, Glob, Skill
|
||||
model: opus
|
||||
---
|
||||
|
||||
# Reviewer agent — Лидерра brain governance
|
||||
|
||||
You are the independent reviewer of routing decisions for the Лидерра CRM brain-governance experiment. Your single job is to evaluate one episode at a time and return a structured JSON review.
|
||||
|
||||
You DO NOT edit files. You DO NOT commit. You DO NOT modify the episode you are reviewing. You DO NOT make architectural decisions. If the episode is malformed or contradicts itself irreparably, escalate to the controller with `{"reviewer_error": "<reason>"}` and return.
|
||||
|
||||
## Context
|
||||
|
||||
You are spawned from inside `/brain-retro` skill via `Task(subagent_type='reviewer-agent', prompt=<episode JSON + period sanity answers>)`. Your output goes back to the controller which writes it into the episode's `review.*` fields.
|
||||
|
||||
Spec reference: `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md` §4.6.
|
||||
|
||||
## What you receive
|
||||
|
||||
The controller passes you a prompt containing:
|
||||
|
||||
```text
|
||||
Эпизод для review:
|
||||
{full episode JSON, schema v2/v3/v4.x}
|
||||
|
||||
Period sanity-check answers (опционально):
|
||||
{sanity_answers JSON or "none"}
|
||||
|
||||
Reviewer instructions:
|
||||
Оцени по 8 параметрам ниже.
|
||||
Return ONLY JSON, no prose.
|
||||
```
|
||||
|
||||
## What you can read additionally (context)
|
||||
|
||||
Use `Read`, `Grep`, `Glob` to fetch:
|
||||
|
||||
1. **Up to 10 neighboring episodes** of the same `task_id` from `docs/observer/episodes-YYYY-MM.jsonl`. Use Grep to find them by `task_id`. **HARD LIMIT: 10**. If more exist, take the 10 closest in time.
|
||||
2. **`docs/registry/nodes.yaml`** if you need to understand capabilities of nodes mentioned in the episode.
|
||||
3. **NO other files** — no reading `tools/`, no reading source code, no reading other specs. Stay focused.
|
||||
|
||||
## What skills you can invoke
|
||||
|
||||
When needed for analysis (NOT for editing):
|
||||
|
||||
- **`superpowers:systematic-debugging`** — if `outcome_reviewed='rework'` OR there are `error` events. Apply 3-hypothesis methodology to identify `error_root_cause`.
|
||||
- **`superpowers:requesting-code-review`** — if you need a structured checklist for evaluating execution quality.
|
||||
- **`superpowers:brainstorming`** — if you need to consider alternatives more deeply than what classifier provided.
|
||||
|
||||
Skills are tools for YOUR thinking. They don't change anything. After invocation, return back to evaluating the episode.
|
||||
|
||||
## What you evaluate (8 dimensions)
|
||||
|
||||
Return JSON with these exact keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"node_quality": "correct | wrong_node | overkill | underkill | disputable",
|
||||
"chain_quality": "correct | missing_step | extra_step | wrong_order | n/a",
|
||||
"gap_assessment": "acceptable | mistake_should_complete | mistake_should_not_start | n/a",
|
||||
"agent_self_assessment_accuracy": "accurate | over_confident | under_confident | no_self_assessment",
|
||||
"error_root_cause": "wrong_skill | wrong_tool | wrong_chain_order | external_failure | n/a",
|
||||
"alternative_better": "<node_id from alternatives_considered or null>",
|
||||
"outcome_reviewed": "success | soft_success | rework | blocked",
|
||||
"reasoning": "1-3 предложения объяснения. Конкретно, не общо."
|
||||
}
|
||||
```
|
||||
|
||||
### Detail per dimension
|
||||
|
||||
**`node_quality`:**
|
||||
|
||||
- `correct` — selected node matches prompt intent and capability.
|
||||
- `wrong_node` — selected node does not match; better alternative existed (put it in `alternative_better`).
|
||||
- `overkill` — node is more heavy than needed (e.g., systematic-debugging for typo fix).
|
||||
- `underkill` — node is too light (e.g., direct edit for security-sensitive area).
|
||||
- `disputable` — reasonable but not obviously best.
|
||||
|
||||
**`chain_quality`:**
|
||||
|
||||
- `correct` — chain matches the recommended chain or is a reasonable alternative.
|
||||
- `missing_step` — important step skipped (e.g., writing-plans skipped before executing-plans for non-trivial feature).
|
||||
- `extra_step` — unnecessary step added.
|
||||
- `wrong_order` — steps executed in wrong order.
|
||||
- `n/a` — single-node task, no chain.
|
||||
|
||||
**`gap_assessment`** (only if `chain_gaps[].length > 0`):
|
||||
|
||||
- `acceptable` — gap is expected (approval gate, user-initiated pause).
|
||||
- `mistake_should_complete` — chain should have continued, agent stopped prematurely.
|
||||
- `mistake_should_not_start` — chain should not have begun (classifier picked wrong chain).
|
||||
|
||||
**`agent_self_assessment_accuracy`:**
|
||||
|
||||
- Сравни `self_assessment.confidence_in_choice` с реальным `outcome_inferred`/`outcome_reviewed`.
|
||||
- `confidence ≥ 0.7 + outcome=rework` → `over_confident`.
|
||||
- `confidence ≤ 0.4 + outcome=success` → `under_confident`.
|
||||
- Соответствие → `accurate`.
|
||||
- `self_assessment_pending: true` → `no_self_assessment`.
|
||||
|
||||
**`error_root_cause`** (only if `events.error.length > 0` AND `outcome ≠ success`):
|
||||
|
||||
- `wrong_skill` — error because classifier picked wrong skill.
|
||||
- `wrong_tool` — error from tool within correct skill (e.g., Edit instead of MultiEdit on multi-occurrence).
|
||||
- `wrong_chain_order` — error from misordered chain steps.
|
||||
- `external_failure` — network/lock/race/API-down (not agent's fault).
|
||||
- `n/a` — no error or success outcome.
|
||||
|
||||
**`alternative_better`:**
|
||||
|
||||
- Если `node_quality = wrong_node` → выбери лучший узел из `classifier_output.alternatives_considered[].node`.
|
||||
- Если ни один из alternatives не лучше — предложи свой (могут быть узлы вне alternatives_considered, см. `docs/registry/nodes.yaml`).
|
||||
- Иначе → `null`.
|
||||
|
||||
**`outcome_reviewed`** (proxy — закрывает 19.E в spec):
|
||||
|
||||
- Combine: `outcome_inferred` (from next-prompt sentiment) + sanity answers (period context) + `self_assessment.confidence` vs actual.
|
||||
- `success` — task completed and user moved on positively.
|
||||
- `soft_success` — task completed but with caveats (corrections, partial).
|
||||
- `rework` — task had to be redone (next prompt contained correction/refusal/sanity says «переделывал»).
|
||||
- `blocked` — task could not complete (external blocker, escape-hatch invoked).
|
||||
|
||||
**`reasoning`:**
|
||||
|
||||
- 1-3 предложения объяснения твоего решения.
|
||||
- Конкретно: ссылайся на episode fields, not general principles.
|
||||
- Если использовал cross-episode context — упомяни.
|
||||
|
||||
## Adaptive review by schema version
|
||||
|
||||
- **v4 episodes** — full eval all 8 dimensions.
|
||||
- **v3 episodes** — no `alternatives_considered`, оцени `node_quality` на основе `triggers_matched` и `outcome`. `alternative_better` ставь null.
|
||||
- **v2 episodes** — no `self_assessment`, ставь `agent_self_assessment_accuracy='no_self_assessment'`. Остальное как обычно.
|
||||
- **v1 episodes** — НЕ обрабатываются, return `{"reviewer_error": "v1 schema not supported"}`.
|
||||
|
||||
## What you DON'T do
|
||||
|
||||
- Не редактируешь episode (controller сам пишет review.* поля по твоему JSON output).
|
||||
- Не правишь nodes.yaml.
|
||||
- Не правишь spec.
|
||||
- Не делаешь коммиты.
|
||||
- Не общаешься с пользователем — твой output идёт controller'у.
|
||||
- Не читаешь больше 10 соседних эпизодов (cost cap).
|
||||
- Не читаешь tools/* / source code — это вне scope review.
|
||||
|
||||
## Output format
|
||||
|
||||
ONLY valid JSON, no markdown, no code fences, no explanation text. Controller парсит твой output напрямую как JSON.
|
||||
|
||||
Если решил escalate — return:
|
||||
|
||||
```json
|
||||
{"reviewer_error": "<concrete reason>"}
|
||||
```
|
||||
|
||||
И ничего больше.
|
||||
|
||||
## Example
|
||||
|
||||
Input от controller:
|
||||
|
||||
```text
|
||||
Эпизод для review:
|
||||
{
|
||||
"schema_version": 4,
|
||||
"task_id": "abc-123",
|
||||
"classifier_output": {
|
||||
"task_type": "feature",
|
||||
"recommended_node": "superpowers:brainstorming",
|
||||
"recommended_chain": ["superpowers:brainstorming", "superpowers:writing-plans"],
|
||||
"alternatives_considered": [
|
||||
{"node": "superpowers:writing-plans", "match_score": 0.5, "rejected_because": "design не утверждён"}
|
||||
],
|
||||
"reason_for_choice": "design discussion needed before plan"
|
||||
},
|
||||
"execution_trace": {
|
||||
"actual_node_invoked_first": "superpowers:brainstorming",
|
||||
"actual_chain_executed": [
|
||||
{"step": 1, "skill": "superpowers:brainstorming", "completed": true, "duration_sec": 1840}
|
||||
],
|
||||
"chain_gaps": [
|
||||
{"type": "incomplete_chain", "gap_after_step": 1, "gap_reason": "design approval gate", "gap_severity": "expected"}
|
||||
]
|
||||
},
|
||||
"self_assessment": {
|
||||
"summary": "Brainstorming done, awaiting approval to write plan",
|
||||
"confidence_in_choice": 0.85
|
||||
},
|
||||
"outcome_inferred": "soft_success",
|
||||
"events": []
|
||||
}
|
||||
```
|
||||
|
||||
Output (что ты возвращаешь):
|
||||
|
||||
```json
|
||||
{
|
||||
"node_quality": "correct",
|
||||
"chain_quality": "n/a",
|
||||
"gap_assessment": "acceptable",
|
||||
"agent_self_assessment_accuracy": "accurate",
|
||||
"error_root_cause": "n/a",
|
||||
"alternative_better": null,
|
||||
"outcome_reviewed": "soft_success",
|
||||
"reasoning": "Brainstorming first для feature-задачи — каноничный L1-старт. Gap after step 1 ожидаем: дизайн нуждается в approval. Self-assessment confidence=0.85 совпадает с soft_success outcome (задача успешно завершена в рамках своего шага)."
|
||||
}
|
||||
```
|
||||
|
||||
## Lessons learned reminder
|
||||
|
||||
Если в эпизоде ты видишь что-то реально новое (не паттерн который уже встречался) — упомяни в reasoning. Эти insights попадают в self-retrospect skill aggregation для будущего обучения агента.
|
||||
|
||||
Но НЕ делай self-retrospect сам — это отдельный skill.
|
||||
+2
-194
@@ -65,116 +65,6 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-memory-coverage.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-tdd-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-branch-switch.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-verify-before-push.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-router-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "PowerShell",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-powershell-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-normative-content-rules.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-tdd-real-test-verifier.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-self-debrief-detector.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "AskUserQuestion",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/askuser-cosmetic-detector.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "mcp__.*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-mcp-classification.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Read",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-read-path-deny.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -195,41 +85,6 @@
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-verify-record.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Task",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-subagent-return-scanner.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
@@ -238,7 +93,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/observer-stop-hook.mjs",
|
||||
"timeout": 60
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -250,33 +105,6 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-coverage-verify.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-todowrite-skill-verifier.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/cost-stop-hook.mjs",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
@@ -285,27 +113,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-prehook.mjs",
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-prompt-injection.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-embedding-warmup.mjs",
|
||||
"timeout": 30
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: brain-retro
|
||||
description: Use каждые 1-2 недели OR при триггере sanity-check threshold (Phase 3 cadence, spec §4.7). Also fires on explicit «брейн-ретро» / «/brain-retro». Aggregates evidence from docs/observer/episodes-*.jsonl + notes/*.md, asks 3-4 sanity questions via AskUserQuestion (PII-filtered), spawns reviewer-agent subagent per unreviewed episode (Opus, fallback to tools/brain-retro-opus-reviewer.mjs on subagent crash), and proposes regulatory candidates. Read-only — never edits Tooling/Pravila/PSR_v1 automatically; only proposes.
|
||||
description: Use ONCE PER SPRINT (or by explicit user invocation "брейн-ретро") to aggregate evidence from docs/observer/episodes-*.jsonl + notes/*.md and propose regulatory candidates. Read-only — never edits Tooling/Pravila/PSR_v1 automatically; only proposes.
|
||||
---
|
||||
|
||||
# Brain Retro
|
||||
@@ -21,50 +21,16 @@ Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces can
|
||||
|
||||
## Procedure
|
||||
|
||||
> **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 + %).
|
||||
> 4. **GAP «рекомендован но выбран direct»** (per-node count + rework rate этого подмножества).
|
||||
> 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).
|
||||
>
|
||||
> Без этих 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.
|
||||
4. **Update read-counter**: run `node tools/observer-of-observer.mjs record`. This atomically bumps `docs/observer/.read-counter.json` `last_read_at` to now and increments `read_count_last_period`. (Side-effect — used by C3 observer-of-observer for 54-week self-prune detection.)
|
||||
5. **Run the deterministic analyzer**: `node tools/brain-retro-analyzer.mjs docs/observer/episodes-YYYY-MM.jsonl` (pass every monthly file in the period). It returns JSON with `episodeCount`, `observerErrorCount`, `tasks` (episodes grouped into tasks), `causalChains` (error→fix candidates) and `factorMatrix` (outcome distribution per factor). The analyzer deduplicates the routing-gate double-write and infers the true `outcome` of each episode from the next episode's `prompt_signal` — never trust the stored `outcome` (it is `unknown` at write time).
|
||||
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`.
|
||||
|
||||
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.
|
||||
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`.
|
||||
7. **Propose candidates** — clearly separated section «Candidates for owner review». Each candidate has rationale + suggested edit + rejection-option.
|
||||
8. **Save retro note**: `docs/observer/notes/YYYY-MM-DD-brain-retro.md` with full aggregation.
|
||||
8a. **Refresh STATUS.md**: `node tools/status-md-generator.mjs` — auto-rebuild dashboard so it reflects the just-finished retro (`Last /brain-retro: 0 day(s) ago`, current episode count, refreshed C1–C5 controller statuses, cost report from `~/.claude/runtime/cost-daily.json`). Without this, STATUS.md only updates on the next git commit.
|
||||
9. **[Phase 3] Self-retrospect trigger (spec §4.8)** — read `docs/observer/.self-retrospect-counter.json`. If `episodes_since_last >= 50`, propose to the user invoking `/self-retrospect` (opt-in skill at `.claude/skills/self-retrospect/`). Bump `episodes_since_last` by the period's episode count regardless.
|
||||
10. **Cost report** — read `~/.claude/runtime/cost-daily.json`; include classifier + self_assessment + reviewer cost totals for the period in the retro note.
|
||||
11. **Report to user**: high-signal summary including sanity highlights, reviewer outcome distribution, and any escalations.
|
||||
|
||||
<!-- markdownlint-enable MD029 MD032 -->
|
||||
8a. **Refresh STATUS.md**: `node tools/status-md-generator.mjs` — auto-rebuild dashboard so it reflects the just-finished retro (`Last /brain-retro: 0 day(s) ago`, current episode count, refreshed C1–C5 controller statuses). Without this, STATUS.md only updates on the next git commit.
|
||||
9. **Report to user**: high-signal summary.
|
||||
|
||||
## Output anatomy
|
||||
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
name: self-retrospect
|
||||
description: |
|
||||
Opt-in self-retrospect: один раз за период (по умолчанию ~50 эпизодов или
|
||||
«триггер от заказчика») контроллер прогоняется по своим эпизодам и
|
||||
отвечает на вопросы про собственные паттерны: где переоценил уверенность,
|
||||
где зря выбрал direct вместо навыка, где наоборот стоило выбрать direct
|
||||
но навык сработал лишним. Результат пишется как заметка в
|
||||
`docs/observer/notes/<YYYY-MM-DD>-self-retrospect.md`, НЕ как эпизод.
|
||||
|
||||
Triggers: явное «/self-retrospect» от заказчика, OR порог
|
||||
`docs/observer/.self-retrospect-counter.json:episodes_since_last >= 50`
|
||||
(контроллер видит порог в STATUS.md C5 и предлагает запуск).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md §4.8.
|
||||
tools: Read, Grep, Glob, AskUserQuestion, Write, Edit
|
||||
---
|
||||
|
||||
# self-retrospect — Phase 3 Task 19 stub
|
||||
|
||||
This is the **stub** for the opt-in self-retrospect skill (Phase 3 Task 19).
|
||||
The full procedure (read 50 episodes → answer 5-7 introspection questions
|
||||
via AskUserQuestion → write note → bump counter) is **wired in Phase 3 Task
|
||||
20** when the analyzer and STATUS.md generator surface the
|
||||
`episodes_since_last >= 50` threshold.
|
||||
|
||||
For now, when invoked:
|
||||
|
||||
1. Read `docs/observer/.self-retrospect-counter.json`.
|
||||
2. Read the last N episodes from `docs/observer/episodes-YYYY-MM.jsonl`
|
||||
(default N = `episodes_since_last`).
|
||||
3. Ask the user (via AskUserQuestion) 3-5 retrospective questions about
|
||||
own routing patterns over that window (template in `references/` —
|
||||
created in Task 20).
|
||||
4. Sanitize answers via `tools/observer-pii-filter.mjs` (`sanitize` export)
|
||||
before writing.
|
||||
5. Write `docs/observer/notes/YYYY-MM-DD-self-retrospect.md`.
|
||||
6. Reset counter: `episodes_since_last = 0`, `last_run_at = now`.
|
||||
|
||||
Until Task 20 wires steps 3 and the references template, invoking this
|
||||
skill should walk through steps 1-2 + 4-6 manually and ask the user the
|
||||
3-5 questions inline.
|
||||
@@ -3,8 +3,3 @@
|
||||
# break vitest module loading (SyntaxError: Invalid or unexpected token,
|
||||
# no file:line). See memory quirk #100 (2026-05-19).
|
||||
*.mjs text eol=lf
|
||||
|
||||
# Shell scripts must stay LF. CRLF breaks `set -euo pipefail` on the server
|
||||
# (`set: pipefail: invalid option name`) when scp'd from a Windows working tree —
|
||||
# деплой обрывается до рестарта. Инцидент 24.06.2026 (deploy/redeploy.sh).
|
||||
*.sh text eol=lf
|
||||
|
||||
Binary file not shown.
@@ -1,119 +0,0 @@
|
||||
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|deals:backfill-region-city --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)?|deals:backfill-region-city)( *)$'
|
||||
|
||||
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
|
||||
@@ -1,229 +0,0 @@
|
||||
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
|
||||
@@ -1,213 +0,0 @@
|
||||
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
|
||||
@@ -1,109 +0,0 @@
|
||||
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
|
||||
@@ -1,113 +0,0 @@
|
||||
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
|
||||
@@ -1,221 +0,0 @@
|
||||
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
|
||||
@@ -1,393 +0,0 @@
|
||||
name: Lead region — prod ops
|
||||
|
||||
# Самодостаточный launch-инструмент фичи lead-region-resolution.
|
||||
# Один воркфлоу, переключатель op. НЕ трогает deploy.yml / artisan-run.yml.
|
||||
#
|
||||
# op:
|
||||
# pre-migrate — пред-применить миграцию 2026_05_31_100000 через postgres
|
||||
# superuser (crm_app_user не член crm_migrator → обычный migrate
|
||||
# падает) + пометить применённой, чтобы deploy её пропустил.
|
||||
# set-env — записать DADATA-ключи (из secrets) + LEAD_REGION_RESOLVER_ENABLED
|
||||
# (input flag) в боевой .env, перекэшировать config, рестарт очереди.
|
||||
# fetch-rossvyaz — скачать файл/архив реестра (input url) на прод в /var/www/liderra/rossvyaz.
|
||||
# import — phone-ranges:import (input dry_run) под www-data (DDL-свап идёт
|
||||
# через pgsql_supplier = crm_supplier_worker, член crm_migrator).
|
||||
# smoke — phone-region:smoke --phone=<input phone> под www-data (нужны ключи).
|
||||
#
|
||||
# Secrets: LIDERRA_SSH_KEY, DADATA_API_KEY, DADATA_SECRET.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
op:
|
||||
description: 'Операция'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- pre-migrate
|
||||
- set-env
|
||||
- fetch-rossvyaz
|
||||
- fetch-via-runner
|
||||
- deliver-from-repo
|
||||
- import
|
||||
- smoke
|
||||
flag:
|
||||
description: 'set-env: LEAD_REGION_RESOLVER_ENABLED'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: choice
|
||||
options:
|
||||
- 'false'
|
||||
- 'true'
|
||||
url:
|
||||
description: 'fetch-rossvyaz: прямая ссылка на CSV/ZIP реестра Россвязи'
|
||||
required: false
|
||||
type: string
|
||||
dir:
|
||||
description: 'import: каталог с CSV на проде'
|
||||
required: false
|
||||
default: '/var/www/liderra/rossvyaz'
|
||||
type: string
|
||||
dry_run:
|
||||
description: 'import: только staging без swap'
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
phone:
|
||||
description: 'smoke: телефон'
|
||||
required: false
|
||||
default: '79161234567'
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
op:
|
||||
name: ${{ github.event.inputs.op }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: liderra-prod-deploy
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
LIDERRA_HOST: 111.88.246.137
|
||||
LIDERRA_USER: ubuntu
|
||||
APP_DIR: /var/www/liderra/app
|
||||
OP: ${{ github.event.inputs.op }}
|
||||
FLAG: ${{ github.event.inputs.flag }}
|
||||
URL: ${{ github.event.inputs.url }}
|
||||
DIR: ${{ github.event.inputs.dir }}
|
||||
DRY: ${{ github.event.inputs.dry_run }}
|
||||
PHONE: ${{ github.event.inputs.phone }}
|
||||
|
||||
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 "${LIDERRA_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||
|
||||
- name: Checkout repo (for deliver-from-repo)
|
||||
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: op=pre-migrate (superuser DDL + mark applied)
|
||||
if: ${{ github.event.inputs.op == 'pre-migrate' }}
|
||||
run: |
|
||||
SQL_B64=$(cat <<'SQLEOF' | base64 -w0
|
||||
BEGIN;
|
||||
-- 1. phone_ranges_imports (FK target — создаём первым)
|
||||
CREATE TABLE phone_ranges_imports (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
imported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source_url TEXT NOT NULL,
|
||||
rows_inserted INTEGER NOT NULL DEFAULT 0,
|
||||
rows_updated INTEGER NOT NULL DEFAULT 0,
|
||||
checksum_sha256 TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||
CHECK (status IN ('in_progress','completed','failed','rolled_back')),
|
||||
error TEXT,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE phone_ranges_imports IS
|
||||
'Журнал импортов реестра Россвязи (idempotency по checksum_sha256, atomic-swap откат).';
|
||||
|
||||
-- 2. phone_ranges (реестр диапазонов; SaaS-level, без RLS — публичные данные)
|
||||
CREATE TABLE phone_ranges (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
def_code SMALLINT NOT NULL,
|
||||
from_num BIGINT NOT NULL,
|
||||
to_num BIGINT NOT NULL,
|
||||
operator TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
region_normalized TEXT,
|
||||
subject_code SMALLINT,
|
||||
imported_at TIMESTAMPTZ NOT NULL,
|
||||
import_id BIGINT NOT NULL REFERENCES phone_ranges_imports(id),
|
||||
CONSTRAINT chk_phone_ranges_def_code CHECK (def_code BETWEEN 300 AND 999),
|
||||
CONSTRAINT chk_phone_ranges_subject_code CHECK (subject_code IS NULL OR subject_code BETWEEN 1 AND 89),
|
||||
CONSTRAINT chk_phone_ranges_range_valid CHECK (from_num <= to_num)
|
||||
);
|
||||
CREATE INDEX idx_phone_ranges_lookup ON phone_ranges (def_code, from_num, to_num);
|
||||
COMMENT ON TABLE phone_ranges IS
|
||||
'Реестр диапазонов нумерации Россвязи (rossvyaz.gov.ru). Локальный fallback для LeadRegionResolver.';
|
||||
|
||||
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
|
||||
|
||||
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at)
|
||||
CREATE TABLE lead_region_resolution_log (
|
||||
id BIGSERIAL,
|
||||
supplier_lead_id BIGINT NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL,
|
||||
phone_masked TEXT NOT NULL,
|
||||
subject_code_resolved SMALLINT,
|
||||
subject_code_from_tag SMALLINT,
|
||||
region_source TEXT NOT NULL
|
||||
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
dadata_qc SMALLINT,
|
||||
dadata_provider TEXT,
|
||||
dadata_type TEXT,
|
||||
dadata_response_masked JSONB,
|
||||
rossvyaz_matched BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
actual_subject_code SMALLINT
|
||||
CHECK (actual_subject_code IS NULL OR actual_subject_code BETWEEN 1 AND 89),
|
||||
substituted_subject_code SMALLINT
|
||||
CHECK (substituted_subject_code IS NULL OR substituted_subject_code BETWEEN 1 AND 89),
|
||||
routing_step SMALLINT
|
||||
CHECK (routing_step IS NULL OR routing_step BETWEEN 1 AND 3),
|
||||
phone_operator TEXT,
|
||||
cache_hit BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
duration_ms INTEGER,
|
||||
resolved_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (id, received_at)
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
CREATE INDEX idx_lrrl_lead_id ON lead_region_resolution_log (supplier_lead_id);
|
||||
CREATE INDEX idx_lrrl_source ON lead_region_resolution_log (region_source, received_at);
|
||||
COMMENT ON TABLE lead_region_resolution_log IS
|
||||
'Аудит каждого резолва региона лида (источник, qc, оператор, шаг каскада). Партиции помесячно.';
|
||||
|
||||
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
|
||||
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
|
||||
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m05
|
||||
PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
|
||||
CREATE TABLE lead_region_resolution_log_y2026_m06
|
||||
PARTITION OF lead_region_resolution_log
|
||||
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
|
||||
|
||||
-- 4. supplier_leads: +4 колонки
|
||||
ALTER TABLE supplier_leads
|
||||
ADD COLUMN resolved_subject_code SMALLINT
|
||||
CHECK (resolved_subject_code IS NULL OR resolved_subject_code BETWEEN 1 AND 89),
|
||||
ADD COLUMN region_source TEXT
|
||||
CHECK (region_source IN ('dadata','rossvyaz','tag','unknown')),
|
||||
ADD COLUMN dadata_qc SMALLINT,
|
||||
ADD COLUMN phone_operator TEXT;
|
||||
|
||||
-- 5. deals: +2 колонки
|
||||
ALTER TABLE deals
|
||||
ADD COLUMN phone_operator TEXT,
|
||||
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- ownership как у миграции (она шла бы под crm_migrator)
|
||||
ALTER TABLE phone_ranges_imports OWNER TO crm_migrator;
|
||||
ALTER TABLE phone_ranges OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log_y2026_m05 OWNER TO crm_migrator;
|
||||
ALTER TABLE lead_region_resolution_log_y2026_m06 OWNER TO crm_migrator;
|
||||
|
||||
-- retention (system_settings, 12 мес)
|
||||
INSERT INTO system_settings (key, value, type, description, updated_at)
|
||||
SELECT 'partition_retention_months_lead_region_resolution_log', '12', 'int',
|
||||
'Retention в месяцах для lead_region_resolution_log (~365 дней)', NOW()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM system_settings
|
||||
WHERE key = 'partition_retention_months_lead_region_resolution_log');
|
||||
COMMIT;
|
||||
SQLEOF
|
||||
)
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" "SQL_B64='$SQL_B64' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
MIG_NAME='2026_05_31_100000_create_phone_ranges_and_resolution_log'
|
||||
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} уже применена — пропускаю."
|
||||
exit 0
|
||||
fi
|
||||
TABLE_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT 1 FROM information_schema.tables WHERE table_name='phone_ranges' LIMIT 1")
|
||||
if [ "${TABLE_EXISTS}" != "1" ]; then
|
||||
echo "Применяю lead-region DDL через postgres superuser..."
|
||||
echo "$SQL_B64" | base64 -d | sudo -u postgres psql -d liderra -v ON_ERROR_STOP=1
|
||||
else
|
||||
echo "Таблица phone_ranges уже существует — только помечаю миграцию."
|
||||
fi
|
||||
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 "Помечено ${MIG_NAME} применённой (batch ${NEXT_BATCH})."
|
||||
echo "=== Проверка таблиц ==="
|
||||
sudo -u postgres psql -d liderra -c "\dt phone_ranges|phone_ranges_imports|lead_region_resolution_log" || true
|
||||
REMOTE
|
||||
|
||||
- name: op=set-env (keys from secrets + flag → prod .env)
|
||||
if: ${{ github.event.inputs.op == 'set-env' }}
|
||||
env:
|
||||
DK: ${{ secrets.DADATA_API_KEY }}
|
||||
DS: ${{ secrets.DADATA_SECRET }}
|
||||
run: |
|
||||
DK_B64=$(printf '%s' "$DK" | base64 -w0)
|
||||
DS_B64=$(printf '%s' "$DS" | base64 -w0)
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"DK_B64='$DK_B64' DS_B64='$DS_B64' FLAG='$FLAG' APP_DIR='$APP_DIR' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
ENV="${APP_DIR}/.env"
|
||||
DK=$(echo "$DK_B64" | base64 -d)
|
||||
DS=$(echo "$DS_B64" | base64 -d)
|
||||
upsert() {
|
||||
local key="$1" val="$2"
|
||||
sudo sed -i "/^${key}=/d" "$ENV"
|
||||
echo "${key}=${val}" | sudo tee -a "$ENV" >/dev/null
|
||||
}
|
||||
upsert DADATA_API_KEY "$DK"
|
||||
upsert DADATA_SECRET "$DS"
|
||||
upsert LEAD_REGION_RESOLVER_ENABLED "$FLAG"
|
||||
cd "$APP_DIR"
|
||||
sudo -u www-data php artisan config:clear
|
||||
sudo -u www-data php artisan config:cache
|
||||
sudo systemctl restart liderra-queue
|
||||
echo "set-env готово: flag=${FLAG}, ключи записаны."
|
||||
echo "=== Проверка (значения скрыты) ==="
|
||||
sudo grep -E '^(DADATA_API_KEY|DADATA_SECRET|LEAD_REGION_RESOLVER_ENABLED)=' "$ENV" | sed -E 's/=(.).*/=\1***/'
|
||||
echo "=== queue status ==="
|
||||
systemctl is-active liderra-queue || true
|
||||
REMOTE
|
||||
|
||||
- name: op=fetch-rossvyaz (download registry on prod)
|
||||
if: ${{ github.event.inputs.op == 'fetch-rossvyaz' }}
|
||||
run: |
|
||||
# Пустой url → качаем все 4 официальных файла Минцифры за один прогон.
|
||||
# Непустой url → качаем только его (ручной режим).
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"URL='$URL' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -euo pipefail
|
||||
DEST=/var/www/liderra/rossvyaz
|
||||
sudo mkdir -p "$DEST"
|
||||
cd "$DEST"
|
||||
if [ -n "$URL" ]; then
|
||||
URLS="$URL"
|
||||
else
|
||||
URLS="https://opendata.digital.gov.ru/downloads/DEF-9xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-3xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-4xx.csv
|
||||
https://opendata.digital.gov.ru/downloads/ABC-8xx.csv"
|
||||
fi
|
||||
for U in $URLS; do
|
||||
FNAME=$(basename "${U%%\?*}")
|
||||
[ -n "$FNAME" ] || FNAME="rossvyaz-download"
|
||||
echo "Скачиваю $U -> $FNAME"
|
||||
sudo curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,application/octet-stream,*/*' -H 'Accept-Language: ru-RU,ru;q=0.9' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FNAME" "$U"
|
||||
case "$FNAME" in
|
||||
*.zip|*.ZIP) echo "Распаковываю zip..."; sudo unzip -o "$FNAME" ;;
|
||||
esac
|
||||
done
|
||||
sudo chown -R www-data:www-data "$DEST"
|
||||
echo "=== Содержимое $DEST ==="
|
||||
ls -lh "$DEST"
|
||||
FIRST_CSV=$(ls "$DEST"/DEF-9xx.csv "$DEST"/*.csv "$DEST"/*.CSV 2>/dev/null | head -1 || true)
|
||||
if [ -n "$FIRST_CSV" ]; then
|
||||
echo "=== Первые строки $FIRST_CSV (cp1251→utf8) ==="
|
||||
sudo head -3 "$FIRST_CSV" | iconv -f cp1251 -t utf-8 2>/dev/null || sudo head -3 "$FIRST_CSV"
|
||||
fi
|
||||
REMOTE
|
||||
|
||||
- name: op=fetch-via-runner (download on runner, ship to prod)
|
||||
if: ${{ github.event.inputs.op == 'fetch-via-runner' }}
|
||||
run: |
|
||||
mkdir -p /tmp/rv && cd /tmp/rv && rm -f /tmp/rv/*.csv
|
||||
for U in https://opendata.digital.gov.ru/downloads/DEF-9xx.csv https://opendata.digital.gov.ru/downloads/ABC-3xx.csv https://opendata.digital.gov.ru/downloads/ABC-4xx.csv https://opendata.digital.gov.ru/downloads/ABC-8xx.csv; do
|
||||
FN=$(basename "${U%%\?*}")
|
||||
echo "runner: скачиваю $U -> $FN"
|
||||
curl -fSL --retry 3 --retry-delay 2 -e 'https://opendata.digital.gov.ru/registry/numeric/downloads/' -H 'Accept: text/csv,application/csv,*/*' -A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' -o "$FN" "$U"
|
||||
done
|
||||
echo "=== скачано на runner ==="
|
||||
ls -lh /tmp/rv | tee /tmp/op.log
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*.csv'
|
||||
scp -i ~/.ssh/liderra_deploy /tmp/rv/*.csv "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'sudo mkdir -p /var/www/liderra/rossvyaz && sudo mv /tmp/rvup/*.csv /var/www/liderra/rossvyaz/ && sudo chown -R www-data:www-data /var/www/liderra/rossvyaz && echo "=== на проде /var/www/liderra/rossvyaz ===" && ls -lh /var/www/liderra/rossvyaz' | tee -a /tmp/op.log
|
||||
|
||||
- name: op=deliver-from-repo (scp repo CSV/ZIP to prod, unzip there)
|
||||
if: ${{ github.event.inputs.op == 'deliver-from-repo' }}
|
||||
run: |
|
||||
# Ищем файлы реестра где угодно (корень или папка), .csv или .zip
|
||||
mapfile -t FILES < <(find . -maxdepth 2 -type f \( \( -iname 'DEF-9xx*' -o -iname 'ABC-3xx*' -o -iname 'ABC-4xx*' -o -iname 'ABC-8xx*' \) -iname '*.csv' -o -iname '*.zip' \) ! -path './.git/*')
|
||||
if [ ${#FILES[@]} -eq 0 ]; then
|
||||
echo "::error::Не нашёл файлов реестра (DEF-9xx/ABC-*.csv|zip) ни в корне, ни в rossvyaz-data/. Проверь, что они закоммичены в репозиторий."; exit 1
|
||||
fi
|
||||
echo "=== файлы в репозитории (rossvyaz-data/) ==="
|
||||
ls -lh "${FILES[@]}" | tee /tmp/op.log
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" 'mkdir -p /tmp/rvup && rm -f /tmp/rvup/*'
|
||||
scp -i ~/.ssh/liderra_deploy "${FILES[@]}" "${LIDERRA_USER}@${LIDERRA_HOST}:/tmp/rvup/"
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" '
|
||||
cd /tmp/rvup
|
||||
for z in *.zip *.ZIP; do if [ -e "$z" ]; then echo "распаковываю $z"; unzip -o "$z"; rm -f "$z"; fi; done
|
||||
sudo mkdir -p /var/www/liderra/rossvyaz
|
||||
find . -iname "*.csv" -exec sudo mv {} /var/www/liderra/rossvyaz/ \;
|
||||
sudo chown -R www-data:www-data /var/www/liderra/rossvyaz
|
||||
echo "=== на проде /var/www/liderra/rossvyaz ==="
|
||||
ls -lh /var/www/liderra/rossvyaz
|
||||
' | tee -a /tmp/op.log
|
||||
|
||||
- name: op=import (phone-ranges:import)
|
||||
if: ${{ github.event.inputs.op == 'import' }}
|
||||
run: |
|
||||
DRY_FLAG=""
|
||||
if [ "${DRY}" = "true" ]; then DRY_FLAG="--dry-run"; fi
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -e
|
||||
cd "$APP_DIR"
|
||||
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ==="
|
||||
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG 2>&1
|
||||
echo "=== Счётчики ==="
|
||||
sudo -u postgres psql -d liderra -c "SELECT count(*) AS phone_ranges FROM phone_ranges" 2>&1 || true
|
||||
# staging-счётчик: 2 отдельных запроса, чтобы Postgres не парсил
|
||||
# подзапрос к phone_ranges_staging, когда таблица уже свапнута (иначе
|
||||
# ERROR relation "phone_ranges_staging" does not exist даже в ветке CASE).
|
||||
STAGING_EXISTS=$(sudo -u postgres psql -d liderra -tAc "SELECT to_regclass('phone_ranges_staging') IS NOT NULL")
|
||||
if [ "$STAGING_EXISTS" = "t" ]; then
|
||||
sudo -u postgres psql -d liderra -c "SELECT count(*) AS staging_rows FROM phone_ranges_staging" 2>&1 || true
|
||||
else
|
||||
echo "staging: отсутствует (после свапа — норма)"
|
||||
fi
|
||||
echo "=== Последний импорт ==="
|
||||
sudo -u postgres psql -d liderra -c \
|
||||
"SELECT id, status, rows_inserted, rows_updated, imported_at FROM phone_ranges_imports ORDER BY id DESC LIMIT 3" 2>&1 || true
|
||||
REMOTE
|
||||
|
||||
- name: op=smoke (phone-region:smoke)
|
||||
if: ${{ github.event.inputs.op == 'smoke' }}
|
||||
run: |
|
||||
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
|
||||
"APP_DIR='$APP_DIR' PHONE='$PHONE' bash -s" <<'REMOTE' | tee /tmp/op.log
|
||||
set -e
|
||||
cd "$APP_DIR"
|
||||
echo "=== phone-region:smoke --phone=${PHONE} ==="
|
||||
sudo -u www-data php artisan phone-region:smoke --phone="$PHONE" 2>&1
|
||||
REMOTE
|
||||
|
||||
- name: Print summary
|
||||
if: always()
|
||||
run: |
|
||||
{
|
||||
echo "## lead-region-ops: \`${OP}\`"
|
||||
echo
|
||||
echo '```'
|
||||
cat /tmp/op.log 2>/dev/null || echo "(нет вывода)"
|
||||
echo '```'
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/liderra_deploy
|
||||
@@ -1,96 +0,0 @@
|
||||
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
|
||||
@@ -1,192 +0,0 @@
|
||||
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
|
||||
@@ -1,167 +0,0 @@
|
||||
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
|
||||
@@ -1,208 +0,0 @@
|
||||
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
|
||||
@@ -1,104 +0,0 @@
|
||||
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
|
||||
@@ -1,136 +0,0 @@
|
||||
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
|
||||
@@ -1,117 +0,0 @@
|
||||
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
|
||||
@@ -1,111 +0,0 @@
|
||||
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
|
||||
-36
@@ -2,14 +2,6 @@
|
||||
# .gitignore — Лидерра
|
||||
# =============================================================================
|
||||
|
||||
# ── Session junk (broken PS paths from parallel Claude sessions, deploy tarballs, ad-hoc screenshots) ──
|
||||
CTemp*
|
||||
CWindowsTemp*
|
||||
phase[0-9]*-update.tar.gz
|
||||
recheck-*.png
|
||||
.tmp-*.sql
|
||||
tools/cloudflared.*
|
||||
|
||||
# ── Node / npm ──────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
@@ -47,16 +39,6 @@ demo-*.jpeg
|
||||
# gitleaks
|
||||
gitleaks-report.json
|
||||
|
||||
# ward (security-сканер) — отчёты в корне
|
||||
ward-report.*
|
||||
lychee-links-report.txt
|
||||
walk-*.png
|
||||
|
||||
# ZAP active scan — сырые отчёты (анализ коммитится как .md, сырьё локально:
|
||||
# может содержать снимки ответов dev-приложения)
|
||||
docs/security/*-zap-active-scan.json
|
||||
docs/security/*-zap-active-scan.html
|
||||
|
||||
# ── IDE / редакторы ─────────────────────────────────────────────────────────
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -139,7 +121,6 @@ c--Users-*/
|
||||
# ── Временные файлы ─────────────────────────────────────────────────────────
|
||||
*.tmp
|
||||
*.bak
|
||||
.mcp.json.bak-*
|
||||
*.log
|
||||
tmp/
|
||||
.tmp/
|
||||
@@ -162,12 +143,6 @@ 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/
|
||||
|
||||
@@ -213,14 +188,3 @@ ruflo-mcp-stderr.log
|
||||
.claude/commands/*
|
||||
!.claude/commands/security-review.md
|
||||
.claude/helpers/
|
||||
|
||||
# ── Локальные бэкапы settings.json + эталон-снимки (M7 canon backups, local-only) ──
|
||||
.claude/arh settings/
|
||||
.claude/settings - *.json
|
||||
.claude/settings эталон*.json
|
||||
.claude/эталон/
|
||||
.claude/scheduled_tasks.lock
|
||||
/settings.json
|
||||
settings copy.json
|
||||
# Строчный Ctemp-дамп (CTemp* выше не ловит из-за регистра)
|
||||
Ctemp*
|
||||
|
||||
+1
-19
@@ -89,37 +89,19 @@ paths = [
|
||||
'''app/tests/.*\.php''',
|
||||
# Database seeders с демо-данными (admin@demo.local + +7916123XXXX демо-телефоны)
|
||||
'''app/database/seeders/.*\.php''',
|
||||
# Database factories — генераторы тестовых фикстур (фейковые телефоны/ИНН,
|
||||
# напр. TenantFactory::withRequisites +79150000000), не реальные ПДн. Та же
|
||||
# категория, что seeders/tests.
|
||||
'''app/database/factories/.*\.php''',
|
||||
# Audit-internal docs (findings/blocked/report/plan) — содержат демо-телефоны и
|
||||
# script-смешанные artifacts как finding'и для review (не реальные ПДн)
|
||||
'''docs/superpowers/audits/.*\.md''',
|
||||
'''docs/superpowers/plans/.*\.md''',
|
||||
# Приёмочные ранбуки (R0–R5) — синтетические тест-телефоны (79990001122 и
|
||||
# пр.) в матрицах провижининга/инъекции, не реальные ПДн. Та же категория,
|
||||
# что plans/specs/audits.
|
||||
'''docs/superpowers/runbooks/.*\.md''',
|
||||
# Internal design specs — внутренние проектные доки с демо-данными (демо-телефоны
|
||||
# в примерах, напр. spec про log-PII-scrubbing), не реальные ПДн. Как plans/audits.
|
||||
'''docs/superpowers/specs/.*\.md''',
|
||||
# Mock-данные для UI-разводки фронтенда (фиктивные имена/телефоны)
|
||||
'''app/resources/js/composables/mockDeals\.ts''',
|
||||
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
|
||||
'''app/tests/Frontend/.*\.(spec|test)\.ts''',
|
||||
# Settings-вкладки с фиктивными mock-данными (профиль/сессии — UI-разводка)
|
||||
'''app/resources/js/views/settings/.*\.vue''',
|
||||
# Публичные реквизиты ПРОДАВЦА (ИП) — единый источник для футера/оферты/цен.
|
||||
# По требованию ЮKassa контакты продавца (телефон/почта) обязаны быть публично
|
||||
# на сайте; это не клиентские ПДн, а опубликованные бизнес-реквизиты.
|
||||
'''app/resources/js/constants/legal\.ts''',
|
||||
# Test fixtures for the observer PII filter — contains synthetic JWT / AWS /
|
||||
# Yandex tokens that the filter is supposed to redact. Not real secrets.
|
||||
'''tools/observer-pii-filter\.test\.mjs''',
|
||||
# Test fixture for the secret-scanner / read-path-deny (M5) — PEM-header marker +
|
||||
# AWS EXAMPLE key, used to verify detection. Not a real key; file deleted in brain split.
|
||||
'''tools/enforce-read-path-deny\.test\.mjs'''
|
||||
'''tools/observer-pii-filter\.test\.mjs'''
|
||||
]
|
||||
regexTarget = "match"
|
||||
regexes = [
|
||||
|
||||
@@ -39,13 +39,3 @@ a2f6714440c925e8ffdec8667373511dcce1b3aa:ПИЛОТ.md:ru-phone-unmasked:31
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:46
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:48
|
||||
16ac37aba9fdeb8a153e92e44ed42e1693377b58:docs/observer/episodes-2026-05.jsonl:ru-phone-unmasked:76
|
||||
|
||||
# 2026-05-26 — реальные RU-телефоны в ПИЛОТ.md и spec'ах от параллельных сессий
|
||||
# Дмитрия (33184985 / f48f79d2 / da4ab729 уже на origin/main — историю не переписать;
|
||||
# 6b2597ff / d2100a9b на ветке fix/supplier-platform-prefix, не в main lineage).
|
||||
# TODO: маскировать +7XXXXXXXXXX в новых коммитах ПИЛОТ.md / специов supplier-*.
|
||||
6b2597ff4ac2a34ed3d4d5a05c47318502b3f469:ПИЛОТ.md:ru-phone-unmasked:11
|
||||
d2100a9bab954296fa71dcfdb59568a1986e0dbe:docs/superpowers/specs/2026-05-26-supplier-platform-prefix-design.md:ru-phone-unmasked:18
|
||||
f48f79d2f333cd5acffb7751e6c3554d0807cb39:ПИЛОТ.md:ru-phone-unmasked:13
|
||||
33184985875ac8219464fd3d0f65b6740d587f50:ПИЛОТ.md:ru-phone-unmasked:11
|
||||
da4ab729df08ded7aa7d2523ef6c81efeacc1849:docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md:ru-phone-unmasked:34
|
||||
|
||||
+2
-9
@@ -28,12 +28,6 @@ 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,9 +48,8 @@ exclude = [
|
||||
# Sample/примерные адреса
|
||||
"^https?://example\\.com",
|
||||
"^https?://example\\.org",
|
||||
# Покойный GitHub-аккаунт CoralMinister (suspended) — все ссылки на него мертвы:
|
||||
# исторические compare/actions-runs в ПИЛОТ.md / handoffs / plans. Бэкап теперь Gitea.
|
||||
"^https?://github\\.com/CoralMinister/",
|
||||
# Приватный репозиторий проекта (404 для анонимных запросов — это норма)
|
||||
"^https?://github\\.com/CoralMinister/liderra",
|
||||
# web/v8/*.html — статические концепты, root-relative ссылки на будущие маршруты Vue
|
||||
"^/(login|register|legal|dashboard|deals|admin|reports|reminders|billing|impersonation|notifications)(/|$|\\?)",
|
||||
# Корневой `/` в концептах (логотип-якорь для будущей главной)
|
||||
|
||||
@@ -6,4 +6,3 @@ CLAUDE.md
|
||||
.claude/skills/ccpm/
|
||||
.claude/skills/data-scientist/
|
||||
.claude/skills/marketingskills/
|
||||
docs/superpowers/
|
||||
|
||||
@@ -54,32 +54,32 @@
|
||||
},
|
||||
"comment": "A3 integration-tooling #47 — OpenAPI MCP (ivo-toby/mcp-openapi-server, @ivotoby/openapi-mcp-server v1.14.0, MIT). Exposes Лидерра REST API endpoints (docs/api/openapi.yaml) as MCP tools. Config via env-vars API_BASE_URL + OPENAPI_SPEC_PATH (stdio transport default). READ scope: API discovery/introspection for Claude Code. Формализован в Tooling §4.22, PSR_v1 R10.1 блок 3, Pravila §13.2."
|
||||
},
|
||||
"perplexity": {
|
||||
"marketing-metrika": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@perplexity-ai/mcp-server"],
|
||||
"args": ["-y", "github:atomkraft/yandex-metrika-mcp"],
|
||||
"env": {
|
||||
"PERPLEXITY_API_KEY": "${PERPLEXITY_API_KEY}",
|
||||
"PERPLEXITY_BASE_URL": "https://api.aitunnel.ru/v1"
|
||||
"YANDEX_OAUTH_TOKEN": "${YANDEX_OAUTH_TOKEN}"
|
||||
},
|
||||
"comment": "research-tooling (Perplexity Pack) #87 — research-канал. Официальный @perplexity-ai/mcp-server (репо perplexityai/modelcontextprotocol), MIT, подписанная сборка. Tools: perplexity_search/ask/research/reason (sonar-*). ПЛАТНЫЙ API; ключ PERPLEXITY_API_KEY только в user env (не в репо). Вет ПРИНЯТ — docs/research/research-vet.md. Перенос plan-v13 2026-06-14 (owner waiver, Вариант 2)."
|
||||
"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."
|
||||
},
|
||||
"exa": {
|
||||
"marketing-wordstat": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "exa-mcp-server"],
|
||||
"args": ["-y", "github:SvechaPVL/yandex-mcp"],
|
||||
"env": {
|
||||
"EXA_API_KEY": "${EXA_API_KEY}"
|
||||
"YANDEX_OAUTH_TOKEN": "${YANDEX_OAUTH_TOKEN}"
|
||||
},
|
||||
"comment": "research-tooling (Perplexity Pack) #88 — Exa нейро/семантический поиск. exa-mcp-server (репо exa-labs), MIT (license-поле npm пусто — см. вет). Tools: web_search_exa / web_fetch_exa (default). ПЛАТНЫЙ API; ключ EXA_API_KEY только в user env. Вет ПРИНЯТ — docs/research/research-vet.md."
|
||||
"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."
|
||||
},
|
||||
"firecrawl": {
|
||||
"marketing-telegram": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "firecrawl-mcp"],
|
||||
"args": ["-y", "github:chigwell/telegram-mcp"],
|
||||
"env": {
|
||||
"FIRECRAWL_API_KEY": "${FIRECRAWL_API_KEY}"
|
||||
"TELEGRAM_API_ID": "${TELEGRAM_API_ID}",
|
||||
"TELEGRAM_API_HASH": "${TELEGRAM_API_HASH}",
|
||||
"TELEGRAM_SESSION_STRING": "${TELEGRAM_SESSION_STRING}"
|
||||
},
|
||||
"comment": "research-tooling (Perplexity Pack) #89 — Firecrawl глубокое чтение/обход. firecrawl-mcp (репо firecrawl/firecrawl-mcp-server), MIT, очень активен. Tools: scrape/crawl/extract + firecrawl_agent. ПЛАТНЫЙ API; ключ FIRECRAWL_API_KEY только в user env. Вет ПРИНЯТ — docs/research/research-vet.md."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
-69526
File diff suppressed because it is too large
Load Diff
-150000
File diff suppressed because it is too large
Load Diff
-142791
File diff suppressed because it is too large
Load Diff
-73783
File diff suppressed because it is too large
Load Diff
-16985
File diff suppressed because it is too large
Load Diff
@@ -42,18 +42,6 @@ SUPPLIER_PORTAL_URL=https://crm.bp-gr.ru
|
||||
# Supplier alerts (email через Unisender Go relay)
|
||||
SUPPLIER_ALERT_EMAIL=
|
||||
|
||||
# SaaS-admin fail-closed гейт (M-1). Логины nginx basic-auth (.htpasswd-admin),
|
||||
# допущенные в /api/admin/*. CSV; дефолт совпадает с прод-.htpasswd.
|
||||
ADMIN_ALLOWED_USERS=admin
|
||||
# ADMIN_GATE_ENFORCED=true # авто: true вне local/testing; задать явно для override
|
||||
# Системный admin-id для audit-trail (FK saas_admin_audit_log). На проде crm_app_user
|
||||
# не имеет прав на saas_admin_users → задать id сид-стаба. dev/test — оставить пустым.
|
||||
ADMIN_AUDIT_SYSTEM_USER_ID=
|
||||
|
||||
# Капча самозаписи (M-2). driver=null (dev) | yandex (prod). Для yandex нужен server-key.
|
||||
CAPTCHA_DRIVER=null
|
||||
YANDEX_SMARTCAPTCHA_SERVER_KEY=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
@@ -82,8 +70,6 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
SUPPORT_EMAIL=support@liderra.ru
|
||||
JIVO_WIDGET_ID=
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
@@ -92,7 +78,3 @@ AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Клиентский ключ Yandex SmartCaptcha (M-2). Пусто → fallback-чекбокс (dev).
|
||||
# На проде — клиентский ключ ysc1_… (для виджета на странице регистрации).
|
||||
VITE_YANDEX_SMARTCAPTCHA_SITEKEY=
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.env.testing
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.deptrac.cache
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Одноразовый бэкфилл: проставляет deals.city (имя субъекта) у уже существующих сделок,
|
||||
* у которых city ещё пуст, по resolved_subject_code связанного лида
|
||||
* (deals → supplier_lead_deliveries → supplier_leads). Идемпотентно (только city IS NULL).
|
||||
*
|
||||
* Запускается через .github/workflows/artisan-run.yml (mutating-whitelist, confirm_apply).
|
||||
* Парная правка для RouteSupplierLeadJob, который заполняет city у новых сделок.
|
||||
*/
|
||||
final class DealsBackfillRegionCityCommand extends Command
|
||||
{
|
||||
protected $signature = 'deals:backfill-region-city {--dry-run : Только посчитать, ничего не записывать}';
|
||||
|
||||
protected $description = 'Дозаполнить deals.city именем региона по resolved_subject_code лида (одноразовый бэкфилл)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
// BYPASSRLS-роль: бэкфилл идёт по всем тенантам без SET app.current_tenant_id.
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
$map = RussianRegions::CODE_TO_NAME;
|
||||
|
||||
$rows = $conn->table('deals')
|
||||
->join('supplier_lead_deliveries as dlv', 'dlv.deal_id', '=', 'deals.id')
|
||||
->join('supplier_leads as sl', 'sl.id', '=', 'dlv.supplier_lead_id')
|
||||
->whereNull('deals.city')
|
||||
->whereNotNull('sl.resolved_subject_code')
|
||||
->select('deals.id', 'deals.received_at', 'sl.resolved_subject_code')
|
||||
->get();
|
||||
|
||||
$seen = [];
|
||||
$updated = 0;
|
||||
foreach ($rows as $r) {
|
||||
$dealId = (int) $r->id;
|
||||
if (isset($seen[$dealId])) {
|
||||
continue; // у сделки несколько доставок — обрабатываем один раз
|
||||
}
|
||||
$seen[$dealId] = true;
|
||||
|
||||
$name = $map[(int) $r->resolved_subject_code] ?? null;
|
||||
if ($name === null) {
|
||||
continue; // код вне справочника 1..89 — пропускаем
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$conn->table('deals')
|
||||
->where('id', $dealId)
|
||||
->where('received_at', $r->received_at) // partition key
|
||||
->whereNull('city') // идемпотентный страж
|
||||
->update(['city' => $name]);
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$prefix = $dryRun ? '[dry-run] ' : '';
|
||||
$this->info("{$prefix}deals.city backfill: {$updated} обновлено из ".count($seen).' кандидатов.');
|
||||
Log::info('deals.backfill_region_city', [
|
||||
'updated' => $updated,
|
||||
'candidates' => count($seen),
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Imitation;
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Support\RussianRegions;
|
||||
use Carbon\Carbon;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Populate a LOCAL portal with imitation clients and leads for hands-on UI review
|
||||
* (Phase 1 imitation harness). It NEVER runs on production.
|
||||
*
|
||||
* Self-contained on purpose (it must not depend on test-harness helpers): it funds
|
||||
* a few tenant balances locally, disables the external DaData call (region is taken
|
||||
* from the lead tag), builds the routing snapshot for the active date, then injects
|
||||
* synthetic leads through the real RouteSupplierLeadJob so deals, charges and
|
||||
* notifications appear exactly as they would in production.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-06-03-portal-client-imitation-phase1-design.md
|
||||
*/
|
||||
final class ImitationSeedCommand extends Command
|
||||
{
|
||||
protected $signature = 'imitation:seed
|
||||
{--leads=20 : Number of synthetic leads to inject}
|
||||
{--clients=3 : Number of imitation clients to create}';
|
||||
|
||||
protected $description = 'Populate the LOCAL portal with imitation clients and leads for UI review (never on production)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if ($this->getLaravel()->environment('production')) {
|
||||
$this->error('imitation:seed is forbidden in production.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$leads = max(1, (int) $this->option('leads'));
|
||||
$clients = max(1, (int) $this->option('clients'));
|
||||
|
||||
// Region comes from the lead tag — no external (paid) DaData call.
|
||||
config(['services.dadata.enabled' => false]);
|
||||
|
||||
// Reference data required by the ledger.
|
||||
(new PricingTierSeeder)->run();
|
||||
|
||||
$moscow = RussianRegions::nameToCode()['Москва']; // ordinal 82
|
||||
|
||||
// One shared supplier source (B2 site signal). The unique_key must be a
|
||||
// domain-like string: RouteSupplierLeadJob re-resolves the supplier from the
|
||||
// lead payload by (platform, unique_key) and infers signal_type from the
|
||||
// identifier shape (see parseProjectField/resolveOrStub) — a domain → 'site'.
|
||||
$supplierKey = 'imitseed-'.strtolower(Str::random(8)).'.test';
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => $supplierKey,
|
||||
]);
|
||||
|
||||
// Funded imitation clients, all targeting Москва, full week, generous limit.
|
||||
for ($i = 1; $i <= $clients; $i++) {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$project = Project::factory()
|
||||
->asSiteSignal('imitseed-'.$i.'-'.Str::random(6).'.test')
|
||||
->create([
|
||||
'name' => "IMIT-seed-client-{$i}",
|
||||
'tenant_id' => $tenant->id,
|
||||
'regions' => [$moscow],
|
||||
'delivery_days_mask' => 127,
|
||||
'daily_limit_target' => 1000,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $supplier->id,
|
||||
'platform' => $supplier->platform,
|
||||
'subject_code' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Build the routing snapshot for the active date the router will query.
|
||||
Artisan::call('snapshot:rebuild', ['--date' => $this->activeDate()]);
|
||||
|
||||
// Inject synthetic leads through the real routing + ledger pipeline.
|
||||
$injected = 0;
|
||||
for ($n = 1; $n <= $leads; $n++) {
|
||||
$phone = '79'.str_pad((string) random_int(0, 999_999_999), 9, '0', STR_PAD_LEFT);
|
||||
$vid = random_int(100_000_000, 999_999_999);
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => $supplier->id,
|
||||
'platform' => $supplier->platform,
|
||||
'phone' => $phone,
|
||||
'vid' => $vid,
|
||||
'raw_payload' => [
|
||||
'vid' => $vid,
|
||||
'project' => $supplier->platform.'_'.$supplierKey,
|
||||
'tag' => 'Москва',
|
||||
'phone' => $phone,
|
||||
'phones' => [$phone],
|
||||
'time' => now()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
'deals_created_count' => null,
|
||||
]);
|
||||
|
||||
RouteSupplierLeadJob::dispatchSync($lead->id);
|
||||
$injected++;
|
||||
}
|
||||
|
||||
$this->info("imitation:seed done — {$clients} clients, {$injected} leads injected (region from tag, DaData disabled).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Active snapshot date — mirrors LeadRouter::activeSnapshotDate()
|
||||
* (today before 21:00 MSK, tomorrow at/after).
|
||||
*/
|
||||
private function activeDate(): string
|
||||
{
|
||||
$msk = Carbon::now('Europe/Moscow');
|
||||
|
||||
return ($msk->hour >= 21 ? $msk->copy()->addDay() : $msk)->format('Y-m-d');
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,12 @@ 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 : Окно дедупа открытых инцидентов в минутах}
|
||||
{--threshold-single-lead=1000 : Порог storm detection: failures одного supplier_lead_id за окно}';
|
||||
{--window=10 : Окно сканирования в минутах}
|
||||
{--threshold=200 : Порог спайка для failed_webhook_jobs}
|
||||
{--threshold-spike=10 : Порог спайка для failed_jobs (за окно)}
|
||||
{--threshold-daily=50 : Порог суммы за 24ч для failed_jobs}
|
||||
{--persistent-hours=3 : Порог возраста persistent-exception для failed_jobs}
|
||||
{--dedup-window=60 : Окно дедупа открытых инцидентов в минутах}';
|
||||
|
||||
protected $description = 'Сканирует failed_webhook_jobs и failed_jobs, создаёт incidents_log на превышение порогов';
|
||||
|
||||
@@ -46,8 +45,6 @@ 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);
|
||||
@@ -188,39 +185,6 @@ 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;
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Pd;
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* F-P1 / 152-ФЗ ретеншен: анонимизирует ПДн soft-deleted сделок по истечении
|
||||
* настраиваемого срока (спека 2026-06-17-fp1-deal-pii-retention-spec).
|
||||
*
|
||||
* Срок (дней) — в system_settings, ключ `pd_scrub_soft_deleted_deals_days`.
|
||||
* Отсутствие ключа или значение < 1 → no-op (юридический срок не зашит в код,
|
||||
* выставляется на проде). Паттерн безопасности идентичен PartitionsDropExpired.
|
||||
*
|
||||
* Значения анонимизации идентичны PdErasureService::eraseSubject. Работает
|
||||
* cross-tenant через pgsql_supplier (BYPASSRLS). Идемпотентно: уже затёртые
|
||||
* (phone = ANON_PHONE) исключаются из выборки.
|
||||
*/
|
||||
class ScrubSoftDeletedDealsCommand extends Command
|
||||
{
|
||||
private const DB = 'pgsql_supplier';
|
||||
|
||||
private const SETTING_KEY = 'pd_scrub_soft_deleted_deals_days';
|
||||
|
||||
private const ANON_PHONE = '+7000XXXXXXX';
|
||||
|
||||
private const ANON_NAME = 'Удалено';
|
||||
|
||||
/** @var string */
|
||||
protected $signature = 'pd:scrub-soft-deleted-deals
|
||||
{--dry-run : Показать число кандидатов, не анонимизировать}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Анонимизирует ПДн (телефон/имя) soft-deleted сделок старше retention-срока (152-ФЗ, F-P1)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$days = $this->resolveRetentionDays();
|
||||
|
||||
if ($days === null) {
|
||||
$this->line('<fg=gray>skip</> retention не настроен (system_settings.'.self::SETTING_KEY.' отсутствует или < 1).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$cutoff = CarbonImmutable::now()->subDays($days);
|
||||
$candidates = $this->candidateQuery($cutoff)->count();
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line("<fg=yellow>[dry-run]</> кандидатов на анонимизацию: {$candidates} (deleted_at старше {$days} дн.)");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($candidates === 0) {
|
||||
$this->info("Кандидатов на анонимизацию нет (retention={$days} дн.).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$now = CarbonImmutable::now();
|
||||
|
||||
// Bulk-UPDATE атомарен одним SQL; лог — одна summary-запись. Явная
|
||||
// транзакция не нужна и несовместима с shared-PDO в тестах
|
||||
// (pgsql_supplier делит сессию с уже открытой транзакцией pgsql).
|
||||
$this->candidateQuery($cutoff)->update([
|
||||
'phone' => self::ANON_PHONE,
|
||||
'contact_name' => self::ANON_NAME,
|
||||
'phones' => null,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// Системное действие: оба actor-поля NULL (допускается chk_pd_actor).
|
||||
// log_hash заполняется триггером цепочки целостности.
|
||||
DB::connection(self::DB)->table('pd_processing_log')->insert([
|
||||
'tenant_id' => null,
|
||||
'subject_type' => 'deal',
|
||||
'subject_id' => null,
|
||||
'action' => 'deleted',
|
||||
'purpose' => '152-FZ retention scrub',
|
||||
'actor_tenant_user_id' => null,
|
||||
'actor_admin_user_id' => null,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
|
||||
$this->info("Анонимизировано сделок: {$candidates} (retention={$days} дн.).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** Кандидаты: soft-deleted старше cutoff, ещё не анонимизированные. */
|
||||
private function candidateQuery(CarbonImmutable $cutoff): Builder
|
||||
{
|
||||
return DB::connection(self::DB)->table('deals')
|
||||
->whereNotNull('deleted_at')
|
||||
->where('deleted_at', '<', $cutoff)
|
||||
->where('phone', '<>', self::ANON_PHONE);
|
||||
}
|
||||
|
||||
/** Срок ретеншена из system_settings; null если ключа нет или значение < 1. */
|
||||
private function resolveRetentionDays(): ?int
|
||||
{
|
||||
$setting = SystemSetting::find(self::SETTING_KEY);
|
||||
|
||||
if ($setting === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = (int) $setting->value;
|
||||
|
||||
return $value >= 1 ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -1,446 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Connection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use OpenSpout\Reader\XLSX\Reader as XlsxReader;
|
||||
|
||||
/**
|
||||
* Импорт реестра нумерации Россвязи в `phone_ranges` (spec §6).
|
||||
*
|
||||
* php artisan phone-ranges:import --file=<csv|xlsx> [--force] [--dry-run]
|
||||
* php artisan phone-ranges:import --dir=<dir с пакетом файлов> [...]
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. Резолв входных файлов (--file | --dir; --url отложен — оператор качает пакет вручную).
|
||||
* 2. Checksum-идемпотентность: совпал с предыдущим `completed` → status='rolled_back', выход.
|
||||
* 3. Парсинг (CSV через str_getcsv ';', XLSX через openspout) → нормализованные строки.
|
||||
* 4. Маппинг region → subject_code через RussianRegions::nameToCode(). Несматчившиеся → лог в error.
|
||||
* 5. Сборка `phone_ranges_staging` (LIKE phone_ranges) + bulk INSERT.
|
||||
* 6. --dry-run → staging остаётся для инспекции, swap НЕ делается, status='rolled_back'.
|
||||
* Иначе → atomic RENAME swap + status='completed'.
|
||||
*
|
||||
* Запись идёт через `pgsql_supplier` (на проде crm_supplier_worker — член crm_migrator,
|
||||
* INHERIT даёт CREATE; SET ROLE crm_migrator выравнивает ownership. На dev/test — postgres superuser).
|
||||
*
|
||||
* NB (swap — operator-validated): committing-swap (шаг 6 else) НЕ покрыт автотестом —
|
||||
* RENAME коммитит и сломал бы общую тестовую БД. Свап проверяется первым реальным
|
||||
* импортом оператора по runbook (Session 6). Тесты покрывают parse/map/dry-run/idempotency.
|
||||
*/
|
||||
class PhoneRangesImportCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'phone-ranges:import
|
||||
{--file= : Путь к одному CSV/XLSX файлу реестра}
|
||||
{--dir= : Каталог с пакетом файлов реестра (*.csv, *.xlsx)}
|
||||
{--url= : (отложено) URL пакета — скачать вручную и использовать --dir}
|
||||
{--force : Игнорировать checksum-идемпотентность}
|
||||
{--dry-run : Распарсить и собрать staging, но не делать atomic swap}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Импорт реестра нумерации Россвязи в phone_ranges (idempotent, atomic swap)';
|
||||
|
||||
/** Connection для DDL/записи (на проде crm_migrator-capable, на dev/test — superuser fallback). */
|
||||
private const DDL_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/** Размер пачки для bulk INSERT в staging. */
|
||||
private const INSERT_CHUNK = 1000;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$files = $this->resolveFiles();
|
||||
if ($files === null) {
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$checksum = $this->computeChecksum($files);
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
// 2. Идемпотентность по checksum (если не --force).
|
||||
if (! $force) {
|
||||
$prev = DB::table('phone_ranges_imports')
|
||||
->where('checksum_sha256', $checksum)
|
||||
->where('status', 'completed')
|
||||
->orderByDesc('id')
|
||||
->first();
|
||||
|
||||
if ($prev !== null) {
|
||||
DB::table('phone_ranges_imports')->insert([
|
||||
'source_url' => $this->sourceLabel($files),
|
||||
'checksum_sha256' => $checksum,
|
||||
'status' => 'rolled_back',
|
||||
'rows_inserted' => 0,
|
||||
'rows_updated' => 0,
|
||||
'error' => "Идентично импорту #{$prev->id} (checksum совпал) — пропуск.",
|
||||
'imported_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->info("Реестр идентичен импорту #{$prev->id} — пропуск (используйте --force для принудительного импорта).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Журнал импорта (in_progress).
|
||||
$importId = (int) DB::table('phone_ranges_imports')->insertGetId([
|
||||
'source_url' => $this->sourceLabel($files),
|
||||
'checksum_sha256' => $checksum,
|
||||
'status' => 'in_progress',
|
||||
'imported_at' => now(),
|
||||
]);
|
||||
|
||||
try {
|
||||
// 4. Парсинг + маппинг.
|
||||
$unmatched = [];
|
||||
$rows = [];
|
||||
foreach ($files as $file) {
|
||||
foreach ($this->parseFile($file) as $rec) {
|
||||
$regionNormalized = RussianRegions::canonicalRegionName($rec['region']);
|
||||
$subjectCode = $regionNormalized === null
|
||||
? null
|
||||
: (RussianRegions::nameToCode()[$regionNormalized] ?? null);
|
||||
if ($subjectCode === null && trim($rec['region']) !== '') {
|
||||
$unmatched[trim($rec['region'])] = true;
|
||||
}
|
||||
$rows[] = [
|
||||
'def_code' => $rec['def_code'],
|
||||
'from_num' => $rec['from_num'],
|
||||
'to_num' => $rec['to_num'],
|
||||
'operator' => $rec['operator'],
|
||||
'region' => $rec['region'],
|
||||
'region_normalized' => $regionNormalized,
|
||||
'subject_code' => $subjectCode,
|
||||
'imported_at' => now(),
|
||||
'import_id' => $importId,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Сборка staging.
|
||||
$this->buildStaging($rows, $importId);
|
||||
|
||||
$unmatchedNote = $unmatched === []
|
||||
? ''
|
||||
: 'Не сопоставлены регионы: '.implode(', ', array_keys($unmatched)).'.';
|
||||
|
||||
if ($dryRun) {
|
||||
DB::table('phone_ranges_imports')->where('id', $importId)->update([
|
||||
'status' => 'rolled_back',
|
||||
'rows_inserted' => count($rows),
|
||||
'error' => trim('dry-run (swap не выполнен). '.$unmatchedNote),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->info('dry-run: '.count($rows).' строк в phone_ranges_staging, swap не выполнен.');
|
||||
if ($unmatchedNote !== '') {
|
||||
$this->warn($unmatchedNote);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// 6. Atomic swap (operator-validated — см. docblock).
|
||||
$this->atomicSwap();
|
||||
|
||||
DB::table('phone_ranges_imports')->where('id', $importId)->update([
|
||||
'status' => 'completed',
|
||||
'rows_inserted' => count($rows),
|
||||
'error' => $unmatchedNote !== '' ? $unmatchedNote : null,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->info('Импортировано '.count($rows).' строк в phone_ranges (atomic swap выполнен).');
|
||||
if ($unmatchedNote !== '') {
|
||||
$this->warn($unmatchedNote);
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
DB::table('phone_ranges_imports')->where('id', $importId)->update([
|
||||
'status' => 'failed',
|
||||
'error' => mb_substr($e->getMessage(), 0, 2000),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
$this->error('Импорт упал: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>|null Список файлов или null при ошибке валидации опций.
|
||||
*/
|
||||
private function resolveFiles(): ?array
|
||||
{
|
||||
$file = $this->option('file');
|
||||
$dir = $this->option('dir');
|
||||
$url = $this->option('url');
|
||||
|
||||
if ($url !== null) {
|
||||
$this->error('--url отложен (пакет ~500-600 файлов). Скачайте вручную и используйте --dir.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($file !== null) {
|
||||
if (! is_file($file)) {
|
||||
$this->error("Файл не найден: {$file}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return [$file];
|
||||
}
|
||||
|
||||
if ($dir !== null) {
|
||||
if (! is_dir($dir)) {
|
||||
$this->error("Каталог не найден: {$dir}");
|
||||
|
||||
return null;
|
||||
}
|
||||
$found = glob(rtrim($dir, '/\\').DIRECTORY_SEPARATOR.'*.{csv,xlsx}', GLOB_BRACE) ?: [];
|
||||
if ($found === []) {
|
||||
$this->error("В каталоге нет *.csv / *.xlsx: {$dir}");
|
||||
|
||||
return null;
|
||||
}
|
||||
sort($found);
|
||||
|
||||
return array_values($found);
|
||||
}
|
||||
|
||||
$this->error('Укажите --file=<путь> или --dir=<каталог>.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $files
|
||||
*/
|
||||
private function computeChecksum(array $files): string
|
||||
{
|
||||
if (count($files) === 1) {
|
||||
return (string) hash_file('sha256', $files[0]);
|
||||
}
|
||||
|
||||
$hashes = array_map(static fn (string $f): string => (string) hash_file('sha256', $f), $files);
|
||||
sort($hashes);
|
||||
|
||||
return hash('sha256', implode('|', $hashes));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $files
|
||||
*/
|
||||
private function sourceLabel(array $files): string
|
||||
{
|
||||
return $this->option('url')
|
||||
?? $this->option('dir')
|
||||
?? ($files[0] ?? 'unknown');
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсит один файл реестра в нормализованные строки.
|
||||
*
|
||||
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
|
||||
*/
|
||||
private function parseFile(string $path): array
|
||||
{
|
||||
$ext = strtolower(pathinfo($path, PATHINFO_EXTENSION));
|
||||
|
||||
return $ext === 'xlsx'
|
||||
? $this->parseXlsx($path)
|
||||
: $this->parseCsv($path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
|
||||
*/
|
||||
private function parseCsv(string $path): array
|
||||
{
|
||||
$content = (string) file_get_contents($path);
|
||||
// BOM strip + split строк (CRLF/CR/LF).
|
||||
$content = preg_replace('/^\xEF\xBB\xBF/', '', $content) ?? $content;
|
||||
$lines = preg_split('/\r\n|\r|\n/', rtrim($content)) ?: [];
|
||||
if ($lines === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$header = str_getcsv((string) array_shift($lines), ';');
|
||||
$cols = $this->resolveColumns($header);
|
||||
|
||||
$out = [];
|
||||
foreach ($lines as $line) {
|
||||
if (trim($line) === '') {
|
||||
continue;
|
||||
}
|
||||
$cells = str_getcsv($line, ';');
|
||||
$rec = $this->mapCells($cells, $cols);
|
||||
if ($rec !== null) {
|
||||
$out[] = $rec;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Парсинг XLSX через openspout (operator-real-files; CSV-ветка покрыта тестом).
|
||||
*
|
||||
* @return list<array{def_code:int, from_num:int, to_num:int, operator:string, region:string}>
|
||||
*/
|
||||
private function parseXlsx(string $path): array
|
||||
{
|
||||
$reader = new XlsxReader;
|
||||
$reader->open($path);
|
||||
|
||||
$out = [];
|
||||
$cols = null;
|
||||
foreach ($reader->getSheetIterator() as $sheet) {
|
||||
foreach ($sheet->getRowIterator() as $row) {
|
||||
$cells = array_map(static fn ($c): string => (string) $c, $row->toArray());
|
||||
if ($cols === null) {
|
||||
$cols = $this->resolveColumns($cells);
|
||||
|
||||
continue;
|
||||
}
|
||||
$rec = $this->mapCells($cells, $cols);
|
||||
if ($rec !== null) {
|
||||
$out[] = $rec;
|
||||
}
|
||||
}
|
||||
break; // только первый лист
|
||||
}
|
||||
$reader->close();
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сопоставляет индексы колонок по заголовку (русские имена Россвязи) с позиционным fallback.
|
||||
*
|
||||
* @param list<string> $header
|
||||
* @return array{def:int, from:int, to:int, operator:int, region:int}
|
||||
*/
|
||||
private function resolveColumns(array $header): array
|
||||
{
|
||||
$cols = ['def' => 0, 'from' => 1, 'to' => 2, 'operator' => 4, 'region' => 5];
|
||||
|
||||
foreach ($header as $i => $cell) {
|
||||
$n = preg_replace('/[\s\/]+/u', '', mb_strtolower(trim((string) $cell))) ?? '';
|
||||
if (str_contains($n, 'def') || str_contains($n, 'авс')) {
|
||||
$cols['def'] = $i;
|
||||
} elseif ($n === 'от') {
|
||||
$cols['from'] = $i;
|
||||
} elseif ($n === 'до') {
|
||||
$cols['to'] = $i;
|
||||
} elseif (str_contains($n, 'оператор')) {
|
||||
$cols['operator'] = $i;
|
||||
} elseif (str_contains($n, 'регион')) {
|
||||
$cols['region'] = $i;
|
||||
}
|
||||
}
|
||||
|
||||
return $cols;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $cells
|
||||
* @param array{def:int, from:int, to:int, operator:int, region:int} $cols
|
||||
* @return array{def_code:int, from_num:int, to_num:int, operator:string, region:string}|null
|
||||
*/
|
||||
private function mapCells(array $cells, array $cols): ?array
|
||||
{
|
||||
$def = (int) preg_replace('/\D+/', '', $cells[$cols['def']] ?? '');
|
||||
if ($def === 0) {
|
||||
return null; // пустая/битая строка
|
||||
}
|
||||
|
||||
return [
|
||||
'def_code' => $def,
|
||||
'from_num' => (int) preg_replace('/\D+/', '', $cells[$cols['from']] ?? '0'),
|
||||
'to_num' => (int) preg_replace('/\D+/', '', $cells[$cols['to']] ?? '0'),
|
||||
'operator' => trim((string) ($cells[$cols['operator']] ?? '')),
|
||||
'region' => trim((string) ($cells[$cols['region']] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает phone_ranges_staging (LIKE phone_ranges) и заливает строки.
|
||||
*
|
||||
* id: НЕ копируем серийный default через INCLUDING DEFAULTS — он ссылается на
|
||||
* исходную последовательность phone_ranges, которую atomic-swap уничтожает
|
||||
* (DROP phone_ranges_old CASCADE) после первого импорта, оставляя staging.id
|
||||
* без default (NOT NULL violation на повторном импорте). Вместо этого даём
|
||||
* staging собственную последовательность с уникальным по import_id именем,
|
||||
* OWNED BY колонкой id → она переезжает при RENAME и дропается вместе со
|
||||
* старой таблицей (без коллизий имён и без утечки последовательностей).
|
||||
*
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function buildStaging(array $rows, int $importId): void
|
||||
{
|
||||
$c = DB::connection(self::DDL_CONNECTION);
|
||||
$this->elevate($c);
|
||||
|
||||
$seq = "phone_ranges_stg_seq_{$importId}";
|
||||
$c->statement('DROP TABLE IF EXISTS phone_ranges_staging CASCADE');
|
||||
$c->statement('CREATE TABLE phone_ranges_staging (LIKE phone_ranges INCLUDING CONSTRAINTS)');
|
||||
$c->statement("CREATE SEQUENCE {$seq}");
|
||||
$c->statement("ALTER TABLE phone_ranges_staging ALTER COLUMN id SET DEFAULT nextval('{$seq}')");
|
||||
$c->statement("ALTER SEQUENCE {$seq} OWNED BY phone_ranges_staging.id");
|
||||
$c->statement('CREATE INDEX IF NOT EXISTS idx_phone_ranges_staging_lookup ON phone_ranges_staging (def_code, from_num, to_num)');
|
||||
|
||||
foreach (array_chunk($rows, self::INSERT_CHUNK) as $chunk) {
|
||||
$c->table('phone_ranges_staging')->insert($chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic swap живого phone_ranges на staging (spec §6.2 шаг 6).
|
||||
*
|
||||
* NB: НЕ покрыт автотестом (committing RENAME сломал бы общую тестовую БД).
|
||||
* Проверяется первым реальным импортом оператора (Session 6 runbook).
|
||||
* Сохраняет одну предыдущую версию (phone_ranges_old) для `phone-ranges:rollback`.
|
||||
* GRANT'ы переустанавливаются (RENAME их не переносит); lookup-индекс на новой
|
||||
* таблице носит имя idx_phone_ranges_staging_lookup (косметика — имя занято _old).
|
||||
*/
|
||||
private function atomicSwap(): void
|
||||
{
|
||||
$c = DB::connection(self::DDL_CONNECTION);
|
||||
$this->elevate($c);
|
||||
|
||||
// Транзакция вокруг свапа (spec §6.2): PostgreSQL поддерживает транзакционный
|
||||
// DDL, поэтому DROP+RENAME+RENAME+GRANT атомарны. Обрыв процесса между
|
||||
// переименованиями не оставит phone_ranges несуществующей — откат вернёт
|
||||
// живую таблицу (раньше 4 авто-коммит-statement'а оставляли окно, в котором
|
||||
// Россвязь-lookup падал бы до ручного восстановления).
|
||||
$c->transaction(function () use ($c) {
|
||||
$c->statement('DROP TABLE IF EXISTS phone_ranges_old CASCADE');
|
||||
$c->statement('ALTER TABLE phone_ranges RENAME TO phone_ranges_old');
|
||||
$c->statement('ALTER TABLE phone_ranges_staging RENAME TO phone_ranges');
|
||||
$c->statement('GRANT SELECT ON phone_ranges TO crm_app_user, crm_supplier_worker');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SET ROLE crm_migrator для корректного ownership на проде; на dev/test роль
|
||||
* отсутствует → RESET и работаем как superuser (зеркало миграционного паттерна).
|
||||
*/
|
||||
private function elevate(Connection $c): void
|
||||
{
|
||||
try {
|
||||
$c->statement('SET ROLE crm_migrator');
|
||||
$canCreate = $c->selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
|
||||
if (! $canCreate || ! $canCreate->ok) {
|
||||
$c->statement('RESET ROLE');
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// окружение без роли — продолжаем как superuser
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\SupplierLead;
|
||||
use App\Services\LeadRegionResolver;
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Staging-smoke резолва региона по телефону (spec §9.4): дёргает живой каскад
|
||||
* DaData → Россвязь → tag и печатает решение. В БД ничего НЕ пишет.
|
||||
*
|
||||
* php artisan phone-region:smoke --phone=79161234567 [--tag=Москва]
|
||||
*
|
||||
* Принудительно включает services.dadata.enabled на время прогона (smoke всегда
|
||||
* проверяет полный каскад, независимо от глобального feature-flag). С реальным
|
||||
* DADATA_API_KEY делает платный вызов — запускать осознанно.
|
||||
*/
|
||||
class PhoneRegionSmokeCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'phone-region:smoke
|
||||
{--phone= : Телефон в формате 7XXXXXXXXXX}
|
||||
{--tag= : Регион-тег поставщика (fallback-слой)}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Прогон резолва региона по телефону (DaData→Россвязь→tag) без записи в БД (staging-smoke)';
|
||||
|
||||
public function handle(LeadRegionResolver $resolver): int
|
||||
{
|
||||
$phone = (string) $this->option('phone');
|
||||
if ($phone === '') {
|
||||
$this->error('Укажите --phone=7XXXXXXXXXX');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Smoke всегда прогоняет полный каскад, даже если глобальный флаг выключен.
|
||||
config(['services.dadata.enabled' => true]);
|
||||
|
||||
$lead = new SupplierLead([
|
||||
'phone' => $phone,
|
||||
'raw_payload' => ['tag' => (string) $this->option('tag')],
|
||||
]);
|
||||
|
||||
$r = $resolver->resolve($lead);
|
||||
|
||||
$region = $r->subjectCode !== null
|
||||
? (RussianRegions::CODE_TO_NAME[$r->subjectCode] ?? '?')
|
||||
: '—';
|
||||
|
||||
$this->info('Телефон: '.$this->maskPhone($phone));
|
||||
$this->line('Источник: '.$r->source);
|
||||
$this->line('Субъект: '.($r->subjectCode ?? '—').' ('.$region.')');
|
||||
$this->line('Оператор: '.($r->phoneOperator ?? '—'));
|
||||
$this->line('DaData qc: '.($r->qc ?? '—'));
|
||||
$this->line('Cache hit: '.($r->cacheHit ? 'да' : 'нет'));
|
||||
$this->line('Россвязь: '.($r->rossvyazMatched ? 'совпала' : 'нет'));
|
||||
$this->line('Длит., мс: '.($r->durationMs ?? '—'));
|
||||
$this->newLine();
|
||||
$this->comment('NB: запись в БД НЕ выполнялась (smoke).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $phone) ?? '';
|
||||
if (strlen($digits) < 8) {
|
||||
return '***';
|
||||
}
|
||||
|
||||
return substr($digits, 0, 4).'***'.substr($digits, -4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Reminder;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Cron-команда диспатча due-reminders.
|
||||
*
|
||||
* Идёт по `reminders` где `is_sent=false AND completed_at IS NULL AND
|
||||
* remind_at <= NOW()`. Для каждой строки:
|
||||
* 1) NotificationService::notifyReminder (email + inapp по prefs);
|
||||
* 2) UPDATE is_sent=true, sent_at=NOW().
|
||||
*
|
||||
* RLS: SET LOCAL app.current_tenant_id = reminder.tenant_id внутри
|
||||
* транзакции каждой обработки (по одному reminder в транзакции — иначе
|
||||
* нельзя переключить tenant между строками с разных tenant'ов).
|
||||
*
|
||||
* Запускается каждую минуту через Windows Task Scheduler / cron.
|
||||
* Идемпотентна: повторный вызов на отправленных ($is_sent=true) skipаются.
|
||||
*
|
||||
* --dry-run печатает плановых получателей без реальных INSERT'ов.
|
||||
*
|
||||
* Источник: db/schema.sql §17.5; ТЗ §6.6 / §18.5.
|
||||
*/
|
||||
class RemindersDispatchDue extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'reminders:dispatch-due
|
||||
{--dry-run : Не отправлять, только напечатать список плановых получателей}
|
||||
{--limit=500 : Максимум reminders за один запуск}';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Диспатч due-reminders: email/inapp уведомления + is_sent=true (ТЗ §18.5)';
|
||||
|
||||
public function handle(NotificationService $service): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
$limit = max(1, (int) $this->option('limit'));
|
||||
$now = Carbon::now();
|
||||
|
||||
// Cross-tenant gather via BYPASSRLS connection — on prod crm_app_user cannot
|
||||
// call current_setting('app.current_tenant_id') without a GUC set first.
|
||||
// pgsql_supplier (crm_supplier_worker, BYPASSRLS) is the canonical pattern
|
||||
// for SaaS-admin cron queries (precedent: IncidentsWatchFailures, Reset*).
|
||||
$rows = DB::connection('pgsql_supplier')
|
||||
->table('reminders')
|
||||
->select(['id', 'tenant_id', 'deal_id', 'remind_at'])
|
||||
->where('is_sent', false)
|
||||
->whereNull('completed_at')
|
||||
->where('remind_at', '<=', $now)
|
||||
->orderBy('remind_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
$this->info('Нет due-reminders.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$sent = 0;
|
||||
$failed = 0;
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($dryRun) {
|
||||
$this->line(sprintf(
|
||||
' would dispatch <fg=yellow>id=%d</> tenant=%d deal=%d remind_at=%s',
|
||||
$row->id,
|
||||
$row->tenant_id,
|
||||
$row->deal_id,
|
||||
$row->remind_at ?? '-',
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($row, $service): void {
|
||||
// SET LOCAL scopes GUC to this transaction — PgBouncer-safe.
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $row->tenant_id);
|
||||
// Fetch the full Eloquent model with tenant context active so
|
||||
// relations (user, etc.) work correctly inside NotificationService.
|
||||
$reminder = Reminder::query()->findOrFail((int) $row->id);
|
||||
$service->notifyReminder($reminder);
|
||||
$reminder->update([
|
||||
'is_sent' => true,
|
||||
'sent_at' => Carbon::now(),
|
||||
]);
|
||||
});
|
||||
$sent++;
|
||||
$this->info(" dispatched <fg=green>id={$row->id}</>");
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->error(" failed <fg=red>id={$row->id}</>: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Done: {$sent} sent, {$failed} failed (limit={$limit}, dry-run=".($dryRun ? '1' : '0').').');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
<?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,7 +5,6 @@ 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;
|
||||
@@ -84,12 +83,166 @@ 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 (AuditChainConfig::TABLES as $table => $config) {
|
||||
foreach (self::TABLE_CONFIG as $table => $config) {
|
||||
// Get all partitions for this table via pg_inherits.
|
||||
$partitions = $this->listPartitions($table);
|
||||
|
||||
@@ -98,10 +251,8 @@ class VerifyAuditChains extends Command
|
||||
$partitions = [$table];
|
||||
}
|
||||
|
||||
$tableHadBreach = false;
|
||||
|
||||
foreach ($partitions as $partitionName) {
|
||||
$breaches = $this->checkPartition($partitionName, $table, $config['partition']);
|
||||
$breaches = $this->checkPartition($partitionName, $config['columns'], $config['partition']);
|
||||
|
||||
if (empty($breaches)) {
|
||||
$this->line(" ✓ {$partitionName}: chain intact");
|
||||
@@ -110,7 +261,6 @@ class VerifyAuditChains extends Command
|
||||
}
|
||||
|
||||
$anyBreach = true;
|
||||
$tableHadBreach = true;
|
||||
$firstId = $breaches[0]->id;
|
||||
$count = count($breaches);
|
||||
|
||||
@@ -125,18 +275,6 @@ class VerifyAuditChains extends Command
|
||||
|
||||
$this->sendAlert($table, $partitionName, $firstId, $count);
|
||||
}
|
||||
|
||||
// Auto-resolve: a table whose chain is intact across ALL partitions
|
||||
// closes any stale open chain incident left by a previous transient
|
||||
// breach (e.g. acceptance test-tenant rows since removed by teardown).
|
||||
// Best-effort: never let cleanup break the command or its exit code.
|
||||
if (! $tableHadBreach) {
|
||||
try {
|
||||
$this->resolveOpenIncidents($table, $now);
|
||||
} catch (\Throwable $e) {
|
||||
$this->warn(" Incident auto-resolve failed for {$table}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit FAILURE on ANY breach regardless of incident-write success.
|
||||
@@ -183,11 +321,12 @@ 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, string $table, string $partition): array
|
||||
private function checkPartition(string $partitionName, array $columns, string $partition): array
|
||||
{
|
||||
$rowExpr = AuditChainConfig::rowExpression($table);
|
||||
$rowExpr = $this->buildRowExpression($columns);
|
||||
|
||||
// Build OVER clause: with or without PARTITION BY depending on table's RLS scope.
|
||||
$overClause = $partition !== ''
|
||||
@@ -227,6 +366,25 @@ 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).
|
||||
* Дедупликация: не создаёт повторный инцидент для той же таблицы,
|
||||
@@ -296,38 +454,6 @@ class VerifyAuditChains extends Command
|
||||
$this->warn(" Incident recorded for {$partitionName} (first broken id={$firstBrokenId})");
|
||||
}
|
||||
|
||||
/**
|
||||
* Авто-закрытие устаревших открытых инцидентов разрыва цепочки для таблицы,
|
||||
* чья цепочка снова целостна во всех партициях.
|
||||
*
|
||||
* Закрывает класс «вечно-открытых» high-инцидентов после транзиентного
|
||||
* разрыва (строки удалены/исправлены вне прогона — напр. строки тест-тенантов
|
||||
* приёмки, убранные teardown): без этого verify-chains накапливал бы открытые
|
||||
* инциденты и слал бы по ним алёрты после истечения дедупа.
|
||||
*
|
||||
* Матчинг summary — тот же per-table шаблон, что в recordIncident()
|
||||
* (дедупликация и закрытие симметричны). Вызывается только когда таблица
|
||||
* чиста во ВСЕХ партициях (guard $tableHadBreach в handle()).
|
||||
*/
|
||||
private function resolveOpenIncidents(string $table, Carbon $now): void
|
||||
{
|
||||
$resolved = DB::connection(self::DB_CONNECTION)
|
||||
->table('incidents_log')
|
||||
->where('type', 'other')
|
||||
->where('severity', 'high')
|
||||
->where('summary', 'like', '%chain%'.addcslashes($table, '%_\\').'%')
|
||||
->whereNull('resolved_at')
|
||||
->update([
|
||||
'resolved_at' => $now,
|
||||
'updated_at' => $now,
|
||||
'root_cause' => "Автоматически закрыт: audit:verify-chains подтвердил целостность hash-chain таблицы {$table}.",
|
||||
]);
|
||||
|
||||
if ($resolved > 0) {
|
||||
$this->info(" ↻ {$table}: auto-resolved {$resolved} stale chain incident(s).");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправляет email-алёрт на monitoring email.
|
||||
*/
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Account\ChangePasswordRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\UserSessionTracker;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Аккаунт пользователя — вкладка «Безопасность» (UI-аудит 21.06.2026).
|
||||
*
|
||||
* Заменяет статичные mock-карточки (ChangePasswordCard/SessionsTable):
|
||||
* - POST /api/account/change-password — реальная смена пароля.
|
||||
* - GET /api/account/security — дата последней смены пароля + активные сессии.
|
||||
* - DELETE /api/account/sessions/{id} — отозвать сессию (UI-аудит 21.06.2026).
|
||||
*
|
||||
* Активные сессии берутся из user_sessions (запись при входе); отзыв реально
|
||||
* убивает сессию (удаление из Redis по session_id). Заменяет прежний mock.
|
||||
*/
|
||||
class AccountController extends Controller
|
||||
{
|
||||
use WritesAuthLog;
|
||||
|
||||
/**
|
||||
* POST /api/account/change-password — смена пароля авторизованным пользователем.
|
||||
*
|
||||
* Проверяет текущий пароль (Hash::check против password_hash), пишет новый хэш,
|
||||
* логирует password_changed в auth_log. На неверном текущем — 422 + лог
|
||||
* password_change_failed.
|
||||
*/
|
||||
public function changePassword(ChangePasswordRequest $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
if (! Hash::check($request->string('current_password')->toString(), (string) $user->password_hash)) {
|
||||
$this->logAuthEvent(
|
||||
'password_change_failed',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
'wrong_current_password',
|
||||
);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'current_password' => ['Неверный текущий пароль.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'password_hash' => Hash::make($request->string('password')->toString()),
|
||||
])->save();
|
||||
|
||||
$this->logAuthEvent(
|
||||
'password_changed',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
null,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Пароль изменён.',
|
||||
'last_password_change_at' => now()->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/account/security — данные вкладки «Безопасность».
|
||||
*
|
||||
* last_password_change_at — max(created_at) по password-событиям в auth_log
|
||||
* (null, если пароль ни разу не менялся через портал).
|
||||
* recent_logins — последние входы текущего пользователя (устройство/IP/время).
|
||||
*/
|
||||
public function security(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
$lastChange = DB::table('auth_log')
|
||||
->where('user_id', $user->id)
|
||||
->whereIn('event', ['password_changed', 'password_reset_completed'])
|
||||
->max('created_at');
|
||||
|
||||
$currentSid = $request->session()->getId();
|
||||
$rows = DB::table('user_sessions')
|
||||
->where('user_id', $user->id)
|
||||
->where('expires_at', '>', now())
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get(['id', 'token_hash', 'ip_address', 'user_agent', 'last_active_at', 'created_at']);
|
||||
|
||||
$sessions = $rows->map(fn ($row): array => [
|
||||
'id' => $row->id,
|
||||
'device' => $this->deviceLabel($row->user_agent),
|
||||
'ip' => $row->ip_address,
|
||||
'at' => Carbon::parse($row->last_active_at ?? $row->created_at)->toIso8601String(),
|
||||
'current' => $row->token_hash === $currentSid,
|
||||
])->all();
|
||||
|
||||
return response()->json([
|
||||
'last_password_change_at' => $lastChange ? Carbon::parse($lastChange)->toIso8601String() : null,
|
||||
'sessions' => $sessions,
|
||||
]);
|
||||
}
|
||||
|
||||
/** DELETE /api/account/sessions/{id} — отозвать конкретную сессию пользователя. */
|
||||
public function revokeSession(Request $request, int $id): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$ok = app(UserSessionTracker::class)->revoke($user->id, $id);
|
||||
|
||||
if (! $ok) {
|
||||
return response()->json(['message' => 'Сессия не найдена.'], 404);
|
||||
}
|
||||
|
||||
$this->logAuthEvent(
|
||||
'session_revoked',
|
||||
$user->id,
|
||||
$user->tenant_id,
|
||||
$user->email,
|
||||
$request->ip(),
|
||||
$request->userAgent(),
|
||||
null,
|
||||
);
|
||||
|
||||
return response()->json(['message' => 'Сессия завершена.']);
|
||||
}
|
||||
|
||||
/** Грубый человекочитаемый ярлык устройства из User-Agent (браузер + ОС). */
|
||||
private function deviceLabel(?string $ua): string
|
||||
{
|
||||
if ($ua === null || $ua === '') {
|
||||
return 'Неизвестное устройство';
|
||||
}
|
||||
|
||||
$browser = match (true) {
|
||||
str_contains($ua, 'Firefox/') => 'Firefox',
|
||||
str_contains($ua, 'Edg/') => 'Edge',
|
||||
str_contains($ua, 'OPR/') || str_contains($ua, 'Opera') => 'Opera',
|
||||
str_contains($ua, 'Chrome/') => 'Chrome',
|
||||
str_contains($ua, 'Safari/') => 'Safari',
|
||||
default => 'Браузер',
|
||||
};
|
||||
|
||||
$os = match (true) {
|
||||
str_contains($ua, 'Windows') => 'Windows',
|
||||
str_contains($ua, 'Android') => 'Android',
|
||||
str_contains($ua, 'iPhone') || str_contains($ua, 'iPad') => 'iOS',
|
||||
str_contains($ua, 'Mac OS') || str_contains($ua, 'Macintosh') => 'macOS',
|
||||
str_contains($ua, 'Linux') => 'Linux',
|
||||
default => '',
|
||||
};
|
||||
|
||||
return $os !== '' ? "{$browser}, {$os}" : $browser;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LegalEntity;
|
||||
use App\Models\PaymentGateway;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
/**
|
||||
* SaaS-admin: ввод секретных ключей платёжного шлюза.
|
||||
*
|
||||
* config хранится Crypt::encrypt — ключи не попадают в БД в открытом виде и
|
||||
* не возвращаются обратно клиенту. На MVP без auth-middleware (как остальные
|
||||
* /api/admin/* эндпоинты); production — middleware('auth:saas-admin').
|
||||
*/
|
||||
class AdminPaymentGatewayController extends Controller
|
||||
{
|
||||
public function update(Request $request, string $code): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'shop_id' => ['required', 'string', 'max:255'],
|
||||
'secret_key' => ['required', 'string', 'max:255'],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
'legal_entity_id' => ['nullable', 'integer', 'exists:legal_entities,id'],
|
||||
]);
|
||||
|
||||
$gw = PaymentGateway::firstOrNew(['code' => $code]);
|
||||
|
||||
// legal_entity_id обязателен (NOT NULL FK). Берём из запроса, иначе первое юрлицо.
|
||||
if ($gw->legal_entity_id === null) {
|
||||
$legalEntityId = $validated['legal_entity_id'] ?? LegalEntity::query()->min('id');
|
||||
if ($legalEntityId === null) {
|
||||
return response()->json([
|
||||
'message' => 'Сначала заведите юридическое лицо (реквизиты получателя платежей).',
|
||||
], 422);
|
||||
}
|
||||
$gw->legal_entity_id = (int) $legalEntityId;
|
||||
}
|
||||
|
||||
$gw->name ??= 'ЮKassa';
|
||||
$gw->driver ??= $code;
|
||||
$gw->config = Crypt::encrypt([
|
||||
'shop_id' => $validated['shop_id'],
|
||||
'secret_key' => $validated['secret_key'],
|
||||
]);
|
||||
$gw->is_active = $validated['is_active'];
|
||||
$gw->min_amount_rub ??= '100.00';
|
||||
$gw->save();
|
||||
|
||||
return response()->json(['status' => 'ok', 'code' => $gw->code, 'is_active' => $gw->is_active]);
|
||||
}
|
||||
}
|
||||
@@ -67,7 +67,7 @@ final class AdminPricingTiersController extends Controller
|
||||
'tiers.*.tier_no' => ['required', 'integer', 'between:1,7'],
|
||||
'tiers.*.leads_in_tier' => ['nullable', 'integer', 'min:1'],
|
||||
'tiers.*.price_rub' => ['required', 'string', 'regex:/^\d+(\.\d{1,2})?$/'],
|
||||
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after_or_equal:'.$todayMsk],
|
||||
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],
|
||||
]);
|
||||
|
||||
/** @var array<int, array{tier_no:int, leads_in_tier:?int, price_rub:string|float}> $tiers */
|
||||
@@ -163,13 +163,6 @@ final class AdminPricingTiersController extends Controller
|
||||
*/
|
||||
private function resolveAdminUserId(Request $request): int
|
||||
{
|
||||
// Прод: crm_app_user не имеет прав на saas_admin_users → берём системный
|
||||
// admin-id из конфига, не обращаясь к таблице. null (dev/test) → fallback ниже.
|
||||
$configured = config('admin.audit_system_user_id');
|
||||
if ($configured !== null) {
|
||||
return (int) $configured;
|
||||
}
|
||||
|
||||
$requested = $request->input('admin_user_id');
|
||||
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
|
||||
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
|
||||
|
||||
@@ -7,12 +7,11 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Mail\SuspiciousLoginNotification;
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\UserSessionTracker;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -94,23 +93,6 @@ class AuthController extends Controller
|
||||
|
||||
if (! $user->is_active) {
|
||||
RateLimiter::hit($throttleKey, self::LOGIN_DECAY_SECONDS);
|
||||
|
||||
// Косяк 05: неподтверждённая почта — это НЕ блокировка. Новый клиент
|
||||
// создаётся is_active=false до ввода кода из письма. Не пугаем
|
||||
// «Аккаунт заблокирован», а зовём подтвердить почту.
|
||||
if ($user->email_verified_at === null) {
|
||||
$this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(),
|
||||
'email_not_confirmed');
|
||||
|
||||
$msg = 'Подтвердите почту — мы отправили код на '.$user->email.'.';
|
||||
|
||||
return response()->json([
|
||||
'message' => $msg,
|
||||
'errors' => ['email' => [$msg]],
|
||||
'email_not_confirmed' => true,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$this->logAuthEvent('login_failed', $user->id, $user->tenant_id, $credentials['email'], $ip, $request->userAgent(),
|
||||
'account_locked');
|
||||
|
||||
@@ -142,7 +124,6 @@ class AuthController extends Controller
|
||||
$user->update(['last_login_at' => now()]);
|
||||
|
||||
$this->logAuthEvent('login_success', $user->id, $user->tenant_id, $user->email, $ip, $request->userAgent(), null);
|
||||
app(UserSessionTracker::class)->record($request, $user->id);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
@@ -150,25 +131,46 @@ class AuthController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(RegisterRequest $request): JsonResponse
|
||||
{
|
||||
// На MVP — attach нового user'а к первому tenant'у (для UI-разводки).
|
||||
// Production: wizard с tenant_name + ИНН + создание Tenant + первый user owner-роли.
|
||||
$tenant = Tenant::first();
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
'message' => 'Tenants не настроены. Обратитесь к администратору.',
|
||||
], 503);
|
||||
}
|
||||
|
||||
$user = User::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'email' => $request->string('email')->toString(),
|
||||
'password_hash' => Hash::make($request->string('password')->toString()),
|
||||
'first_name' => 'Новый',
|
||||
'last_name' => 'Пользователь',
|
||||
'is_active' => true,
|
||||
'totp_enabled' => false,
|
||||
]);
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
'requires_2fa' => false,
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function me(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$resource = $this->userResource($user);
|
||||
|
||||
$marker = $request->hasSession() ? $request->session()->get('impersonation') : null;
|
||||
if ($marker !== null) {
|
||||
$token = ImpersonationToken::on('pgsql_supplier')->find($marker['token_id']);
|
||||
$tenant = $token?->tenant;
|
||||
$resource['impersonation'] = [
|
||||
'active' => true,
|
||||
'tenant_name' => $tenant?->organization_name,
|
||||
'started_at' => $marker['started_at'] ?? null,
|
||||
'expires_at' => $token?->sessionExpiresAt()?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json(['user' => $resource]);
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
@@ -177,9 +179,6 @@ class AuthController extends Controller
|
||||
$tenantId = $request->user()?->tenant_id;
|
||||
$email = $request->user()?->email;
|
||||
|
||||
// Снять текущую сессию из списка «Активные» до инвалидации (id ещё прежний).
|
||||
app(UserSessionTracker::class)->revokeCurrent($request);
|
||||
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
|
||||
@@ -13,10 +13,6 @@ use App\Models\User;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalanceToLeadsConverter;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use App\Services\Billing\Gateway\PaymentGatewayManager;
|
||||
use App\Services\Billing\OnlineTopupService;
|
||||
use App\Services\Billing\RunwayCalculator;
|
||||
use App\Support\SystemSettings;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -42,9 +38,8 @@ class BillingController extends Controller
|
||||
/**
|
||||
* POST /api/billing/topup — пополнить рублёвый баланс.
|
||||
*
|
||||
* Развилка: если флаг billing_yookassa_enabled ВКЛ — создаём платёж через
|
||||
* шлюз и возвращаем confirmation_url (баланс не меняется до webhook).
|
||||
* Если ВЫКЛ — MVP-stub мгновенного зачисления (текущее прод-поведение до Б-1).
|
||||
* MVP-stub: кредитует баланс немедленно (без ЮKassa — реальная оплата
|
||||
* post-Б-1). Записывает append-only строку balance_transactions(topup).
|
||||
*/
|
||||
public function topup(Request $request): JsonResponse
|
||||
{
|
||||
@@ -54,25 +49,10 @@ class BillingController extends Controller
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
// Нормализуем в DECIMAL-строку scale 2 для bcmath (НЕ float).
|
||||
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
|
||||
|
||||
// Развилка: реальный шлюз (флаг ВКЛ) ИЛИ мгновенная заглушка (флаг ВЫКЛ).
|
||||
if (SystemSettings::bool('billing_yookassa_enabled')) {
|
||||
$manager = app(PaymentGatewayManager::class);
|
||||
$gateway = $manager->activeGateway();
|
||||
if ($gateway === null) {
|
||||
return response()->json(['message' => 'Платёжный шлюз не настроен.'], 503);
|
||||
}
|
||||
|
||||
$returnUrl = rtrim((string) config('app.url'), '/').'/billing?topup=return';
|
||||
$result = app(OnlineTopupService::class)->start(
|
||||
(int) $user->tenant_id, $amountRub, $gateway, $returnUrl, (int) $user->id
|
||||
);
|
||||
|
||||
return response()->json(['confirmation_url' => $result->confirmationUrl], 201);
|
||||
}
|
||||
|
||||
// Заглушка (текущее прод-поведение до Б-1): мгновенное зачисление.
|
||||
$tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id);
|
||||
|
||||
return response()->json([
|
||||
@@ -336,8 +316,21 @@ class BillingController extends Controller
|
||||
*/
|
||||
private function runwayDays(Tenant $tenant, int $affordableLeads): ?int
|
||||
{
|
||||
// F3 (17.06.2026): единый источник расчёта — RunwayCalculator (общий с дашбордом),
|
||||
// чтобы прогноз «хватит на дни» не расходился между биллингом и дашбордом.
|
||||
return app(RunwayCalculator::class)->daysLeft((int) $tenant->id, $affordableLeads);
|
||||
if ($affordableLeads <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$leadsLast30Days = (int) DB::table('lead_charges')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('charged_at', '>=', now()->subDays(30))
|
||||
->count();
|
||||
|
||||
if ($leadsLast30Days <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$avgPerDay = $leadsLast30Days / 30.0;
|
||||
|
||||
return max(0, (int) floor($affordableLeads / $avgPerDay));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalanceToLeadsConverter;
|
||||
use App\Services\Billing\RunwayCalculator;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -107,35 +103,13 @@ class DashboardController extends Controller
|
||||
->map(fn ($c) => (int) $c)
|
||||
->toArray();
|
||||
|
||||
// --- runway (F3, 17.06.2026: единый источник с биллингом) ---
|
||||
// Раньше дашборд считал от legacy `balance_leads` (после Billing v2 ≈0
|
||||
// для рублёвых тенантов) → расходился с биллингом «0 дней ↔ N дней».
|
||||
// Теперь — affordable leads от рублёвого баланса по тарифу
|
||||
// (BalanceToLeadsConverter) + общий RunwayCalculator.
|
||||
$activeTiers = app(PricingTierRepository::class)
|
||||
->activeAt(Carbon::now('Europe/Moscow'));
|
||||
$conversion = app(BalanceToLeadsConverter::class)->convert(
|
||||
(string) $tenant->balance_rub,
|
||||
(int) ($tenant->delivered_in_month ?? 0),
|
||||
$activeTiers,
|
||||
);
|
||||
$affordableLeads = (int) $conversion['leads'];
|
||||
// B1-2 (UX-аудит 25.06): null (нет активных проектов) НЕ приводим к 0 —
|
||||
// иначе дашборд врал «хватит на 0 дней» при полном балансе, расходясь с
|
||||
// биллингом «∞». null → фронт показывает «нет активных проектов».
|
||||
$runwayDays = app(RunwayCalculator::class)
|
||||
->daysLeft($tenantId, $affordableLeads);
|
||||
|
||||
// --- средняя стоимость лида (F5): среднее фактически списанных rub-сумм
|
||||
// за окно периода. Только charge_source='rub' (у prepaid цена 0 по CHECK —
|
||||
// иначе среднее занижается); источник тот же, что у карточки сделки (F2).
|
||||
// null, если в окне нет rub-списаний (ничего ещё не списано).
|
||||
$avgKopecks = DB::table('lead_charges')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('charge_source', 'rub')
|
||||
->whereBetween('charged_at', [$windowStart, $now])
|
||||
->avg('price_per_lead_kopecks');
|
||||
$avgLeadCostRub = $avgKopecks !== null ? round((float) $avgKopecks / 100, 2) : null;
|
||||
// --- runway ---
|
||||
// runway опирается на приток за фиксированное 7-дневное окно,
|
||||
// независимо от выбранного range (для today/30d $curLeads — не 7-дневный).
|
||||
$leads7d = (clone $base())->whereBetween('received_at', [$now->subDays(7), $now])->count();
|
||||
$avgDaily = $leads7d / 7.0;
|
||||
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
|
||||
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
|
||||
|
||||
return [
|
||||
'range' => $range,
|
||||
@@ -145,11 +119,10 @@ class DashboardController extends Controller
|
||||
'balance' => [
|
||||
'amount_rub' => (string) $tenant->balance_rub,
|
||||
'runway_days' => $runwayDays,
|
||||
'runway_leads' => $affordableLeads,
|
||||
'runway_leads' => $balanceLeads,
|
||||
],
|
||||
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
|
||||
'funnel' => (object) $funnel,
|
||||
'avg_lead_cost_rub' => $avgLeadCostRub,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\ActivityLog;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLeadCost;
|
||||
use App\Models\User;
|
||||
@@ -103,6 +102,13 @@ class DealController extends Controller
|
||||
// whereNotNull('deleted_at') фильтрует только удалённые.
|
||||
$query = Deal::query()
|
||||
->select('deals.*')
|
||||
->addSelect(['next_reminder_at' => DB::table('reminders')
|
||||
->select('remind_at')
|
||||
->whereColumn('reminders.deal_id', 'deals.id')
|
||||
->whereNull('reminders.completed_at')
|
||||
->orderBy('remind_at')
|
||||
->limit(1),
|
||||
])
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
|
||||
|
||||
@@ -188,24 +194,6 @@ class DealController extends Controller
|
||||
return response()->json(['total' => $total]);
|
||||
}
|
||||
|
||||
// U4 (UX-аудит 25.06): стоимость лида должна быть видна и в листинге
|
||||
// (панель деталей и канбан рисуют из строки листинга, show отдельно не
|
||||
// дозапрашивают). Запрос — в своей транзакции с app.current_tenant_id,
|
||||
// иначе RLS на lead_charges вернёт 0 строк (как в show). rub-провенанс.
|
||||
$costByDeal = collect();
|
||||
$dealIds = $deals->pluck('id')->all();
|
||||
if ($dealIds !== []) {
|
||||
$costByDeal = DB::transaction(function () use ($tenantId, $dealIds) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
return LeadCharge::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->whereIn('deal_id', $dealIds)
|
||||
->where('charge_source', 'rub')
|
||||
->pluck('price_per_lead_kopecks', 'deal_id');
|
||||
});
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'deals' => $deals->map(fn (Deal $d) => [
|
||||
'id' => $d->id,
|
||||
@@ -229,7 +217,9 @@ class DealController extends Controller
|
||||
'project_signal_identifier' => $d->project?->signal_identifier,
|
||||
'project_sms_keyword' => $d->project?->sms_keyword,
|
||||
'project_sms_senders' => $d->project?->sms_senders,
|
||||
'cost_kopecks' => $costByDeal[$d->id] ?? null,
|
||||
'next_reminder_at' => $d->next_reminder_at
|
||||
? Carbon::parse($d->next_reminder_at)->toIso8601String()
|
||||
: null,
|
||||
]),
|
||||
'limit' => $limit,
|
||||
'next_cursor' => $nextCursor,
|
||||
@@ -256,7 +246,7 @@ class DealController extends Controller
|
||||
{
|
||||
$tenantId = (int) $request->user()->tenant_id;
|
||||
|
||||
[$deal, $events, $charge] = DB::transaction(function () use ($tenantId, $id) {
|
||||
[$deal, $events] = DB::transaction(function () use ($tenantId, $id) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$deal = Deal::query()
|
||||
@@ -266,7 +256,7 @@ class DealController extends Controller
|
||||
->first();
|
||||
|
||||
if ($deal === null) {
|
||||
return [null, [], null];
|
||||
return [null, []];
|
||||
}
|
||||
|
||||
$events = ActivityLog::query()
|
||||
@@ -278,14 +268,7 @@ class DealController extends Controller
|
||||
->limit(50)
|
||||
->get();
|
||||
|
||||
// F2: реальная стоимость лида — снимок списания из lead_charges
|
||||
// (rub-провенанс). Запрос в транзакции, где выставлен app.current_tenant_id.
|
||||
$charge = LeadCharge::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('deal_id', $id)
|
||||
->first();
|
||||
|
||||
return [$deal, $events, $charge];
|
||||
return [$deal, $events];
|
||||
});
|
||||
|
||||
if ($deal === null) {
|
||||
@@ -326,8 +309,6 @@ class DealController extends Controller
|
||||
'project_signal_identifier' => $deal->project?->signal_identifier,
|
||||
'project_sms_keyword' => $deal->project?->sms_keyword,
|
||||
'project_sms_senders' => $deal->project?->sms_senders,
|
||||
// F2: стоимость лида = снимок rub-списания (копейки) или null (prepaid/не списано).
|
||||
'cost_kopecks' => ($charge && $charge->charge_source === 'rub') ? $charge->price_per_lead_kopecks : null,
|
||||
],
|
||||
'events' => $events->map(fn (ActivityLog $e) => [
|
||||
'id' => $e->id,
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Support\CsvFormulaGuard;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -123,15 +122,12 @@ class DealExportController extends Controller
|
||||
$signal = $deal->project?->signal_type;
|
||||
$source = trim(($deal->project?->name ?? '—').' · '
|
||||
.(self::SIGNAL_LABELS[$signal] ?? '—'));
|
||||
// F-CSV: свободный текст (телефон/источник/город/статус/
|
||||
// комментарий) экранируем от formula-инъекции. Дата —
|
||||
// системная, не экранируется.
|
||||
$writer->addRow(Row::fromValues([
|
||||
CsvFormulaGuard::neutralize((string) $deal->phone),
|
||||
CsvFormulaGuard::neutralize($source),
|
||||
CsvFormulaGuard::neutralize((string) ($deal->city ?? '')),
|
||||
CsvFormulaGuard::neutralize((string) ($statusNames[$deal->status] ?? $deal->status)),
|
||||
CsvFormulaGuard::neutralize((string) ($deal->comment ?? '')),
|
||||
(string) $deal->phone,
|
||||
$source,
|
||||
(string) ($deal->city ?? ''),
|
||||
(string) ($statusNames[$deal->status] ?? $deal->status),
|
||||
(string) ($deal->comment ?? ''),
|
||||
$deal->received_at?->toDateTimeString() ?? '',
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -5,19 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\ImpersonationCodeMail;
|
||||
use App\Mail\ImpersonationEndedMail;
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Pd\ImpersonationAuditService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* SaaS-admin impersonation flow (ТЗ §22.7 / Ю-1).
|
||||
@@ -46,8 +39,6 @@ class ImpersonationController extends Controller
|
||||
|
||||
private const MAX_FAILED_ATTEMPTS = 5;
|
||||
|
||||
private const SESSION_TTL_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* SaaS-admin — кросс-тенантная зона: запросы к impersonation_tokens / tenants
|
||||
* идут через BYPASSRLS-подключение pgsql_supplier (роль crm_supplier_worker).
|
||||
@@ -143,12 +134,7 @@ class ImpersonationController extends Controller
|
||||
|
||||
$audit->recordInit($token, adminId: $requestedBy, ip: $request->ip());
|
||||
|
||||
try {
|
||||
Mail::to((string) $tenant->contact_email)
|
||||
->queue(new ImpersonationCodeMail($plainCode, (string) $tenant->contact_email));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('impersonation init: не удалось поставить письмо с кодом: '.$e->getMessage());
|
||||
}
|
||||
// TODO: отправить email на $tenant->contact_email с $plainCode.
|
||||
$payload = [
|
||||
'token_id' => $token->id,
|
||||
'expires_at' => $token->expires_at->toIso8601String(),
|
||||
@@ -204,33 +190,10 @@ class ImpersonationController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
// Success: целевой пользователь тенанта = самый ранний активный.
|
||||
$targetUser = User::on(self::DB_CONNECTION)
|
||||
->where('tenant_id', $token->tenant_id)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->first();
|
||||
|
||||
if ($targetUser === null) {
|
||||
return response()->json(['message' => 'У тенанта нет активного пользователя для входа.'], 422);
|
||||
}
|
||||
|
||||
// Машинный ключ для ИИ: lpimp_<id>_<secret>. Храним только хеш секрета.
|
||||
$secret = Str::random(48);
|
||||
$machineToken = 'lpimp_'.$token->id.'_'.$secret;
|
||||
|
||||
// Success: mark used. Создание saas_admin_session с
|
||||
// impersonating_token_id — отдельный коммит после saas-admin auth.
|
||||
$token->update([
|
||||
'used_at' => now(),
|
||||
'session_token_hash' => Hash::make($secret),
|
||||
]);
|
||||
|
||||
// Путь человека: логиним браузер целевым пользователем + маркер impersonation в сессию.
|
||||
Auth::login($targetUser);
|
||||
$request->session()->put('impersonation', [
|
||||
'token_id' => $token->id,
|
||||
'tenant_id' => $token->tenant_id,
|
||||
'target_user_id' => $targetUser->id,
|
||||
'started_at' => now()->toIso8601String(),
|
||||
]);
|
||||
|
||||
$audit->recordVerify($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
@@ -239,8 +202,6 @@ class ImpersonationController extends Controller
|
||||
'token_id' => $token->id,
|
||||
'tenant_id' => $token->tenant_id,
|
||||
'used_at' => $token->used_at->toIso8601String(),
|
||||
'expires_at' => $token->sessionExpiresAt(self::SESSION_TTL_MINUTES)->toIso8601String(),
|
||||
'machine_token' => $machineToken,
|
||||
'message' => 'Impersonation начат. Сессия активна 1 час.',
|
||||
]);
|
||||
}
|
||||
@@ -271,12 +232,7 @@ class ImpersonationController extends Controller
|
||||
|
||||
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
|
||||
try {
|
||||
Mail::to((string) $token->sent_to_email)
|
||||
->queue(new ImpersonationEndedMail((string) $token->sent_to_email));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('impersonation end mail: '.$e->getMessage());
|
||||
}
|
||||
// TODO: уведомление клиенту по email о завершении (как и в init flow).
|
||||
|
||||
return response()->json([
|
||||
'token_id' => $token->id,
|
||||
@@ -284,35 +240,4 @@ class ImpersonationController extends Controller
|
||||
'message' => 'Impersonation завершён.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/impersonation/leave — завершить свою impersonation-сессию из кабинета.
|
||||
*
|
||||
* Маркер `impersonation` из сессии НЕ удаляется здесь намеренно:
|
||||
* ImpersonationContext (global web middleware) на следующем запросе
|
||||
* обнаружит isSessionActive()=false и вернёт 401 явно, не доходя до auth:sanctum.
|
||||
* Это обеспечивает корректный 401 как в реальном браузере, так и в тест-среде
|
||||
* (где Auth::guard('web')->logout() может не повлиять на кэш sanctum-guard).
|
||||
*/
|
||||
public function leave(Request $request, ImpersonationAuditService $audit): JsonResponse
|
||||
{
|
||||
$marker = $request->session()->get('impersonation');
|
||||
if ($marker === null) {
|
||||
return response()->json(['message' => 'Сессия impersonation не активна.'], 422);
|
||||
}
|
||||
|
||||
$token = ImpersonationToken::on(self::DB_CONNECTION)->find($marker['token_id']);
|
||||
if ($token !== null && $token->session_ended_at === null) {
|
||||
$token->update(['session_ended_at' => now()]);
|
||||
$audit->recordEnd($token, adminId: (int) $token->requested_by, ip: $request->ip());
|
||||
try {
|
||||
Mail::to((string) $token->sent_to_email)
|
||||
->queue(new ImpersonationEndedMail((string) $token->sent_to_email));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('impersonation leave mail: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Вы вышли из режима поддержки.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PaymentGateway;
|
||||
use App\Models\SaasTransaction;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use App\Services\Billing\Gateway\PaymentGatewayDriver;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
|
||||
/**
|
||||
* Приём webhook от платёжного шлюза (ЮKassa). Публичный роут (без auth/tenant),
|
||||
* URL под маской api/webhook/* → CSRF-exempt (bootstrap/app.php).
|
||||
*
|
||||
* Подлинность: НЕ доверяем телу webhook — по object.id делаем server-to-server
|
||||
* сверку через драйвер (GET /payments/{id}) и верим статусу из ответа шлюза.
|
||||
*
|
||||
* RLS: webhook вне tenant-сессии. Поиск платежа cross-tenant — через
|
||||
* BYPASSRLS-соединение pgsql_supplier (как джобы, Plan 3/4). Зачисление —
|
||||
* под app.current_tenant_id (SET LOCAL внутри транзакции, как BalancePreflightSweepJob).
|
||||
*
|
||||
* Идемпотентность: атомарный claim pending→success (UPDATE ... WHERE status='pending').
|
||||
* Повторный webhook → claimed=0 → no-op, 200 OK без двойного зачисления.
|
||||
*
|
||||
* Зачисление денег делегируется BillingTopupService (lockForUpdate + append-only ledger).
|
||||
*/
|
||||
class PaymentWebhookController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PaymentGatewayDriver $driver,
|
||||
private readonly BillingTopupService $topupService,
|
||||
) {}
|
||||
|
||||
public function receive(Request $request): JsonResponse
|
||||
{
|
||||
// Defense-in-depth: IP-allowlist ЮKassa. Fail-open при пустом списке —
|
||||
// не ломаем легитимный поток; на проде заполнить YOOKASSA_WEBHOOK_IPS
|
||||
// опубликованными ЮKassa подсетями, чтобы аноним не дёргал endpoint.
|
||||
$allowlist = array_values(array_filter((array) config('services.yookassa.webhook_ip_allowlist', [])));
|
||||
if ($allowlist !== [] && ! IpUtils::checkIp((string) $request->ip(), $allowlist)) {
|
||||
return response()->json(['status' => 'ignored'], 200);
|
||||
}
|
||||
|
||||
$paymentId = (string) $request->input('object.id', '');
|
||||
if ($paymentId === '') {
|
||||
return response()->json(['status' => 'ignored'], 200);
|
||||
}
|
||||
|
||||
// Cross-tenant поиск платежа под BYPASSRLS-ролью (tenant ещё неизвестен).
|
||||
$tx = DB::connection('pgsql_supplier')->table('saas_transactions')
|
||||
->where('gateway_payment_id', $paymentId)
|
||||
->first();
|
||||
if ($tx === null) {
|
||||
return response()->json(['status' => 'unknown'], 200);
|
||||
}
|
||||
|
||||
$gateway = $this->gatewayFor($tx);
|
||||
|
||||
// Server-to-server сверка — источник правды о статусе.
|
||||
$verify = $this->driver->verifyPayment($gateway, $paymentId);
|
||||
if (! $verify->isSucceeded()) {
|
||||
return response()->json(['status' => 'not_paid'], 200);
|
||||
}
|
||||
|
||||
// Confused-deputy: сверенный платёж должен быть РОВНО тем, что в webhook.
|
||||
if ($verify->gatewayPaymentId !== $paymentId) {
|
||||
return response()->json(['status' => 'ignored'], 200);
|
||||
}
|
||||
|
||||
// Защита от чужой валюты с тем же числом — зачисляем только рубли.
|
||||
if ($verify->currency !== 'RUB') {
|
||||
return response()->json(['status' => 'currency_mismatch'], 200);
|
||||
}
|
||||
|
||||
// Защита: оплаченная сумма должна совпасть с запрошенной (scale 2).
|
||||
if (bccomp((string) $verify->amountRub, (string) $tx->amount_rub, 2) !== 0) {
|
||||
return response()->json(['status' => 'amount_mismatch'], 200);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tx, $verify) {
|
||||
// RLS-контекст для этой транзакции (PgBouncer-safe SET LOCAL).
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tx->tenant_id);
|
||||
|
||||
// Атомарно занимаем pending→success; 0 строк = уже зачислено (дубль/гонка).
|
||||
$claimed = SaasTransaction::where('id', $tx->id)
|
||||
->where('status', SaasTransaction::STATUS_PENDING)
|
||||
->update(['status' => SaasTransaction::STATUS_SUCCESS, 'completed_at' => now()]);
|
||||
|
||||
if ($claimed === 0) {
|
||||
return; // идемпотентный no-op
|
||||
}
|
||||
|
||||
$balanceTx = $this->topupService->topup(
|
||||
(int) $tx->tenant_id, (string) $tx->amount_rub, null
|
||||
);
|
||||
|
||||
SaasTransaction::where('id', $tx->id)->update([
|
||||
'balance_rub_after' => $balanceTx->balance_rub_after,
|
||||
'payment_method' => $verify->paymentMethod,
|
||||
'balance_transaction_id' => $balanceTx->id, // provenance: оплата → строка журнала
|
||||
]);
|
||||
});
|
||||
|
||||
return response()->json(['status' => 'ok'], 200);
|
||||
}
|
||||
|
||||
private function gatewayFor(object $tx): PaymentGateway
|
||||
{
|
||||
return $tx->gateway_id !== null
|
||||
? PaymentGateway::findOrFail($tx->gateway_id)
|
||||
: PaymentGateway::where('code', $tx->gateway_code)->firstOrFail();
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,11 @@ use App\Http\Requests\StoreProjectRequest;
|
||||
use App\Http\Requests\UpdateProjectRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Project\ProjectService;
|
||||
use App\Services\Requisites\RequisitesService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
@@ -30,17 +29,13 @@ use Illuminate\Http\Request;
|
||||
*/
|
||||
class ProjectController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProjectService $projects,
|
||||
private readonly RequisitesService $requisites,
|
||||
) {}
|
||||
public function __construct(private readonly ProjectService $projects) {}
|
||||
|
||||
/** GET /api/projects */
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$query = Project::query()
|
||||
->with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1 in aggregation helpers
|
||||
->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1 (hasLinks без per-row запроса)
|
||||
->where('tenant_id', $request->user()->tenant_id);
|
||||
|
||||
// Batch-fetch по ids — возвращает без пагинации (для dropdown'ов и т.п.)
|
||||
@@ -127,13 +122,6 @@ class ProjectController extends Controller
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$tenant = $request->user()->tenant;
|
||||
|
||||
// G1/SP2: гейт первого проекта — нельзя создать первый проект без минимальных реквизитов.
|
||||
if (Project::where('tenant_id', $tenant->id)->count() === 0
|
||||
&& ! $this->requisites->isLightComplete($tenant)) {
|
||||
return response()->json(['error' => 'requisites_required'], 422);
|
||||
}
|
||||
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
|
||||
@@ -162,7 +150,7 @@ class ProjectController extends Controller
|
||||
|
||||
$project = $this->projects->create($tenant, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project->loadCount('supplierProjects'))], 201);
|
||||
return response()->json(['data' => new ProjectResource($project)], 201);
|
||||
}
|
||||
|
||||
/** PATCH /api/projects/{id} */
|
||||
@@ -203,7 +191,7 @@ class ProjectController extends Controller
|
||||
|
||||
$updated = $this->projects->update($project, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($updated->loadCount('supplierProjects'))]);
|
||||
return response()->json(['data' => new ProjectResource($updated)]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,8 +199,7 @@ class ProjectController extends Controller
|
||||
*/
|
||||
private function runPreflight(Tenant $tenant, int $requiredLeads): array
|
||||
{
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
$tiers = PricingTier::query()->where('is_active', true)->get();
|
||||
|
||||
// Safe fallback: без активных pricing_tiers биллинг не настроен —
|
||||
// преfflight не имеет смысла, пропускаем (legacy-окружения / тесты).
|
||||
@@ -238,7 +225,6 @@ class ProjectController extends Controller
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::with(['supplierB1', 'supplierB2', 'supplierB3']) // eager-load to avoid N+1
|
||||
->withCount('supplierProjects') // ProjectResource::source_locked — анти-N+1
|
||||
->where('tenant_id', $request->user()->tenant_id)
|
||||
->findOrFail($id);
|
||||
|
||||
@@ -268,24 +254,13 @@ class ProjectController extends Controller
|
||||
{
|
||||
$request->validate(['is_active' => ['required', 'boolean']]);
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||||
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта.
|
||||
$newActive = $request->boolean('is_active');
|
||||
$project->update([
|
||||
'is_active' => $newActive,
|
||||
'paused_at' => $newActive ? null : now(),
|
||||
]);
|
||||
$project->update(['is_active' => $request->boolean('is_active')]);
|
||||
|
||||
// #10: pause/resume must reach the supplier. The job's group recompute pushes
|
||||
// status=paused when no active project of the group remains (resume → active).
|
||||
// G (балансовый блок): заблокированный за нехваткой баланса проект не
|
||||
// возобновляется/синхронизируется у поставщика (зеркалит create-гард).
|
||||
if ($project->preflight_blocked_at === null) {
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
}
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project->fresh()->loadCount('supplierProjects'))]);
|
||||
return response()->json(['data' => new ProjectResource($project->fresh())]);
|
||||
}
|
||||
|
||||
/** POST /api/projects/bulk — batch pause/resume/delete/update_regions/update_days/update_limit */
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* Публичный (без auth) список текущей тарифной сетки для страницы цен.
|
||||
*
|
||||
* Read-only, без ПДн. Переиспользует PricingTierRepository::activeAt.
|
||||
* Требование ЮKassa: цены должны быть публично доступны на сайте.
|
||||
*/
|
||||
class PublicPricingController extends Controller
|
||||
{
|
||||
public function index(PricingTierRepository $repo): JsonResponse
|
||||
{
|
||||
$tiers = $repo->activeAt(CarbonImmutable::now())
|
||||
->map(fn ($t) => [
|
||||
'tier_no' => (int) $t->tier_no,
|
||||
'leads_in_tier' => $t->leads_in_tier === null ? null : (int) $t->leads_in_tier,
|
||||
'price_rub' => number_format((int) $t->price_per_lead_kopecks / 100, 2, '.', ''),
|
||||
])
|
||||
->values();
|
||||
|
||||
return response()->json(['tiers' => $tiers]);
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Concerns\WritesAuthLog;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\ConfirmEmailRequest;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Http\Requests\Auth\ResendCodeRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\RegistrationException;
|
||||
use App\Services\Auth\RegistrationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Самозапись клиента (G1/SP1): register → confirm-email → (вход).
|
||||
* Подтверждение почты 6-значным кодом; новый тенант создаётся в статусе
|
||||
* pending_email_confirm, активируется и получает 300 ₽ при подтверждении.
|
||||
*/
|
||||
class RegistrationController extends Controller
|
||||
{
|
||||
use WritesAuthLog;
|
||||
|
||||
public function register(RegisterRequest $request, RegistrationService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $service->register(
|
||||
$request->string('email')->toString(),
|
||||
$request->string('password')->toString(),
|
||||
$request->input('captcha_token'),
|
||||
$request->ip(),
|
||||
);
|
||||
} catch (RegistrationException $e) {
|
||||
return $this->registrationError($e);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'status' => $result['status'],
|
||||
'email' => $result['user']->email,
|
||||
'expires_at' => $result['verification']->expires_at->toIso8601String(),
|
||||
];
|
||||
if ($result['dev_code'] !== null) {
|
||||
$payload['_dev_plain_code'] = $result['dev_code'];
|
||||
}
|
||||
|
||||
return response()->json($payload, 201);
|
||||
}
|
||||
|
||||
public function confirmEmail(ConfirmEmailRequest $request, RegistrationService $service): JsonResponse
|
||||
{
|
||||
try {
|
||||
$user = $service->confirm(
|
||||
$request->string('email')->toString(),
|
||||
$request->string('code')->toString(),
|
||||
);
|
||||
} catch (RegistrationException $e) {
|
||||
$payload = ['message' => 'Код подтверждения недействителен.', 'reason' => $e->reason];
|
||||
if ($e->attemptsRemaining !== null) {
|
||||
$payload['attempts_remaining'] = $e->attemptsRemaining;
|
||||
}
|
||||
|
||||
return response()->json($payload, 422);
|
||||
}
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
$this->logAuthEvent('register_success', $user->id, $user->tenant_id, $user->email, $request->ip(), $request->userAgent(), null);
|
||||
|
||||
return response()->json([
|
||||
'user' => $this->userResource($user),
|
||||
'requires_2fa' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
public function resendCode(ResendCodeRequest $request, RegistrationService $service): JsonResponse
|
||||
{
|
||||
$devCode = $service->resend($request->string('email')->toString());
|
||||
|
||||
$payload = ['message' => 'Если аккаунт ожидает подтверждения, мы отправили новый код на указанный email.'];
|
||||
if ($devCode !== null) {
|
||||
$payload['_dev_plain_code'] = $devCode;
|
||||
}
|
||||
|
||||
return response()->json($payload);
|
||||
}
|
||||
|
||||
private function registrationError(RegistrationException $e): JsonResponse
|
||||
{
|
||||
$map = [
|
||||
'captcha_failed' => ['captcha_token', 'Проверка «я не робот» не пройдена.'],
|
||||
'email_taken' => ['email', 'Аккаунт с таким email уже существует.'],
|
||||
];
|
||||
[$field, $message] = $map[$e->reason] ?? ['email', 'Не удалось зарегистрировать аккаунт.'];
|
||||
|
||||
return response()->json([
|
||||
'message' => $message,
|
||||
'errors' => [$field => [$message]],
|
||||
], 422);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function userResource(User $user): array
|
||||
{
|
||||
return [
|
||||
'id' => $user->id,
|
||||
'email' => $user->email,
|
||||
'first_name' => $user->first_name,
|
||||
'last_name' => $user->last_name,
|
||||
'phone' => $user->phone,
|
||||
'timezone' => $user->timezone,
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'totp_enabled' => $user->totp_enabled,
|
||||
'last_login_at' => $user->last_login_at,
|
||||
'notification_preferences' => $user->notification_preferences,
|
||||
'sound_enabled' => $user->sound_enabled,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Reminder;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Reminders API (schema v8.10 §17.5). Все endpoint'ы под `auth:sanctum`.
|
||||
*
|
||||
* Фильтры filter= для GET /api/reminders:
|
||||
* today — completed_at IS NULL AND remind_at в (now-1d, now+1d)
|
||||
* upcoming — completed_at IS NULL AND remind_at > now+1d
|
||||
* overdue — completed_at IS NULL AND remind_at < now-1d
|
||||
* completed — completed_at IS NOT NULL
|
||||
* active — completed_at IS NULL (default)
|
||||
*
|
||||
* RLS: внутри транзакции SET LOCAL app.current_tenant_id = $user->tenant_id.
|
||||
* Защита от кражи: явный where('tenant_id', $user->tenant_id) поверх RLS.
|
||||
*/
|
||||
class ReminderController extends Controller
|
||||
{
|
||||
private const FILTERS = ['active', 'today', 'upcoming', 'overdue', 'completed'];
|
||||
|
||||
/**
|
||||
* GET /api/reminders?filter=&deal_id=&limit=
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'filter' => 'nullable|string|in:'.implode(',', self::FILTERS),
|
||||
'deal_id' => 'nullable|integer|min:1',
|
||||
'limit' => 'nullable|integer|min:1|max:200',
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$filter = $validated['filter'] ?? 'active';
|
||||
$limit = (int) ($validated['limit'] ?? 100);
|
||||
|
||||
return DB::transaction(function () use ($user, $filter, $validated, $limit): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$query = Reminder::query()
|
||||
->with('creator:id,email,first_name,last_name')
|
||||
->where('tenant_id', $user->tenant_id);
|
||||
|
||||
if (isset($validated['deal_id'])) {
|
||||
$query->where('deal_id', (int) $validated['deal_id']);
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
switch ($filter) {
|
||||
case 'today':
|
||||
$query->whereNull('completed_at')
|
||||
->whereBetween('remind_at', [$now->copy()->subDay(), $now->copy()->addDay()]);
|
||||
break;
|
||||
case 'upcoming':
|
||||
$query->whereNull('completed_at')
|
||||
->where('remind_at', '>', $now->copy()->addDay());
|
||||
break;
|
||||
case 'overdue':
|
||||
$query->whereNull('completed_at')
|
||||
->where('remind_at', '<', $now->copy()->subDay());
|
||||
break;
|
||||
case 'completed':
|
||||
$query->whereNotNull('completed_at');
|
||||
break;
|
||||
case 'active':
|
||||
default:
|
||||
$query->whereNull('completed_at');
|
||||
break;
|
||||
}
|
||||
|
||||
$items = $query->orderBy('remind_at')->limit($limit)->get();
|
||||
|
||||
// Counters для UI badges (today/upcoming/overdue) — отдельные SELECT'ы.
|
||||
$base = Reminder::query()->where('tenant_id', $user->tenant_id);
|
||||
$counts = [
|
||||
'today' => (clone $base)->whereNull('completed_at')
|
||||
->whereBetween('remind_at', [$now->copy()->subDay(), $now->copy()->addDay()])
|
||||
->count(),
|
||||
'upcoming' => (clone $base)->whereNull('completed_at')
|
||||
->where('remind_at', '>', $now->copy()->addDay())
|
||||
->count(),
|
||||
'overdue' => (clone $base)->whereNull('completed_at')
|
||||
->where('remind_at', '<', $now->copy()->subDay())
|
||||
->count(),
|
||||
'active' => (clone $base)->whereNull('completed_at')->count(),
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'items' => $items->map(fn (Reminder $r) => $this->toResource($r))->all(),
|
||||
'counts' => $counts,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/reminders {deal_id, text?, remind_at}.
|
||||
*/
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'deal_id' => 'required|integer|min:1',
|
||||
'text' => 'nullable|string|max:255',
|
||||
'remind_at' => 'required|date',
|
||||
'assignee_id' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
// Manager FK guard для assignee_id: должен принадлежать тому же tenant'у.
|
||||
if (isset($validated['assignee_id'])) {
|
||||
$exists = User::query()
|
||||
->where('id', $validated['assignee_id'])
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
if (! $exists) {
|
||||
return response()->json([
|
||||
'message' => 'Менеджер не найден в этом тенанте.',
|
||||
'errors' => ['assignee_id' => ['Не принадлежит вашему тенанту или не активен.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $validated): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$reminder = Reminder::create([
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'deal_id' => (int) $validated['deal_id'],
|
||||
'text' => $validated['text'] ?? null,
|
||||
'remind_at' => Carbon::parse($validated['remind_at']),
|
||||
'created_by' => $user->id,
|
||||
'assignee_id' => $validated['assignee_id'] ?? null,
|
||||
'is_sent' => false,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'reminder' => $this->toResource($reminder->load('creator:id,email,first_name,last_name')),
|
||||
], 201);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/reminders/{id} {text?, remind_at?, assignee_id?}.
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'text' => 'nullable|string|max:255',
|
||||
'remind_at' => 'nullable|date',
|
||||
'assignee_id' => 'nullable|integer|min:1',
|
||||
]);
|
||||
|
||||
if (count($validated) === 0) {
|
||||
return response()->json([
|
||||
'message' => 'Передайте хотя бы одно поле.',
|
||||
'errors' => ['_general' => ['Нужно хотя бы одно поле для обновления.']],
|
||||
], 422);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
if (isset($validated['assignee_id'])) {
|
||||
$exists = User::query()
|
||||
->where('id', $validated['assignee_id'])
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->whereNull('deleted_at')
|
||||
->where('is_active', true)
|
||||
->exists();
|
||||
if (! $exists) {
|
||||
return response()->json([
|
||||
'message' => 'Менеджер не найден.',
|
||||
'errors' => ['assignee_id' => ['Не принадлежит вашему тенанту или не активен.']],
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($user, $id, $validated): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$reminder = Reminder::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->first();
|
||||
|
||||
if ($reminder === null) {
|
||||
return response()->json(['message' => 'Напоминание не найдено.'], 404);
|
||||
}
|
||||
|
||||
$update = [];
|
||||
if (array_key_exists('text', $validated)) {
|
||||
$update['text'] = $validated['text'];
|
||||
}
|
||||
if (isset($validated['remind_at'])) {
|
||||
$update['remind_at'] = Carbon::parse($validated['remind_at']);
|
||||
// При сдвиге remind_at сбрасываем is_sent, чтобы cron смог
|
||||
// снова отправить уведомление к новому времени.
|
||||
$update['is_sent'] = false;
|
||||
$update['sent_at'] = null;
|
||||
}
|
||||
if (array_key_exists('assignee_id', $validated)) {
|
||||
$update['assignee_id'] = $validated['assignee_id'];
|
||||
}
|
||||
|
||||
$reminder->update($update);
|
||||
|
||||
return response()->json([
|
||||
'reminder' => $this->toResource($reminder->fresh('creator')),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/reminders/{id}/complete — пометить выполненным.
|
||||
* Идемпотентно: повторный вызов NO-OP.
|
||||
*/
|
||||
public function complete(Request $request, int $id): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
return DB::transaction(function () use ($user, $id): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$reminder = Reminder::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->first();
|
||||
|
||||
if ($reminder === null) {
|
||||
return response()->json(['message' => 'Напоминание не найдено.'], 404);
|
||||
}
|
||||
|
||||
if ($reminder->completed_at === null) {
|
||||
$reminder->update(['completed_at' => Carbon::now()]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'reminder' => $this->toResource($reminder->fresh('creator')),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/reminders/{id}.
|
||||
*/
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
return DB::transaction(function () use ($user, $id): JsonResponse {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
$deleted = Reminder::query()
|
||||
->where('id', $id)
|
||||
->where('tenant_id', $user->tenant_id)
|
||||
->delete();
|
||||
|
||||
if ($deleted === 0) {
|
||||
return response()->json(['message' => 'Напоминание не найдено.'], 404);
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Удалено.']);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function toResource(Reminder $reminder): array
|
||||
{
|
||||
$creator = $reminder->creator;
|
||||
|
||||
return [
|
||||
'id' => $reminder->id,
|
||||
'deal_id' => $reminder->deal_id,
|
||||
'text' => $reminder->text,
|
||||
'remind_at' => $reminder->remind_at?->toIso8601String(),
|
||||
'completed_at' => $reminder->completed_at?->toIso8601String(),
|
||||
'is_sent' => $reminder->is_sent,
|
||||
'sent_at' => $reminder->sent_at?->toIso8601String(),
|
||||
'created_at' => $reminder->created_at?->toIso8601String(),
|
||||
'created_by' => $reminder->created_by,
|
||||
'assignee_id' => $reminder->assignee_id,
|
||||
'creator_name' => $creator
|
||||
? trim(($creator->first_name ?? '').' '.($creator->last_name ?? '')) ?: $creator->email
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -44,22 +44,9 @@ class SupplierWebhookController extends Controller
|
||||
/** Audit-fix C2: per-IP rate-limit (DoS-guard), запросов в минуту. */
|
||||
private const RATE_LIMIT_PER_MINUTE = 600;
|
||||
|
||||
public function receive(Request $request, string $secret = ''): JsonResponse
|
||||
public function receive(Request $request, string $secret): JsonResponse
|
||||
{
|
||||
// Аутентификация (аддитивно): URL-секрет (backward-compat) ИЛИ HMAC-подпись
|
||||
// тела (X-Webhook-Signature = hash_hmac sha256 от raw body на том же
|
||||
// supplier_webhook_secret). HMAC позволяет поставщику не слать секрет в URL
|
||||
// — тот течёт в access-логи (P2/E4). verifySecret('') всегда false.
|
||||
$sig = (string) $request->header('X-Webhook-Signature', '');
|
||||
$sig = str_starts_with($sig, 'sha256=') ? substr($sig, 7) : $sig;
|
||||
$secretRow = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first();
|
||||
$expectedSecret = $secretRow !== null ? (string) $secretRow->value : '';
|
||||
$hmacValid = $sig !== ''
|
||||
&& $expectedSecret !== '__SET_ON_DEPLOY__'
|
||||
&& strlen($expectedSecret) >= 32
|
||||
&& hash_equals(hash_hmac('sha256', $request->getContent(), $expectedSecret), $sig);
|
||||
|
||||
if (! $this->verifySecret($secret) && ! $hmacValid) {
|
||||
if (! $this->verifySecret($secret)) {
|
||||
$this->logSupplierWebhook($request, null, 'rejected_secret');
|
||||
|
||||
return response()->json(['message' => 'Not found.'], 404);
|
||||
@@ -96,7 +83,7 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'vid' => 'required|integer|min:1',
|
||||
'project' => ['required', 'string', 'max:255'], // Phase 3: regex /^B[123]_.+$/ снят — non-B → platform=DIRECT
|
||||
'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'],
|
||||
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
|
||||
'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"],
|
||||
'tag' => 'nullable|string|max:255',
|
||||
@@ -195,12 +182,8 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
private function parsePlatform(string $project): string
|
||||
{
|
||||
// Phase 3: проекты без B-префикса → DIRECT (раньше silent fallback на 'B1'
|
||||
// приводил к неверной маршрутизации).
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
preg_match('/^(B[123])_/', $project, $m);
|
||||
|
||||
return 'DIRECT';
|
||||
return $m[1] ?? 'B1';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Mail\SupportRequestMail;
|
||||
use App\Models\SupportRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* G7-A: приём клиентских заявок в техподдержку. Запись в БД — основной канал;
|
||||
* письмо в поддержку — best-effort (сбой SMTP не валит запрос, паттерн G1 sendCode).
|
||||
*/
|
||||
class SupportRequestController extends Controller
|
||||
{
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'contact' => 'required|string|max:255',
|
||||
'message' => 'required|string|max:5000',
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
$supportRequest = DB::transaction(function () use ($user, $validated): SupportRequest {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $user->tenant_id);
|
||||
|
||||
return SupportRequest::create([
|
||||
'tenant_id' => $user->tenant_id,
|
||||
'user_id' => $user->id,
|
||||
'name' => $validated['name'],
|
||||
'contact' => $validated['contact'],
|
||||
'message' => $validated['message'],
|
||||
]);
|
||||
});
|
||||
|
||||
// Письмо — best-effort: заявка уже в БД, сбой почты не теряет её и не валит запрос.
|
||||
try {
|
||||
Mail::to(config('services.support.email'))->queue(new SupportRequestMail($supportRequest));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SupportRequestMail queue failed', ['id' => $supportRequest->id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => true], 201);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LookupInnRequest;
|
||||
use App\Http\Requests\UpdateRequisitesRequest;
|
||||
use App\Http\Resources\RequisitesResource;
|
||||
use App\Models\TenantRequisites;
|
||||
use App\Services\DaData\PartyLookup;
|
||||
use App\Services\Requisites\RequisitesService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TenantRequisitesController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly RequisitesService $service,
|
||||
private readonly PartyLookup $party,
|
||||
) {}
|
||||
|
||||
/** GET /api/tenant/requisites */
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$req = TenantRequisites::where('tenant_id', $request->user()->tenant_id)->first();
|
||||
|
||||
return response()->json(['data' => $req ? new RequisitesResource($req) : null]);
|
||||
}
|
||||
|
||||
/** PUT /api/tenant/requisites */
|
||||
public function update(UpdateRequisitesRequest $request): JsonResponse
|
||||
{
|
||||
$req = $this->service->upsert($request->user()->tenant, $request->validated());
|
||||
|
||||
return response()->json(['data' => new RequisitesResource($req)]);
|
||||
}
|
||||
|
||||
/** POST /api/tenant/requisites/lookup-inn — мягкая подтяжка, ничего не сохраняет */
|
||||
public function lookupInn(LookupInnRequest $request): JsonResponse
|
||||
{
|
||||
$res = $this->party->findByInn($request->validated()['inn']);
|
||||
if ($res === null) {
|
||||
return response()->json(['found' => false]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'found' => true,
|
||||
'legal_name' => $res->legalName,
|
||||
'kpp' => $res->kpp,
|
||||
'ogrn' => $res->ogrn,
|
||||
'legal_address' => $res->address,
|
||||
'subject_type_hint' => $res->type === 'INDIVIDUAL' ? 'sole_proprietor' : 'legal_entity',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ use App\Http\Requests\Auth\UseRecoveryCodeRequest;
|
||||
use App\Http\Requests\Auth\VerifyTwoFactorRequest;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecoveryCode;
|
||||
use App\Services\UserSessionTracker;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -98,7 +97,6 @@ class TwoFactorController extends Controller
|
||||
$request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']);
|
||||
|
||||
$user->update(['last_login_at' => now()]);
|
||||
app(UserSessionTracker::class)->record($request, $user->id);
|
||||
|
||||
$this->logAuthEvent(
|
||||
'2fa_verify_success',
|
||||
@@ -202,7 +200,6 @@ class TwoFactorController extends Controller
|
||||
$request->session()->forget(['auth.pending_user_id', 'auth.pending_remember']);
|
||||
|
||||
$user->update(['last_login_at' => now()]);
|
||||
app(UserSessionTracker::class)->record($request, $user->id);
|
||||
|
||||
$this->logAuthEvent(
|
||||
'2fa_recovery_used',
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Deal;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Публичный read-API сделок тенанта (G6). Аутентификация — middleware ApiKeyAuth
|
||||
* (tenant_id в request->attributes['api_tenant_id']). Только сделки (deals), не
|
||||
* supplier_leads.
|
||||
*/
|
||||
class DealsController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenantId = (int) $request->attributes->get('api_tenant_id');
|
||||
|
||||
$limit = max(1, min(500, (int) $request->query('limit', '100')));
|
||||
|
||||
$since = trim((string) $request->query('since', ''));
|
||||
$sinceDt = null;
|
||||
if ($since !== '') {
|
||||
try {
|
||||
$sinceDt = Carbon::parse($since);
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'Невалидный since.'], 422);
|
||||
}
|
||||
}
|
||||
|
||||
$cursorRaw = (string) $request->query('cursor', '');
|
||||
$cursor = null;
|
||||
if ($cursorRaw !== '') {
|
||||
$decoded = base64_decode($cursorRaw, true);
|
||||
$parsed = $decoded === false ? null : json_decode($decoded, true);
|
||||
if (! is_array($parsed) || ! isset($parsed['r'], $parsed['i'])) {
|
||||
return response()->json(['message' => 'Невалидный cursor.'], 422);
|
||||
}
|
||||
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
|
||||
}
|
||||
|
||||
[$rows, $next] = DB::transaction(function () use ($tenantId, $limit, $sinceDt, $cursor) {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
|
||||
|
||||
$query = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->with('project:id,name');
|
||||
|
||||
if ($sinceDt !== null) {
|
||||
$query->where('received_at', '>=', $sinceDt);
|
||||
}
|
||||
if ($cursor !== null) {
|
||||
$query->whereRaw('(received_at, id) < (?, ?)', [$cursor['r'], $cursor['i']]);
|
||||
}
|
||||
|
||||
$rows = $query->orderByDesc('received_at')->orderByDesc('id')
|
||||
->limit($limit + 1)->get();
|
||||
|
||||
$hasNext = $rows->count() > $limit;
|
||||
if ($hasNext) {
|
||||
$rows = $rows->slice(0, $limit)->values();
|
||||
}
|
||||
|
||||
$next = null;
|
||||
if ($hasNext && $rows->isNotEmpty()) {
|
||||
$last = $rows->last();
|
||||
$next = base64_encode((string) json_encode([
|
||||
'r' => $last->received_at->toIso8601String(),
|
||||
'i' => $last->id,
|
||||
]));
|
||||
}
|
||||
|
||||
return [$rows, $next];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $rows->map(fn (Deal $d) => [
|
||||
'id' => $d->id,
|
||||
'received_at' => $d->received_at->toIso8601String(),
|
||||
'phone' => $d->phone,
|
||||
'contact_name' => $d->contact_name,
|
||||
'city' => $d->city,
|
||||
'status' => $d->status,
|
||||
'project' => $d->project?->name,
|
||||
])->all(),
|
||||
'next_cursor' => $next,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -127,16 +127,15 @@ class WebhookSettingsController extends Controller
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
// SSRF-гард + DNS-rebind пиннинг: ОДИН резолв target_url даёт причину
|
||||
// блокировки И безопасный IP. Блокируем адреса во внутренней/зарезервированной
|
||||
// сети (cloud-metadata 169.254.169.254, loopback, RFC1918), которые
|
||||
// https://-валидация на сохранении не ловит.
|
||||
$delivery = WebhookUrlGuard::safeDeliveryIp($sub->target_url);
|
||||
if ($delivery['blockReason'] !== null) {
|
||||
// SSRF-гард: target_url задаёт админ тенанта; блокируем адреса во
|
||||
// внутренней/зарезервированной сети (cloud-metadata 169.254.169.254,
|
||||
// loopback, RFC1918), которые https://-валидация на сохранении не ловит.
|
||||
$blockReason = WebhookUrlGuard::blockReason($sub->target_url);
|
||||
if ($blockReason !== null) {
|
||||
return response()->json([
|
||||
'ok' => false,
|
||||
'status' => null,
|
||||
'message' => $delivery['blockReason'],
|
||||
'message' => $blockReason,
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
|
||||
@@ -146,19 +145,9 @@ class WebhookSettingsController extends Controller
|
||||
'message' => 'Тестовая доставка webhook от Лидерра.',
|
||||
];
|
||||
|
||||
// DNS-rebind пиннинг: подключаемся к УЖЕ проверенному IP, не давая
|
||||
// HTTP-клиенту резолвить хост повторно (TOCTOU). Host/SNI — исходный хост.
|
||||
$httpOptions = [];
|
||||
if ($delivery['ip'] !== null) {
|
||||
$host = trim((string) parse_url($sub->target_url, PHP_URL_HOST), '[]');
|
||||
$port = parse_url($sub->target_url, PHP_URL_PORT) ?? 443;
|
||||
$httpOptions['curl'] = [CURLOPT_RESOLVE => ["{$host}:{$port}:{$delivery['ip']}"]];
|
||||
}
|
||||
|
||||
// Unsigned connectivity-проверка (HMAC-подписанная доставка — отдельный эпик).
|
||||
try {
|
||||
$response = Http::withOptions($httpOptions)
|
||||
->timeout(10)
|
||||
$response = Http::timeout(10)
|
||||
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
|
||||
->post($sub->target_url, $testPayload);
|
||||
|
||||
|
||||
@@ -21,14 +21,6 @@ trait ResolvesAdminUserId
|
||||
{
|
||||
protected function resolveAdminUserId(Request $request, string $stubEmail, string $stubName): int
|
||||
{
|
||||
// Прод: crm_app_user не имеет прав на saas_admin_users → берём системный
|
||||
// admin-id из конфига, не обращаясь к таблице (фикс 500 на admin-сохранениях).
|
||||
// null (dev/test, суперюзер) → fallback на старую логику ниже.
|
||||
$configured = config('admin.audit_system_user_id');
|
||||
if ($configured !== null) {
|
||||
return (int) $configured;
|
||||
}
|
||||
|
||||
$requested = $request->input('admin_user_id');
|
||||
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
|
||||
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ApiKey;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Аутентификация публичного API по ключу тенанта (G6).
|
||||
*
|
||||
* Ключ — `Authorization: Bearer lpkapi_...`. В БД лежит bcrypt key_hash + key_prefix
|
||||
* (первые 10 символов). Ищем кандидатов по префиксу через pgsql_supplier (BYPASSRLS —
|
||||
* публичный роут не ставит tenant-GUC, под RLS api_keys вернул бы пусто), затем
|
||||
* Hash::check. Успех → tenant_id в request->attributes (api_tenant_id) + last_used.
|
||||
*/
|
||||
class ApiKeyAuth
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$key = $this->bearer($request);
|
||||
if ($key === null || $key === '') {
|
||||
return response()->json(['message' => 'Требуется API-ключ.'], 401);
|
||||
}
|
||||
|
||||
$prefix = substr($key, 0, 10);
|
||||
|
||||
$candidates = ApiKey::on('pgsql_supplier')
|
||||
->where('key_prefix', $prefix)
|
||||
->where('is_active', true)
|
||||
->where('expires_at', '>', now())
|
||||
->get();
|
||||
|
||||
$matched = null;
|
||||
foreach ($candidates as $candidate) {
|
||||
if (Hash::check($key, (string) $candidate->key_hash)) {
|
||||
$matched = $candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($matched === null) {
|
||||
return response()->json(['message' => 'Неверный или неактивный API-ключ.'], 401);
|
||||
}
|
||||
|
||||
if (! in_array('read', (array) $matched->scopes, true)) {
|
||||
return response()->json(['message' => 'Недостаточно прав ключа.'], 403);
|
||||
}
|
||||
|
||||
ApiKey::on('pgsql_supplier')->whereKey($matched->getKey())->update([
|
||||
'last_used_at' => now(),
|
||||
'last_used_ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
$request->attributes->set('api_tenant_id', (int) $matched->tenant_id);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function bearer(Request $request): ?string
|
||||
{
|
||||
$header = (string) $request->header('Authorization', '');
|
||||
if (str_starts_with($header, 'Bearer ')) {
|
||||
return trim(substr($header, 7));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -11,52 +11,26 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
/**
|
||||
* Гейт SaaS-admin зоны (/api/admin/*) — audit-находка J2.
|
||||
*
|
||||
* СТОПГЭП (2026-05-25): защита боевой админ-зоны (/admin + /api/admin/*)
|
||||
* перенесена на уровень nginx — отдельный HTTP Basic Auth с собственным
|
||||
* паролем (`/etc/nginx/.htpasswd-admin`, location ^~ /admin и ^~ /api/admin).
|
||||
* Поэтому middleware больше не закрывает зону на проде: дверь держит nginx.
|
||||
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
|
||||
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
|
||||
* реального механизма аутентификации нет.
|
||||
*
|
||||
* Ранее (Sprint 3F) здесь был fail-closed 503 вне dev/testing — он закрывал
|
||||
* всю админку на проде наглухо, т.к. настоящий saas-admin SSO (Yandex 360)
|
||||
* ещё не готов (гейтится Б-1 + DO-4). Замок 503 снят осознанно: оголять
|
||||
* /api/admin/* в интернет нельзя, но nginx-пароль её прикрывает.
|
||||
* Поведение стаба:
|
||||
* - dev / testing (local, testing) → пропускаем. Admin-панель работает на
|
||||
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
|
||||
* - прочие окружения (production / staging) → fail-closed 503: зона
|
||||
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
|
||||
* открытый /api/admin/* в проде.
|
||||
*
|
||||
* admin_user_id для audit-trail по-прежнему резолвится трейтом
|
||||
* ResolvesAdminUserId (стаб super_admin) — это отдельная зона.
|
||||
*
|
||||
* G7-B: пока активен impersonation (маркер сессии ИЛИ машинный ключ) —
|
||||
* вход в saas-admin зону запрещён (запрет эскалации к другим тенантам).
|
||||
*
|
||||
* M-1 (приёмка 21.06): nginx-дверь дополнена app-слойным fail-closed гейтом по
|
||||
* REMOTE_USER + config-allowlist. Закрывает обходы front-controller'а
|
||||
* (/index.php/api/admin, /API/admin), где nginx basic-auth не применяется и
|
||||
* REMOTE_USER пуст. См. config/admin.php и spec 2026-06-21-m1-admin-gate-fail-closed.
|
||||
*
|
||||
* TODO (после Б-1 + DO-4): заменить nginx-дверь на настоящий saas-admin
|
||||
* guard (Yandex 360 SSO-сессия + роль).
|
||||
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
|
||||
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
|
||||
*/
|
||||
class EnsureSaasAdmin
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// G7-B: пока активен impersonation (маркер сессии ИЛИ машинный ключ) —
|
||||
// вход в saas-admin зону запрещён (запрет эскалации к другим тенантам).
|
||||
$hasMarker = $request->hasSession() && $request->session()->has('impersonation');
|
||||
$hasBearer = str_starts_with((string) $request->header('Authorization', ''), 'Bearer lpimp_');
|
||||
if ($hasMarker || $hasBearer) {
|
||||
abort(403, 'Во время сессии impersonation доступ в админ-зону запрещён.');
|
||||
}
|
||||
|
||||
// M-1 (приёмка 21.06): fail-closed гейт. REMOTE_USER непуст только у запросов,
|
||||
// прошедших nginx admin-basic-auth (^~ /admin, ^~ /api/admin); обходы через
|
||||
// front-controller (/index.php/api/admin, /API/admin) попадают в auth_basic off
|
||||
// → REMOTE_USER пуст → 403. В local/testing гейт выключен (см. config/admin.php).
|
||||
if (config('admin.basic_auth_gate')) {
|
||||
$remoteUser = (string) $request->server('REMOTE_USER', '');
|
||||
$allowlist = (array) config('admin.basic_auth_allowlist', []);
|
||||
if ($remoteUser === '' || ! in_array($remoteUser, $allowlist, true)) {
|
||||
abort(403, 'Доступ в админ-зону запрещён.');
|
||||
}
|
||||
if (! app()->environment('local', 'testing')) {
|
||||
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\ImpersonationToken;
|
||||
use App\Services\Pd\ImpersonationExpiryService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* G7-B: на web-запросах с активным impersonation-маркером проверяет 60-мин
|
||||
* лимит. Истёк (или токен уже неактивен) → завершает токен, шлёт письмо,
|
||||
* разлогинивает, чистит маркер. No-op, если маркера нет.
|
||||
*
|
||||
* Отправка письма делегирована ImpersonationExpiryService (Service-слой) —
|
||||
* middleware не зависит от слоя Mail напрямую (deptrac ruleset).
|
||||
*/
|
||||
class ImpersonationContext
|
||||
{
|
||||
public function __construct(private readonly ImpersonationExpiryService $expiry) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->hasSession() || ! $request->session()->has('impersonation')) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$marker = $request->session()->get('impersonation');
|
||||
$token = ImpersonationToken::on('pgsql_supplier')->find($marker['token_id'] ?? 0);
|
||||
|
||||
if ($token === null || ! $token->isSessionActive()) {
|
||||
if ($token !== null) {
|
||||
$this->expiry->endSession($token);
|
||||
}
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->forget('impersonation');
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
// Завершаем текущий запрос немедленно — auth:sanctum уже мог
|
||||
// резолвить user'а из сессии, поэтому не передаём $next, а
|
||||
// возвращаем 401 явно, чтобы клиент знал о разрыве сессии.
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => 'Сессия impersonation истекла.'], 401);
|
||||
}
|
||||
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Account;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
/**
|
||||
* Валидация POST /api/account/change-password (UI-аудит 21.06.2026, Security).
|
||||
*
|
||||
* current_password — текущий пароль (проверка совпадения — в контроллере через
|
||||
* Hash::check против колонки password_hash). password — новый, min 10 (ТЗ §22.4.1,
|
||||
* как reset-flow) + confirmed (password_confirmation).
|
||||
*/
|
||||
class ChangePasswordRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'current_password' => ['required', 'string'],
|
||||
'password' => ['required', 'confirmed', Password::min(10)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'current_password.required' => 'Укажите текущий пароль.',
|
||||
'password.required' => 'Укажите новый пароль.',
|
||||
'password.confirmed' => 'Пароли не совпадают.',
|
||||
'password.min' => 'Пароль должен быть не короче 10 символов.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Валидация POST /api/auth/confirm-email — подтверждение почты 6-значным кодом.
|
||||
*/
|
||||
class ConfirmEmailRequest extends FormRequest
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'email.required' => 'Укажите email.',
|
||||
'code.required' => 'Укажите код из письма.',
|
||||
'code.regex' => 'Код состоит из 6 цифр.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -18,21 +18,14 @@ class RegisterRequest extends FormRequest
|
||||
{
|
||||
use HasPasswordRules;
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* NB: уникальность email НЕ через DB-rule — её решает RegistrationService
|
||||
* (активный email → 422 email_taken; неподтверждённый → перевыпуск кода).
|
||||
* Капча проверяется на КАЖДОМ register-запросе (это независимый публичный POST).
|
||||
*/
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
||||
'password' => $this->passwordRules(),
|
||||
'accept_offer' => ['required', 'accepted'],
|
||||
'accept_pdn' => ['required', 'accepted'],
|
||||
'captcha_token' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -42,9 +35,9 @@ class RegisterRequest extends FormRequest
|
||||
return array_merge($this->passwordMessages(), [
|
||||
'email.required' => 'Укажите email.',
|
||||
'email.email' => 'Email указан некорректно.',
|
||||
'email.unique' => 'Аккаунт с таким email уже существует.',
|
||||
'accept_offer.accepted' => 'Необходимо принять оферту.',
|
||||
'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.',
|
||||
'captcha_token.required' => 'Подтвердите, что вы не робот.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Auth;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* Валидация POST /api/auth/resend-code — повторная отправка кода подтверждения.
|
||||
*/
|
||||
class ResendCodeRequest extends FormRequest
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => ['required', 'string', 'email', 'max:255'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'email.required' => 'Укажите email.',
|
||||
'email.email' => 'Email указан некорректно.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class LookupInnRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'inn' => ['required', 'string', 'regex:/^(\d{10}|\d{12})$/'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Support\PhoneNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
@@ -15,33 +14,6 @@ class StoreProjectRequest extends FormRequest
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Косяк 02: для типа «call» приводим введённый номер к каноничному виду
|
||||
* 7XXXXXXXXXX тем же нормализатором, что и реквизиты (PhoneNormalizer).
|
||||
* Источник проекта хранится без ведущего «+» — раздача лидов матчит
|
||||
* signal_identifier как есть (LeadRouter), поэтому «+» срезаем.
|
||||
* Невалидный мусор оставляем как ввели — финальная regex даст ошибку.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if ($this->input('signal_type') === 'call' && $this->filled('signal_identifier')) {
|
||||
$normalized = PhoneNormalizer::normalize((string) $this->input('signal_identifier'));
|
||||
if ($normalized !== null) {
|
||||
$this->merge(['signal_identifier' => ltrim($normalized, '+')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return match ($this->input('signal_type')) {
|
||||
'call' => ['signal_identifier.regex' => 'Введите номер в формате 79161234567 — цифра 7 и 10 цифр после неё. Можно вводить с +7, 8, скобками и пробелами — приведём сами.'],
|
||||
'site' => ['signal_identifier.regex' => 'Введите домен в формате example.ru — без http://, без www и без пути.'],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$signalType = $this->input('signal_type');
|
||||
|
||||
@@ -5,59 +5,15 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Support\PhoneNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateProjectRequest extends FormRequest
|
||||
{
|
||||
private ?Project $resolvedProject = null;
|
||||
|
||||
private bool $projectResolved = false;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Косяк 02: при редактировании call-проекта нормализуем введённый номер
|
||||
* к 7XXXXXXXXXX (тот же PhoneNormalizer, что и реквизиты; «+» срезаем —
|
||||
* раздача матчит без него). Тип signal_type immutable — берём из проекта.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
if (! $this->filled('signal_identifier')) {
|
||||
return;
|
||||
}
|
||||
if ($this->resolveProject()?->signal_type === 'call') {
|
||||
$normalized = PhoneNormalizer::normalize((string) $this->input('signal_identifier'));
|
||||
if ($normalized !== null) {
|
||||
$this->merge(['signal_identifier' => ltrim($normalized, '+')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function messages(): array
|
||||
{
|
||||
return match ($this->resolveProject()?->signal_type) {
|
||||
'call' => ['signal_identifier.regex' => 'Введите номер в формате 79161234567 — цифра 7 и 10 цифр после неё. Можно вводить с +7, 8, скобками и пробелами — приведём сами.'],
|
||||
'site' => ['signal_identifier.regex' => 'Введите домен в формате example.ru — без http://, без www и без пути.'],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveProject(): ?Project
|
||||
{
|
||||
if (! $this->projectResolved) {
|
||||
$projectId = $this->route('id');
|
||||
$this->resolvedProject = $projectId !== null ? Project::find($projectId) : null;
|
||||
$this->projectResolved = true;
|
||||
}
|
||||
|
||||
return $this->resolvedProject;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
// signal_type immutable: не валидируется в правилах, controller игнорирует поле
|
||||
@@ -80,14 +36,17 @@ class UpdateProjectRequest extends FormRequest
|
||||
// 18.05.2026 UX: редактирование источника (signal_identifier) для site/call.
|
||||
// Регулярки соответствуют StoreProjectRequest (domain + 7\d{10}).
|
||||
// signal_type immutable — берём из текущего проекта по route id.
|
||||
$project = $this->resolveProject();
|
||||
if ($project !== null) {
|
||||
if ($project->signal_type === 'site') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
|
||||
} elseif ($project->signal_type === 'call') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^7\d{10}$/'];
|
||||
$projectId = $this->route('id');
|
||||
if ($projectId !== null) {
|
||||
$project = Project::find($projectId);
|
||||
if ($project !== null) {
|
||||
if ($project->signal_type === 'site') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
|
||||
} elseif ($project->signal_type === 'call') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^7\d{10}$/'];
|
||||
}
|
||||
// sms: signal_identifier меняется через sms_senders/sms_keyword (см. выше)
|
||||
}
|
||||
// sms: signal_identifier меняется через sms_senders/sms_keyword (см. выше)
|
||||
}
|
||||
|
||||
return $rules;
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Support\InnValidator;
|
||||
use App\Support\PhoneNormalizer;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateRequisitesRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user() !== null;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
$subjectType = (string) $this->input('subject_type');
|
||||
|
||||
return [
|
||||
'subject_type' => ['required', Rule::in(['individual', 'sole_proprietor', 'legal_entity'])],
|
||||
'contact_name' => ['required', 'string', 'max:255'],
|
||||
'contact_phone' => ['required', 'string', function ($attr, $value, $fail) {
|
||||
if (PhoneNormalizer::normalize((string) $value) === null) {
|
||||
$fail('Некорректный телефон.');
|
||||
}
|
||||
}],
|
||||
'inn' => [
|
||||
Rule::requiredIf(in_array($subjectType, ['legal_entity', 'sole_proprietor'], true)),
|
||||
'nullable', 'string',
|
||||
function ($attr, $value, $fail) use ($subjectType) {
|
||||
if (in_array($subjectType, ['legal_entity', 'sole_proprietor'], true)
|
||||
&& is_string($value) && $value !== ''
|
||||
&& ! InnValidator::isValid($value, $subjectType)) {
|
||||
$fail('Некорректный ИНН (контрольная цифра).');
|
||||
}
|
||||
},
|
||||
],
|
||||
'legal_name' => ['nullable', 'string', 'max:255'],
|
||||
'kpp' => ['nullable', 'string', 'regex:/^\d{9}$/'],
|
||||
'ogrn' => ['nullable', 'string', 'regex:/^(\d{13}|\d{15})$/'],
|
||||
'legal_address' => ['nullable', 'string'],
|
||||
'bank_name' => ['nullable', 'string', 'max:255'],
|
||||
'bank_bik' => ['nullable', 'string', 'regex:/^\d{9}$/'],
|
||||
'bank_account' => ['nullable', 'string', 'regex:/^\d{20}$/'],
|
||||
'corr_account' => ['nullable', 'string', 'regex:/^\d{20}$/'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\Project\SupplierSnapshotGuard;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -14,17 +13,6 @@ class ProjectResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
// Состояние блокировки источника для UI (read-only). hasLinks — из eager-loaded
|
||||
// supplier_projects_count (анти-N+1); fallback на exists() если count не загружен.
|
||||
$hasLinks = $this->supplier_projects_count !== null
|
||||
? (int) $this->supplier_projects_count > 0
|
||||
: $this->supplierProjects()->exists();
|
||||
$sourceLock = (new SupplierSnapshotGuard)->lockState(
|
||||
hasLinks: $hasLinks,
|
||||
isActive: (bool) $this->is_active,
|
||||
pausedAt: $this->paused_at,
|
||||
);
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
@@ -43,20 +31,10 @@ class ProjectResource extends JsonResource
|
||||
'delivery_days_mask' => $this->delivery_days_mask,
|
||||
'sync_status' => $this->aggregateSyncStatus(),
|
||||
'last_synced_at' => $this->aggregateLastSyncedAt(),
|
||||
// H (балансовый блок): проект приостановлен из-за нехватки баланса (read-only для UI).
|
||||
'balance_blocked' => $this->preflight_blocked_at !== null,
|
||||
'supplier_links' => $this->when(
|
||||
$request->routeIs('projects.show'),
|
||||
fn () => $this->getSupplierLinks(),
|
||||
),
|
||||
// Task 2.11 (Spec §4.2.5): dynamic attribute, не БД-поле. Установлен
|
||||
// ProjectService::update() для slepok-sensitive правок. UI показывает
|
||||
// «изменения вступят в силу с DD.MM HH:MM МСК».
|
||||
'applies_from' => $this->applies_from?->toIso8601String(),
|
||||
// Блокировка смены источника (спека 2026-06-22-project-source-edit-lock-ux).
|
||||
'source_locked' => $sourceLock['locked'],
|
||||
'source_unlock_at' => $sourceLock['unlock_at']?->toIso8601String(),
|
||||
'source_unlock_projected' => $sourceLock['projected'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\TenantRequisites;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin TenantRequisites */
|
||||
class RequisitesResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'subject_type' => $this->subject_type,
|
||||
'contact_name' => $this->contact_name,
|
||||
'contact_phone' => $this->contact_phone,
|
||||
'inn' => $this->inn,
|
||||
'legal_name' => $this->legal_name,
|
||||
'kpp' => $this->kpp,
|
||||
'ogrn' => $this->ogrn,
|
||||
'legal_address' => $this->legal_address,
|
||||
'bank_name' => $this->bank_name,
|
||||
'bank_bik' => $this->bank_bik,
|
||||
'bank_account' => $this->bank_account,
|
||||
'corr_account' => $this->corr_account,
|
||||
'requisites_completed_at' => $this->requisites_completed_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ use App\Mail\BalanceFrozenFinalMail;
|
||||
use App\Mail\BalanceFrozenReminderMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -48,8 +47,7 @@ final class BalanceFrozenReminderJob implements ShouldQueue
|
||||
public function handle(): void
|
||||
{
|
||||
$service = new BalancePreflightService;
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
$tiers = PricingTier::query()->where('is_active', true)->get();
|
||||
|
||||
Tenant::query()
|
||||
->whereNotNull('frozen_by_balance_at')
|
||||
|
||||
@@ -6,12 +6,11 @@ namespace App\Jobs\Billing;
|
||||
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Mail\BalanceFrozenMail;
|
||||
use App\Mail\BalanceUnfrozenMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Tenant;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use App\Services\Billing\ProjectBlockReleaseService;
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -38,8 +37,7 @@ final class BalancePreflightSweepJob implements ShouldQueue
|
||||
public function handle(): void
|
||||
{
|
||||
$service = new BalancePreflightService;
|
||||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||||
$tiers = PricingTier::query()->where('is_active', true)->get();
|
||||
|
||||
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
@@ -71,34 +69,28 @@ final class BalancePreflightSweepJob implements ShouldQueue
|
||||
|
||||
$isFrozen = $tenant->frozen_by_balance_at !== null;
|
||||
|
||||
if (! $result->passes) {
|
||||
// Переход active → frozen (разморозку/снятие блоков здесь НЕ делаем —
|
||||
// заморозка главнее, см. иерархию J спеки balance-lock-unify-FJ).
|
||||
if (! $isFrozen) {
|
||||
$freezeAt = now();
|
||||
$tenant->frozen_by_balance_at = $freezeAt;
|
||||
$tenant->save();
|
||||
// Переход active → frozen.
|
||||
if (! $result->passes && ! $isFrozen) {
|
||||
$tenant->frozen_by_balance_at = now();
|
||||
$tenant->save();
|
||||
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
|
||||
Mail::queue(new BalanceFrozenMail($tenant, $result));
|
||||
$this->dispatchSupplierSyncIfOnline($tenant);
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
return; // заморожен и не хватает — стабильное состояние, блоки не трогаем.
|
||||
return;
|
||||
}
|
||||
|
||||
// passes → единый путь разблокировки (D6): разморозить клиента (если был, J)
|
||||
// + снять блоки всех проектов (F). Идемпотентно: нет замков → no-op.
|
||||
(new ProjectBlockReleaseService)->releaseForTenant($tenant->id);
|
||||
// Переход frozen → active.
|
||||
if ($result->passes && $isFrozen) {
|
||||
$tenant->frozen_by_balance_at = null;
|
||||
$tenant->save();
|
||||
$this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result);
|
||||
Mail::queue(new BalanceUnfrozenMail($tenant, $result));
|
||||
$this->dispatchSupplierSyncIfOnline($tenant);
|
||||
|
||||
return;
|
||||
}
|
||||
// Иначе состояние не изменилось — ничего не делаем (идемпотентность).
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,22 +11,18 @@ use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\Dto\RegionResolution;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRegionResolver;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\Pd\PdAuditLogger;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable as FoundationQueueable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -120,58 +116,22 @@ 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);
|
||||
|
||||
$supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);
|
||||
$lead->update(['supplier_project_id' => $supplier->id]);
|
||||
|
||||
// Lead region resolution (§3.11): резолв региона ДО routing-цикла, чтобы HTTP-вызов
|
||||
// DaData (~150мс) не висел внутри tenant-транзакции. Резолвер — из контейнера (не 7-й
|
||||
// параметр handle(), чтобы не ломать сигнатуру и существующие вызовы тестов).
|
||||
// RegionTagResolver остаётся в DI-цепочке резолвера (fallback-слой).
|
||||
$resolution = app(LeadRegionResolver::class)->resolve($lead);
|
||||
$lead->update([
|
||||
'resolved_subject_code' => $resolution->subjectCode,
|
||||
'region_source' => $resolution->source,
|
||||
'dadata_qc' => $resolution->qc,
|
||||
'phone_operator' => $resolution->phoneOperator,
|
||||
]);
|
||||
$matched = $router->matchEligibleProjects($supplier);
|
||||
$selected = $distributor->selectRecipients($matched); // cap=3 случайных
|
||||
|
||||
// Каскад по региону (§3.9): exact → all-RF → fallback. NULL subject_code → шаг 1 пропуск.
|
||||
$matched = $router->matchEligibleProjects($supplier, $resolution->subjectCode);
|
||||
$selected = $distributor->selectRecipients($matched);
|
||||
$subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
|
||||
|
||||
$createdCount = 0;
|
||||
$failures = [];
|
||||
foreach ($selected as $project) {
|
||||
try {
|
||||
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $resolution)) {
|
||||
if ($this->createDealCopyForProject($lead, $project, $notifier, $ledger, $subjectCode)) {
|
||||
$createdCount++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
@@ -192,10 +152,6 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
);
|
||||
}
|
||||
|
||||
// Аудит резолва региона — одна строка на лид (§3.10/§7.1). Fail-safe: сбой записи
|
||||
// аудит-лога НЕ должен ронять доставку лида (revenue-critical, 30k/сутки).
|
||||
$this->logRegionResolution($lead, $resolution, $selected);
|
||||
|
||||
$lead->update([
|
||||
'processed_at' => now(),
|
||||
'deals_created_count' => $createdCount,
|
||||
@@ -215,16 +171,11 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
*/
|
||||
private function parseProjectField(string $project): array
|
||||
{
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) {
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
} else {
|
||||
// Phase 3: проекты без B-префикса попадают в DIRECT.
|
||||
// Весь project считается identifier-частью; signal_type определяется
|
||||
// тем же regex'ом, что для $rest у B-префиксных.
|
||||
$platform = 'DIRECT';
|
||||
$rest = $project;
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) !== 1) {
|
||||
throw new RuntimeException("Cannot parse supplier project field: '{$project}'");
|
||||
}
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
|
||||
// Домен с латинским TLD ≥2 букв (последний сегмент — только буквы), допускается
|
||||
// в любой позиции строки. Соответствует чистому rest и встроенному в текст домену.
|
||||
@@ -258,14 +209,10 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
Project $project,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
RegionResolution $resolution,
|
||||
?int $subjectCode,
|
||||
): bool {
|
||||
// routing_step проставлен LeadRouter'ом на matched-проекте; захватываем ДО
|
||||
// переназначения $project = $lockedProject (fresh query без этого атрибута).
|
||||
$routingStep = (int) ($project->routing_step ?? 1);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $resolution, $routingStep): bool {
|
||||
return DB::transaction(function () use ($lead, $project, $notifier, $ledger, $subjectCode): bool {
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
|
||||
|
||||
/** @var Tenant $tenant */
|
||||
@@ -284,48 +231,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
->whereKey($project->id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
// 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;
|
||||
|
||||
$effectiveLimit = $lockedProject->effective_daily_limit_today ?? $lockedProject->daily_limit_target;
|
||||
if ($lockedProject->delivered_today >= $effectiveLimit) {
|
||||
Log::info('supplier_lead.project_at_limit_skipped', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
@@ -339,68 +245,6 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
}
|
||||
$project = $lockedProject;
|
||||
|
||||
// Phase 2 fix: merge с CSV-recovered deal если webhook догоняет.
|
||||
// Идемпотентность race condition между CsvReconcileJob (vid=NULL, recovered
|
||||
// from CSV) и webhook (vid=int, реальный supplier-id). До этой проверки они
|
||||
// создавали 2 deal'a (DD снят Spec B Phase 1). Merge выполняется только если:
|
||||
// - webhook ЕСТЬ настоящий vid (lead.vid !== null) — без vid merge'ить нечего;
|
||||
// - csv-recovered deal существует за последние 24h, тот же phone+project+tenant;
|
||||
// - csv-recovered deal БЕЗ source_crm_id (т.е. он именно CSV-recovered, не другой webhook).
|
||||
// При merge: UPDATE existing.source_crm_id, INSERT supplier_lead_deliveries,
|
||||
// БЕЗ chargeForDelivery (LeadCharge уже есть с момента CSV recovery).
|
||||
$existingMergeable = null;
|
||||
if ($lead->vid !== null) {
|
||||
$existingMergeable = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('phone', (string) $lead->phone)
|
||||
->where('project_id', $project->id)
|
||||
->whereNull('source_crm_id')
|
||||
->where('received_at', '>=', now()->subDay())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
}
|
||||
if ($existingMergeable !== null) {
|
||||
// Заполняем supplier_lead.id у обоих SupplierLead → одному Deal
|
||||
DB::table('supplier_lead_deliveries')->insert([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'deal_id' => $existingMergeable->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Обновляем только source_crm_id + updated_at через DB::table.
|
||||
// NB (регрессия 26.05.2026 04:12-05:03 UTC, 9 failed_jobs):
|
||||
// received_at — partition key, и lead_charges имеет FK
|
||||
// (deal_id, deal_received_at) с ON DELETE CASCADE, но
|
||||
// ON UPDATE NO ACTION (default). Любое изменение received_at
|
||||
// ломает FK даже в той же месячной партиции (даже DEFERRABLE
|
||||
// INITIALLY DEFERRED не помогает — проверка падает на COMMIT).
|
||||
// CSV-recovered received_at сохраняем как есть — отличие на минуты
|
||||
// несущественно, чем риск каскадного DELETE lead_charges.
|
||||
// §3.12: при merge обновляем регион/оператора, если webhook-резолв из
|
||||
// источника выше рангом (dadata/rossvyaz), чем tag CSV-восстановления.
|
||||
// deals не хранит region_source (он на supplier_leads + в журнале), поэтому
|
||||
// ранг определяем по факту источника: dadata/rossvyaz всегда достовернее
|
||||
// tag'а, на котором строилась CSV-recovery (RegionResolution::SOURCE_RANK).
|
||||
$mergeUpdate = ['source_crm_id' => $lead->vid, 'updated_at' => now()];
|
||||
if (in_array($resolution->source, ['dadata', 'rossvyaz'], true) && $resolution->subjectCode !== null) {
|
||||
$mergeUpdate['subject_code'] = $resolution->subjectCode;
|
||||
$mergeUpdate['phone_operator'] = $resolution->phoneOperator;
|
||||
$mergeUpdate['city'] = RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null;
|
||||
}
|
||||
DB::table('deals')
|
||||
->where('id', $existingMergeable->id)
|
||||
->where('received_at', $existingMergeable->received_at)
|
||||
->update($mergeUpdate);
|
||||
|
||||
Log::info('supplier_lead.merged_into_csv_recovered', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'merged_into_deal_id' => $existingMergeable->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return true; // считаем «доставленным», но без второго списания
|
||||
}
|
||||
|
||||
// Spec B: per-(supplier_lead, tenant) lock — одна поставка одному клиенту = один раз.
|
||||
// insertOrIgnore вернёт 0, если строка уже существует (повтор/гонка/CSV-recovery).
|
||||
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
|
||||
@@ -427,13 +271,6 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
? array_values(array_map('strval', $payload['phones']))
|
||||
: [(string) $lead->phone];
|
||||
|
||||
// §3.10: на шаге 3 (запасной канал) регион сделки подменяется на регион
|
||||
// клиента (первый подписанный субъект из snapshot); настоящий регион —
|
||||
// в lead_region_resolution_log.actual_subject_code. region_substituted флажит подмену.
|
||||
$dealSubjectCode = $routingStep < 3
|
||||
? $resolution->subjectCode
|
||||
: ($this->pickSubstituteRegion((string) ($snapshot->regions ?? '{}')) ?? $resolution->subjectCode);
|
||||
|
||||
$deal = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'source_crm_id' => $lead->vid,
|
||||
@@ -442,14 +279,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'phones' => $phones,
|
||||
'status' => 'new',
|
||||
'received_at' => $receivedAt,
|
||||
'subject_code' => $dealSubjectCode,
|
||||
// «Город» (UI deals.city) — человекочитаемое имя НАСТОЯЩЕГО региона лида
|
||||
// по резолву (даже если subject_code подменён на шаге 3). NULL → колонка пустая.
|
||||
'city' => $resolution->subjectCode !== null
|
||||
? (RussianRegions::CODE_TO_NAME[$resolution->subjectCode] ?? null)
|
||||
: null,
|
||||
'phone_operator' => $resolution->phoneOperator,
|
||||
'region_substituted' => $routingStep === 3,
|
||||
'subject_code' => $subjectCode,
|
||||
]);
|
||||
|
||||
DB::table('supplier_lead_deliveries')
|
||||
@@ -464,14 +294,6 @@ 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,
|
||||
@@ -547,89 +369,6 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Аудит резолва региона лида — одна строка на лид в lead_region_resolution_log (§7.1).
|
||||
* Fail-safe: сбой записи (например, отсутствие партиции received_at) логируется warning'ом,
|
||||
* но НЕ прерывает доставку (revenue-critical). INSERT через pgsql_supplier (GRANT INSERT
|
||||
* у crm_supplier_worker). Телефон маскируется до INSERT — сырой номер в лог не пишется.
|
||||
*
|
||||
* @param Collection<int, Project> $selected
|
||||
*/
|
||||
private function logRegionResolution(SupplierLead $lead, RegionResolution $resolution, Collection $selected): void
|
||||
{
|
||||
try {
|
||||
$first = $selected->first();
|
||||
$routingStep = $first !== null ? (int) ($first->routing_step ?? 1) : null;
|
||||
$substituted = ($routingStep === 3 && $first !== null)
|
||||
? ($this->pickSubstituteRegion((string) ($first->snapshot_regions ?? '{}')) ?? $resolution->subjectCode)
|
||||
: null;
|
||||
|
||||
$tagCode = app(RegionTagResolver::class)->resolve((string) ($lead->raw_payload['tag'] ?? ''));
|
||||
|
||||
DB::connection(self::DB_CONNECTION)->table('lead_region_resolution_log')->insert([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'received_at' => $lead->received_at ?? now(),
|
||||
'phone_masked' => $this->maskPhone((string) $lead->phone),
|
||||
'subject_code_resolved' => $resolution->subjectCode,
|
||||
'subject_code_from_tag' => $tagCode,
|
||||
'region_source' => $resolution->source,
|
||||
'dadata_qc' => $resolution->qc,
|
||||
'dadata_provider' => $resolution->phoneOperator,
|
||||
'dadata_type' => null,
|
||||
'dadata_response_masked' => $resolution->dadataResponseMasked !== null
|
||||
? json_encode($resolution->dadataResponseMasked, JSON_UNESCAPED_UNICODE)
|
||||
: null,
|
||||
'rossvyaz_matched' => $resolution->rossvyazMatched,
|
||||
'actual_subject_code' => $resolution->actualSubjectCode,
|
||||
'substituted_subject_code' => $substituted,
|
||||
'routing_step' => $routingStep,
|
||||
'phone_operator' => $resolution->phoneOperator,
|
||||
'cache_hit' => $resolution->cacheHit,
|
||||
'duration_ms' => $resolution->durationMs,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('lead_region_resolution.log_write_failed', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Первый код субъекта из PG INT[]-литерала ('{82,83}' → 82; '{}' → null) — регион клиента
|
||||
* для подмены на запасном канале (§3.10).
|
||||
*/
|
||||
private function pickSubstituteRegion(string $regionsLiteral): ?int
|
||||
{
|
||||
return $this->parseSubjectCodes($regionsLiteral)[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int> '{82,83}' → [82,83]; '{}'/'' → []
|
||||
*/
|
||||
private function parseSubjectCodes(string $regionsLiteral): array
|
||||
{
|
||||
$inner = trim($regionsLiteral, '{}');
|
||||
if ($inner === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_map('intval', explode(',', $inner)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Маскирование телефона для лога (§7.1): первые 4 + последние 4 цифры (7916***4567).
|
||||
*/
|
||||
private function maskPhone(string $phone): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $phone) ?? '';
|
||||
if (strlen($digits) < 8) {
|
||||
return '***';
|
||||
}
|
||||
|
||||
return substr($digits, 0, 4).'***'.substr($digits, -4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Финальный callback после исчерпания всех ретраев ($tries=3).
|
||||
*
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* G2-A: раз в 30 минут (routes/console.php) рассылает дайджест новых сделок.
|
||||
* Окно — последние 30 минут по received_at.
|
||||
*
|
||||
* Идемпотентность по СДЕЛКЕ (N-4): окно даёт защиту только при ровно-30-мин
|
||||
* прогонах; ручной/повторный прогон (R3b велит дёргать вручную) перекрывает окно.
|
||||
* Поэтому каждая сделка, попавшая в дайджест, помечается в Redis (TTL 1 сутки) —
|
||||
* повторно в дайджест не включается. Пометка ставится ПОСЛЕ успешной отправки
|
||||
* (mark-after-send): падение джоба до неё оставит сделки непомеченными → очередь
|
||||
* повторит (at-least-once вместо тихой потери). Один воркер → гонки нет.
|
||||
*/
|
||||
final class SendNewLeadsDigestJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
|
||||
public function handle(NotificationService $notifier): void
|
||||
{
|
||||
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (EloquentCollection $tenants) use ($notifier): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
/** @var Tenant $tenant */
|
||||
$this->digestForTenant($tenant, $notifier);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function digestForTenant(Tenant $tenant, NotificationService $notifier): void
|
||||
{
|
||||
DB::transaction(function () use ($tenant, $notifier): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
|
||||
|
||||
$deals = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('received_at', '>', now()->subMinutes(30))
|
||||
->where('is_test', false)
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('received_at')
|
||||
->get();
|
||||
|
||||
// N-4: исключаем сделки, уже попавшие в прошлый дайджест (без side-effect).
|
||||
$fresh = $deals->reject(
|
||||
fn (Deal $deal): bool => Cache::has('digest_sent:'.$deal->id)
|
||||
);
|
||||
|
||||
if ($fresh->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notifier->notifyNewLeadsDigest($tenant, $fresh);
|
||||
|
||||
// N-4: помечаем ТОЛЬКО ПОСЛЕ успешного возврата notify. Падение джоба
|
||||
// до этой точки оставит сделки непомеченными → очередь повторит прогон
|
||||
// (at-least-once вместо тихой потери дайджеста). Один воркер (см.
|
||||
// prod-logic-map §18.4) → гонки между прогонами нет. TTL 1 сутки.
|
||||
foreach ($fresh as $deal) {
|
||||
Cache::put('digest_sent:'.$deal->id, 1, now()->addDay());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
<?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, InteractsWithQueue, Queueable, 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,14 +59,19 @@ class CleanupInactiveSupplierProjectsJob implements ShouldQueue
|
||||
{
|
||||
$client ??= app(SupplierPortalClient::class);
|
||||
|
||||
// Источник истинности активности — `project_supplier_links` pivot (Plan 3+).
|
||||
// Legacy FK `supplier_b{1,2,3}_project_id` оставлены для read-compat,
|
||||
// но не определяют активность.
|
||||
// Подзапрос — DISTINCT id'шники supplier_projects, на которые ссылается
|
||||
// хотя бы один Лидерра-project с is_active=true через любой из трёх FK.
|
||||
$activeIdsSubquery = <<<'SQL'
|
||||
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
|
||||
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
|
||||
SQL;
|
||||
|
||||
// Phase A — re-activate (СНАЧАЛА для safety: до Phase C, чтобы недавно
|
||||
|
||||
@@ -6,12 +6,9 @@ namespace App\Jobs\Supplier;
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Mail\CsvDriftAlertMail;
|
||||
use App\Mail\SupplierCriticalAlertMail;
|
||||
use App\Mail\TenantBusinessDriftAlertMail;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Services\Supplier\SupplierCsvParser;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Cache\LockProvider;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
@@ -63,9 +60,6 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
|
||||
private const LOCK_TTL_SECONDS = 600;
|
||||
|
||||
/** UI-аудит 21.06: не чаще 1 алерта о падении сверки за это окно (анти-спам). */
|
||||
private const FAILURE_ALERT_THROTTLE_HOURS = 6;
|
||||
|
||||
public function handle(
|
||||
SupplierPortalClient $portal,
|
||||
SupplierCsvParser $parser,
|
||||
@@ -132,15 +126,11 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
$missing = array_diff_key($csvByKey, $existingKeys);
|
||||
|
||||
$recoveredCount = 0;
|
||||
$unparseableCount = 0;
|
||||
foreach ($missing as $row) {
|
||||
$platform = $this->extractPlatform((string) $row['project']);
|
||||
if ($platform === null) {
|
||||
// Поставщик иногда кладёт в `project` нестандартные имена (телефон, URL).
|
||||
// Не warning — это не наш баг, processing продолжается, paper-trail на info уровне.
|
||||
// Считаем такие строки отдельно, чтобы исключить из формулы drift'а
|
||||
// (иначе ~40-50% мусора каждый запуск стабильно даёт false-positive drift_alert).
|
||||
$unparseableCount++;
|
||||
Log::info('csv_reconcile.unparseable_project_skipped', [
|
||||
'project' => $row['project'],
|
||||
]);
|
||||
@@ -171,14 +161,7 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
$matchedCount = $totalCsvRows - count($missing);
|
||||
// drift считается только по «реальным» пропускам (parseable, не junk):
|
||||
// real_missing = count(missing) - unparseable (всегда ≥ 0)
|
||||
// parseable_tot = total_csv_rows - unparseable
|
||||
// Это убирает класс «поставщик кладёт телефон/URL в поле project →
|
||||
// строки скипаются → drift искусственно завышен» (см. ПИЛОТ 22.05, 25.05).
|
||||
$realMissing = max(0, count($missing) - $unparseableCount);
|
||||
$parseableTotal = max(0, $totalCsvRows - $unparseableCount);
|
||||
$driftRatio = $parseableTotal > 0 ? $realMissing / $parseableTotal : 0.0;
|
||||
$driftRatio = $totalCsvRows > 0 ? count($missing) / $totalCsvRows : 0.0;
|
||||
$status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok';
|
||||
|
||||
$update = [
|
||||
@@ -186,7 +169,6 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
'total_csv_rows' => $totalCsvRows,
|
||||
'matched_count' => $matchedCount,
|
||||
'recovered_count' => $recoveredCount,
|
||||
'unparseable_count' => $unparseableCount,
|
||||
'drift_ratio' => $driftRatio,
|
||||
'status' => $status,
|
||||
];
|
||||
@@ -210,34 +192,7 @@ 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) {
|
||||
// UI-аудит 21.06: раньше падение сверки писалось в лог status=failed,
|
||||
// но НИКОГО не уведомляло (алерт слался только на drift) — а heartbeat
|
||||
// показывал «OK» (Schedule::job меряет постановку в очередь, не результат).
|
||||
// Из-за этого заход к поставщику падал каждые 30 мин ~3 недели незаметно.
|
||||
// Теперь: при падении шлём critical-алерт на ops-email, троттл 6ч.
|
||||
$alertSent = false;
|
||||
if (! $this->failureAlertRecentlySent()) {
|
||||
try {
|
||||
$mailer->to((string) config('services.supplier.alert_email'))
|
||||
->send(new SupplierCriticalAlertMail(
|
||||
alertType: 'csv_reconcile_failed',
|
||||
details: 'Сверка с поставщиком (CsvReconcileJob) падает. Ошибка: '
|
||||
.substr($e->getMessage(), 0, 500),
|
||||
));
|
||||
$alertSent = true;
|
||||
} catch (Throwable $mailError) {
|
||||
Log::error('csv_reconcile.failure_alert_send_failed', ['error' => $mailError->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// $logId === null — упал сам insertGetId, log-строки нет, обновлять нечего.
|
||||
if ($logId !== null) {
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
@@ -247,7 +202,6 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
'finished_at' => now(),
|
||||
'status' => 'failed',
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
'alert_email_sent_at' => $alertSent ? now() : null,
|
||||
]);
|
||||
}
|
||||
throw $e;
|
||||
@@ -264,96 +218,16 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
return trim($phone).'|'.trim($project);
|
||||
}
|
||||
|
||||
/** Был ли алерт о падении сверки за последнее окно троттла (анти-спам). */
|
||||
private function failureAlertRecentlySent(): bool
|
||||
{
|
||||
return DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->whereNotNull('alert_email_sent_at')
|
||||
->where('alert_email_sent_at', '>=', now()->subHours(self::FAILURE_ALERT_THROTTLE_HOURS))
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает platform из имени проекта:
|
||||
* - `B[123]_<rest>` → 'B1' / 'B2' / 'B3';
|
||||
* - Phase 3: иначе, если строка непустая и состоит из identifier-символов
|
||||
* (домены / телефоны / SMS-отправители) → 'DIRECT';
|
||||
* - откровенный мусор (только спец-символы, пусто) → null (unparseable).
|
||||
* Извлекает platform (B1/B2/B3) из имени проекта формата `B[123]_<rest>`.
|
||||
* Возвращает null если не парсится — caller пропустит строку с warning.
|
||||
*/
|
||||
private function extractPlatform(string $project): ?string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
// Phase 3: всё что выглядит как разумный identifier (домен / телефон / SMS-sender) → DIRECT.
|
||||
// unparseable_count теперь только для откровенного мусора (пустые / только спец-символы).
|
||||
$trimmed = trim($project);
|
||||
if ($trimmed !== '' && preg_match('/^[\w\-.а-яА-Я0-9\/() +]+$/u', $trimmed) === 1) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
CarbonInterface $windowStart,
|
||||
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 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,65 +192,18 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
*/
|
||||
public function collectEligibleProjects(): Collection
|
||||
{
|
||||
// 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 {
|
||||
// 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 {
|
||||
$q->select('id')->from('tenants')->whereNull('frozen_by_balance_at');
|
||||
})
|
||||
->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')
|
||||
->orderBy('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,16 +107,13 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKey($project, $platforms[0]);
|
||||
|
||||
// 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 = $identifier;
|
||||
$agnostic = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
|
||||
$groupProjects = Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->where('signal_type', (string) $project->signal_type)
|
||||
@@ -128,9 +125,8 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
$groupActive = $groupProjects->isNotEmpty();
|
||||
$status = $groupActive ? 'active' : 'paused';
|
||||
|
||||
// eligible target_date → order/workdays (mirror nightly's eligibility window).
|
||||
// R-18 (Stage 4 §4.4.2): see ::targetWeekdayForNow().
|
||||
$targetWeekday = self::targetWeekdayForNow();
|
||||
// eligible tomorrow → order/workdays (mirror nightly's eligibility window).
|
||||
$targetWeekday = Carbon::tomorrow('Europe/Moscow')->isoWeekday();
|
||||
$eligible = $groupProjects->filter(
|
||||
fn (Project $gp) => ((int) $gp->delivery_days_mask & (1 << ($targetWeekday - 1))) !== 0
|
||||
)->values();
|
||||
@@ -388,10 +384,8 @@ 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-запись уже есть?
|
||||
@@ -543,24 +537,4 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logging;
|
||||
|
||||
use Monolog\LogRecord;
|
||||
use Monolog\Processor\ProcessorInterface;
|
||||
|
||||
/**
|
||||
* Monolog-процессор: маскирует ПДн в логах перед записью.
|
||||
*
|
||||
* Закрывает Medium go-live: laravel.log (LOG_LEVEL=debug) мог сохранить телефон/email
|
||||
* открытым, если они попадут в текст исключения или контекст. Процессор ловит ВСЕ
|
||||
* записи каналов, к которым подключён (см. App\Logging\ScrubPii + config/logging.php),
|
||||
* централизованно — надёжнее правки отдельных вызовов Log::.
|
||||
*/
|
||||
final class PiiScrubbingProcessor implements ProcessorInterface
|
||||
{
|
||||
/**
|
||||
* Телефоны РФ: 11 цифр в формате 7XXXXXXXXXX / 8XXXXXXXXXX / +7XXXXXXXXXX.
|
||||
* Lookbehind/lookahead не дают маскировать часть более длинной цифровой строки
|
||||
* (например 14-значный технический id).
|
||||
*/
|
||||
private const PHONE_PATTERN = '/(?<!\d)(?:\+?7|8)\d{10}(?!\d)/';
|
||||
|
||||
private const EMAIL_PATTERN = '/[\p{L}0-9._%+\-]+@[\p{L}0-9.\-]+\.\p{L}{2,}/u';
|
||||
|
||||
public function __invoke(LogRecord $record): LogRecord
|
||||
{
|
||||
return $record->with(
|
||||
message: $this->scrub($record->message),
|
||||
context: $this->scrubArray($record->context),
|
||||
extra: $this->scrubArray($record->extra),
|
||||
);
|
||||
}
|
||||
|
||||
private function scrub(string $value): string
|
||||
{
|
||||
$value = preg_replace(self::PHONE_PATTERN, '[PHONE]', $value) ?? $value;
|
||||
|
||||
return preg_replace(self::EMAIL_PATTERN, '[EMAIL]', $value) ?? $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<array-key, mixed> $data
|
||||
* @return array<array-key, mixed>
|
||||
*/
|
||||
private function scrubArray(array $data): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($data as $key => $value) {
|
||||
if (is_string($value)) {
|
||||
$result[$key] = $this->scrub($value);
|
||||
} elseif (is_array($value)) {
|
||||
$result[$key] = $this->scrubArray($value);
|
||||
} else {
|
||||
$result[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Logging;
|
||||
|
||||
use Illuminate\Log\Logger;
|
||||
|
||||
/**
|
||||
* Tap для config/logging.php: вешает PiiScrubbingProcessor на канал.
|
||||
*
|
||||
* Использование: 'tap' => [\App\Logging\ScrubPii::class] в описании канала.
|
||||
*/
|
||||
final class ScrubPii
|
||||
{
|
||||
public function __invoke(Logger $logger): void
|
||||
{
|
||||
// Illuminate\Log\Logger::getLogger() типизирован как PSR LoggerInterface,
|
||||
// но фактически возвращает Monolog\Logger (у него есть pushProcessor).
|
||||
$monolog = $logger->getLogger();
|
||||
if ($monolog instanceof \Monolog\Logger) {
|
||||
$monolog->pushProcessor(new PiiScrubbingProcessor);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо с 6-значным кодом подтверждения почты при самозаписи (G1/SP1).
|
||||
*/
|
||||
final class EmailVerificationCodeMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $code,
|
||||
public readonly string $email,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Код подтверждения регистрации в Лидерре',
|
||||
to: [$this->email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.email_verification_code',
|
||||
with: ['code' => $this->code],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/** Код-согласие на вход поддержки в кабинет клиента (G7-B / Ю-1). */
|
||||
final class ImpersonationCodeMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $code,
|
||||
public readonly string $email,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Код доступа: запрос входа поддержки в ваш кабинет',
|
||||
to: [$this->email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.impersonation_code', with: ['code' => $this->code]);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/** Уведомление о завершении сессии поддержки в кабинете клиента (G7-B / Ю-1). */
|
||||
final class ImpersonationEndedMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $email,
|
||||
public readonly ?string $tenantName = null,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Сессия поддержки в вашем кабинете завершена',
|
||||
to: [$this->email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.impersonation_ended', with: ['tenantName' => $this->tenantName]);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Deal;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Письмо-сводка о новых сделках за окно (G2-A дайджест).
|
||||
* Заменяет пер-лид NewLeadNotification как email-канал события new_lead.
|
||||
*
|
||||
* @property Collection<int, Deal> $deals
|
||||
*/
|
||||
class NewLeadsDigestMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $user,
|
||||
public Tenant $tenant,
|
||||
public Collection $deals,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Лидерра. Новые сделки — '.$this->deals->count(),
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.new_leads_digest',
|
||||
with: [
|
||||
'user' => $this->user,
|
||||
'tenant' => $this->tenant,
|
||||
'deals' => $this->deals,
|
||||
'count' => $this->deals->count(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Reminder;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Email-уведомление о наступлении срока напоминания (ТЗ §18.5, событие reminder).
|
||||
*
|
||||
* Триггер: cron-команда `reminders:dispatch-due` находит rows с
|
||||
* `is_sent=false AND completed_at IS NULL AND remind_at <= NOW()`,
|
||||
* вызывает NotificationService::notifyReminder для каждой,
|
||||
* затем ставит `is_sent=true, sent_at=NOW()`.
|
||||
*/
|
||||
class ReminderDueNotification extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public User $recipient,
|
||||
public Reminder $reminder,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$shortText = $this->reminder->text
|
||||
? mb_substr($this->reminder->text, 0, 60)
|
||||
: 'Срок касания клиента';
|
||||
|
||||
return new Envelope(
|
||||
subject: "Лидерра. Напоминание — {$shortText}",
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.reminder',
|
||||
with: [
|
||||
'recipient' => $this->recipient,
|
||||
'reminder' => $this->reminder,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\SupportRequest;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо в техподдержку о новой заявке клиента (G7-A). Адресат — config('services.support.email').
|
||||
*/
|
||||
class SupportRequestMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(public SupportRequest $request) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Лидерра. Заявка в поддержку #'.$this->request->id,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.support_request',
|
||||
with: ['r' => $this->request],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user