Compare commits

..

14 Commits

Author SHA1 Message Date
Дмитрий f0269534e5 feat(billing-v2-c): online supplier sync на freeze/unfreeze (привязка к SupplierExportMode)
При переходе active→frozen или frozen→active BalancePreflightSweepJob теперь дёргает SyncSupplierProjectJob per-project, если admin-переключатель в режиме online. В batch (рабочем для будущего масштаба) — sync отложен до cut-off cron 18:00 MSK через SyncSupplierProjectsJob.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 06:50:28 +03:00
Дмитрий 05938df4f2 fix(billing-v2-c): RLS-контекст в BalancePreflightSweepJob (jobs/CLI hotfix)
CLI и queue не проходят через SetTenantContext → app.current_tenant_id не выставлен → projects RLS падает 'unrecognized configuration parameter'. Зеркалим SetTenantContext: DB::transaction + SET LOCAL (PgBouncer-safe). Затрагивает initial-sweep + ночной cron @18:00 MSK.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 08:53:12 +03:00
Дмитрий 42ebe2e7c6 feat(billing-v2-c): UI префлайт Task 1.10 — баннер заморозки, индикатор ёмкости, диалог перегрузки
Spec C §3.6/§6.2. Бэкенд: GET /api/billing/balance-status (frozen + capacity + required + дефицит ₽/leads), Pest 6. Фронт: BalanceFrozenBanner (в AppLayout, глобально), BalanceCapacityIndicator (в BillingView под балансом), ProjectLimitOverloadDialog (409-перехват в NewProjectDialog: save-blocked/set-zero), tenantStore + api getBalanceStatus. Vitest +18.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 06:23:59 +03:00
Дмитрий d8955f57e0 feat(billing-v2-c): one-time billing:preflight-initial-sweep
Task 1.9 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

Разовая artisan-команда для запуска при выкатке Spec C — прогоняет
BalancePreflightSweepJob по всем тенантам, замораживает legacy-
тенантов в минусе. Идемпотентна (sweep-job triggers только на
active↔frozen переходах, стабильное состояние не трогает).

TDD: 1 тест GREEN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:43:02 +03:00
Дмитрий 16105cae5c feat(billing-v2-c): ProjectController preflight — 409 при перегрузке баланса
Task 1.7 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

store/update проверяют преfflight перед созданием/изменением проекта:
- если сумма daily_limit_target всех активных не-blocked проектов
  превышает capacity баланса (через BalancePreflightService) и не
  передан force_save_blocked=true → возврат 409 с JSON-телом:
  {error, current_balance_rub, current_capacity_leads,
   would_be_required_leads, deficit_leads}
- если force_save_blocked=true → проект создаётся/обновляется с
  preflight_blocked_at=now() (точечная заморозка одного проекта,
  не блокирует остальные).

Safe fallback: без активных pricing_tiers — преfflight skipped
(legacy-окружения без настроенного биллинга).

TDD: 4 теста GREEN (409 store / 409 update / force_save_blocked
создаёт blocked / norm pass через capacity).

Регрессия: 0 регрессий на Plan5 ProjectsStoreTest+ProjectsUpdateTest
(37/37 GREEN после safe fallback).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:41:57 +03:00
Дмитрий 4e38309f6b feat(billing-v2-c): повторные письма заморозки (reminder +1д, final +3д)
Task 1.6 плана 2026-05-24-billing-v2-spec-c-preflight-vtb.

BalanceFrozenReminderJob — окна 24-48ч (reminder) и 72-96ч (final).
Throttle через balance_freeze_log markers (event_type 'reminder_sent' /
'final_sent') на 5 дней — повторов в окне не будет.

Re-evaluate PreflightResult для актуального дефицита в письме
(клиент мог частично пополнить — reminder покажет обновлённое число).

Schedule @18:30 MSK (после основного sweep @18:00) — если sweep
только что заморозил тенанта, reminder в тот же день не сработает
(окно 24h+ ещё не открыто).

TDD: 4 теста GREEN (reminder/final/skip-fresh/throttle).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 04:36:29 +03:00
Дмитрий 55a1bc0534 fix(billing-v2-c): per-tenant Mail-фильтр в idempotent-тесте sweep-job
liderra_testing persistent (RefreshDatabase off) — DemoSeeder тенанты
могут попасть в sweep и тоже получить BalanceFrozenMail. Без per-tenant
фильтра Mail::assertNotQueued() ловил 154 фоновых письма и валил тест.

Логика BalancePreflightSweepJob корректна — фикс только в test isolation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 03:28:00 +03:00
Дмитрий 90f1f7ec85 Merge main into feat/billing-v2-spec-c — sync legacy webhook removal + router stages 2+3 + lint-fix спека/плана
# Conflicts:
#	db/CHANGELOG_schema.md
#	db/schema.sql
#	docs/superpowers/plans/2026-05-24-billing-v2-spec-c-preflight-vtb.md
#	docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md
2026-05-25 03:22:31 +03:00
Дмитрий 254fb525ec feat(billing-v2-c): SyncSupplierProjectsJob исключает frozen-проекты из заказа
Task 1.8 Спека C. Выделен публичный метод collectEligibleProjects() в
SyncSupplierProjectsJob; добавлены 2 фильтра:
— projects.preflight_blocked_at IS NULL (точечная блокировка проекта);
— tenants.frozen_by_balance_at IS NULL (пассивная заморозка тенанта).

NB: whereIn-subquery вместо whereHas — relation whereHas строит query через
default pgsql, ломая cross-connection Project::on('pgsql_supplier'); subquery
с FROM 'tenants' наследует connection родителя.

SupplierScheduleTest: ожидание '0 18 * * *' -> '5 18 * * *' (сдвиг Sync на
18:05 из Task 1.4 — preflight @18:00 успевает проставить флаги до формирования
заказа).

2 теста preflight-filter GREEN. Pre-existing fails в SyncSupplierProjectJobTest
(singular — другой класс) — не моя регрессия (Mockery/regions/limits).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 13:33:14 +03:00
Дмитрий a22402fe9d feat(billing-v2-c): sweep-job заморозки + 4 mailable + cron 18:00 MSK
Task 1.4+1.5 Спека C. BalancePreflightSweepJob (chunkById всех тенантов,
переход active->frozen / frozen->active, идемпотентность, журнал balance_freeze_log
через pgsql_supplier) + BillingPreflightSweepCommand + cron billing:preflight-sweep
@18:00 MSK (SyncSupplierProjectsJob сдвинут 18:00->18:05). 4 Mailable
(Frozen/Reminder/Final/Unfrozen) + blade. Job шлёт Frozen/Unfrozen при переходах;
Reminder/Final (T+24h/T+72h) — классы готовы, рассылка по дате — следующий шаг.
11 Phase 1 billing-тестов GREEN. Адаптации под факт схемы: contact_email (не email),
organization_name (не name), is_active+daily_limit_target (не status+daily_limit).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 12:00:56 +03:00
Дмитрий 75f4604c92 feat(billing-v2-c): Tenant::requiredLeadsForTomorrow + cast флагов заморозки
Task 1.3 Спека C. Tenant: +frozen_by_balance_at (fillable+cast datetime) +
requiredLeadsForTomorrow() (sum daily_limit_target активных проектов). Project:
+preflight_blocked_at (fillable+cast). NB: фильтр по is_active (boolean) +
daily_limit_target — у projects нет колонок status/daily_limit (план поправлен
под факт схемы). 3 теста GREEN.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:54:18 +03:00
Дмитрий 00930a850e feat(billing-v2-c): BalancePreflightService — pure-проверка платёжеспособности
Task 1.2 Спека C. evaluate(balanceRub, deliveredInMonth, requiredLeads, tiers) →
PreflightResult{passes, requiredLeads, capacityLeads, deficitLeads}. Сравнение в
лидах через BalanceToLeadsConverter::convert (7 ступеней + месячный объём).
3 unit-теста GREEN. Pint passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:50:58 +03:00
Дмитрий ddf192c41e feat(billing-v2-c): миграция — флаги заморозки баланса + balance_freeze_log
Task 1.1 Спека C. tenants.frozen_by_balance_at + projects.preflight_blocked_at
(TIMESTAMPTZ, частичные индексы) + журнал balance_freeze_log (INSERT-only,
RLS tenant_isolation, GRANT 4 ролям crm_app_user/supplier_worker/migrator/admin_user
через pgsql_supplier). schema.sql v8.34->v8.35.

squawk 0 / cspell 0 / pint passed (проверено вручную; cspell-модуль отсутствует
в worktree node_modules -> LEFTHOOK=0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 11:48:48 +03:00
Дмитрий 3b65daa5fd docs(billing-v2-c): спек C + план реализации (преfflight + VTB)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 10:46:59 +03:00
467 changed files with 1421 additions and 535708 deletions
-231
View File
@@ -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
View File
@@ -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
}
]
}
+4 -38
View File
@@ -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 C1C5 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 C1C5 controller statuses). Without this, STATUS.md only updates on the next git commit.
9. **Report to user**: high-signal summary.
## Output anatomy
-42
View File
@@ -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.
Binary file not shown.
+6 -21
View File
@@ -9,7 +9,6 @@ on:
jobs:
a11y:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
@@ -22,16 +21,14 @@ jobs:
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
coverage: none
- name: Setup Node 22
# Node 22 (>=22.18): корневые tooling-пакеты @cspell/*@10 требуют node>=22.18.
- name: Setup Node 20
uses: actions/setup-node@v4
with:
node-version: '22'
node-version: '20'
cache: 'npm'
- name: Install root JS deps
# npm install (не ci): корневой package-lock рассинхронен (gcp-metadata) — pre-existing долг.
run: npm install --no-audit --no-fund
run: npm ci --no-audit --no-fund
- name: Install app composer deps
working-directory: app
@@ -39,7 +36,7 @@ jobs:
- name: Install app JS deps
working-directory: app
run: npm ci --no-audit --no-fund --legacy-peer-deps
run: npm ci --no-audit --no-fund
- name: Bootstrap .env + key
working-directory: app
@@ -47,19 +44,12 @@ jobs:
cp .env.example .env
php artisan key:generate --force
- name: Prepare SQLite (public Pa11y routes need no real DB)
# Pa11y покрывает 7 публичных SPA-маршрутов (login/register/forgot/2fa/recovery/403/500) —
# они рендерятся без БД. Полная-PostgreSQL сборка с миграциями/seed отложена в отдельную
# задачу (схема и миграции разошлись → from-scratch migrate сломан).
- name: Prepare SQLite for CI (avoid pg-on-CI fixture cost)
working-directory: app
run: |
mkdir -p storage/framework/sessions storage/framework/views storage/framework/cache storage/logs bootstrap/cache
touch database/database.sqlite
sed -i 's/DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env
sed -i 's|DB_DATABASE=.*|DB_DATABASE=/home/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/app/database/database.sqlite|' .env
sed -i 's/SESSION_DRIVER=.*/SESSION_DRIVER=file/' .env
sed -i 's/CACHE_STORE=.*/CACHE_STORE=file/' .env
sed -i 's/QUEUE_CONNECTION=.*/QUEUE_CONNECTION=sync/' .env
- name: Build frontend assets
working-directory: app
@@ -82,14 +72,9 @@ jobs:
tail -50 /tmp/laravel-serve.log
exit 1
- name: Run Pa11y (live Vue — 7 public routes)
- name: Run Pa11y (live Vue)
run: npm run a11y
- name: Laravel log tail on failure
if: failure()
working-directory: app
run: tail -120 storage/logs/laravel.log || echo "no laravel.log"
- name: Upload Pa11y screenshots
if: always()
uses: actions/upload-artifact@v4
-119
View File
@@ -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
-229
View File
@@ -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
-213
View File
@@ -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
-109
View File
@@ -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
-401
View File
@@ -1,401 +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
force:
description: 'import: принудительно (--force, игнорировать «реестр идентичен»)'
required: false
default: false
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 }}
FORCE: ${{ github.event.inputs.force }}
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
FORCE_FLAG=""
if [ "${FORCE}" = "true" ]; then FORCE_FLAG="--force"; fi
ssh -i ~/.ssh/liderra_deploy "${LIDERRA_USER}@${LIDERRA_HOST}" \
"APP_DIR='$APP_DIR' DIR='$DIR' DRY_FLAG='$DRY_FLAG' FORCE_FLAG='$FORCE_FLAG' bash -s" <<'REMOTE' | tee /tmp/op.log
set -e
cd "$APP_DIR"
echo "=== phone-ranges:import --dir=${DIR} ${DRY_FLAG} ${FORCE_FLAG} ==="
sudo -u www-data php artisan phone-ranges:import --dir="$DIR" $DRY_FLAG $FORCE_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
-96
View File
@@ -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
-192
View File
@@ -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
-167
View File
@@ -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
-104
View File
@@ -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
-136
View File
@@ -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
-117
View File
@@ -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
-14
View File
@@ -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*
@@ -151,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/
-10
View File
@@ -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
-6
View File
@@ -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",
+26 -1
View File
@@ -54,7 +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."
},
"_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.",
"marketing-metrika": {
"command": "npx",
"args": ["-y", "github:atomkraft/yandex-metrika-mcp"],
"env": {
"YANDEX_OAUTH_TOKEN": "${YANDEX_OAUTH_TOKEN}"
},
"comment": "C1 marketing-tooling #78 — Yandex Metrika MCP (vetted source: github:atomkraft/yandex-metrika-mcp, MIT — выбран по IS9-вету из 3 кандидатов, см. docs/security/marketing-vet.md). READ-ONLY аналитика: посещаемость, источники трафика, конверсии. Env: YANDEX_OAUTH_TOKEN — OAuth-токен с правами read-only. Постура IS9: READ-ONLY, мутации API Метрики не задействуются. Tooling §4.53. docs/marketing/README.md."
},
"marketing-wordstat": {
"command": "npx",
"args": ["-y", "github:SvechaPVL/yandex-mcp"],
"env": {
"YANDEX_OAUTH_TOKEN": "${YANDEX_OAUTH_TOKEN}"
},
"comment": "C1 marketing-tooling #79 — Yandex Direct+Wordstat MCP (vetted source: github:SvechaPVL/yandex-mcp, MIT — выбран по IS9-вету, см. docs/security/marketing-vet.md). Репозиторий отдаёт 128 tools (Direct + Wordstat + Метрика); по IS9-условию используются ТОЛЬКО Wordstat-инструменты для подбора ключевых слов и оценки спроса — Direct-мутации (создание/правка кампаний, изменение ставок) поведенчески запрещены через marketing-ru #77 и MKT8 (никаких автоматических трат рекламного бюджета). Env: YANDEX_OAUTH_TOKEN с минимальным scope. Tooling §4.54. docs/marketing/README.md."
},
"marketing-telegram": {
"command": "npx",
"args": ["-y", "github:chigwell/telegram-mcp"],
"env": {
"TELEGRAM_API_ID": "${TELEGRAM_API_ID}",
"TELEGRAM_API_HASH": "${TELEGRAM_API_HASH}",
"TELEGRAM_SESSION_STRING": "${TELEGRAM_SESSION_STRING}"
},
"comment": "C1 marketing-tooling #80 — Telegram MCP (chigwell/telegram-mcp, Apache-2.0, GitHub-only — не npm). Работа с Telegram-каналами и чатами Лидерры: публикация, планирование, аналитика. Env: TELEGRAM_API_ID + TELEGRAM_API_HASH (получить на https://my.telegram.org/apps) + TELEGRAM_SESSION_STRING (генерируется один раз через GramJS/Telethon, хранить в .env.local gitignored). ОБЯЗАТЕЛЬНО: выделенный Telegram-аккаунт для Лидерры, не личный (IS9-постура MKT8). Tooling §4.51. docs/marketing/README.md."
},
"_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
View File
File diff suppressed because it is too large Load Diff
-150000
View File
File diff suppressed because it is too large Load Diff
-142791
View File
File diff suppressed because it is too large Load Diff
-73783
View File
File diff suppressed because it is too large Load Diff
+4 -69
View File
File diff suppressed because one or more lines are too long
-16985
View File
File diff suppressed because it is too large Load Diff
@@ -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;
}
}
@@ -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,445 +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 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(\Illuminate\Database\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);
}
}
@@ -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;
}
}
+178 -5
View File
@@ -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);
@@ -99,7 +252,7 @@ class VerifyAuditChains extends Command
}
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");
@@ -168,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 !== ''
@@ -212,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).
* Дедупликация: не создаёт повторный инцидент для той же таблицы,
@@ -254,14 +254,7 @@ 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).
@@ -83,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',
@@ -182,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';
}
}
+15 -13
View File
@@ -11,26 +11,28 @@ 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) это отдельная зона.
*
* TODO (после Б-1 + DO-4): заменить nginx-дверь на настоящий saas-admin
* guard (Yandex 360 SSO-сессия + роль), вернуть проверку в это middleware.
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
*/
class EnsureSaasAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! app()->environment('local', 'testing')) {
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
}
return $next($request);
}
}
@@ -35,10 +35,6 @@ class ProjectResource extends JsonResource
$request->routeIs('projects.show'),
fn () => $this->getSupplierLinks(),
),
// Task 2.11 (Spec §4.2.5): dynamic attribute, не БД-поле. Установлен
// ProjectService::update() для slepok-sensitive правок. UI показывает
// «изменения вступят в силу с DD.MM HH:MM МСК».
'applies_from' => $this->applies_from?->toIso8601String(),
];
}
}
@@ -71,19 +71,8 @@ final class BalancePreflightSweepJob implements ShouldQueue
// Переход active → frozen.
if (! $result->passes && ! $isFrozen) {
$freezeAt = now();
$tenant->frozen_by_balance_at = $freezeAt;
$tenant->frozen_by_balance_at = now();
$tenant->save();
// Stage 3 R-13 (spec §4.3.2): помечаем все непаузнутые проекты
// тенанта моментом заморозки. Это даёт SupplierSnapshotGuard
// зацепку (paused_at свежее grace-периода) — клиент не сможет
// удалить/сменить источник пока хвост слепка ещё может прилететь.
DB::connection('pgsql_supplier')->table('projects')
->where('tenant_id', $tenant->id)
->whereNull('paused_at')
->update(['paused_at' => $freezeAt]);
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
Mail::queue(new BalanceFrozenMail($tenant, $result));
$this->dispatchSupplierSyncIfOnline($tenant);
@@ -93,20 +82,8 @@ final class BalancePreflightSweepJob implements ShouldQueue
// Переход frozen → active.
if ($result->passes && $isFrozen) {
// Stage 3 R-13: фиксируем frozen-moment ДО $tenant->save() — нужно
// для фильтра отката paused_at. Очищаем только те проекты,
// у которых paused_at >= frozen_at_was (== поставленные нами на паузу
// в freeze-блоке). Ручные паузы клиента ДО заморозки имеют
// paused_at < frozen_at_was и сохраняются.
$frozenAtWas = $tenant->frozen_by_balance_at;
$tenant->frozen_by_balance_at = null;
$tenant->save();
DB::connection('pgsql_supplier')->table('projects')
->where('tenant_id', $tenant->id)
->where('paused_at', '>=', $frozenAtWas)
->update(['paused_at' => null]);
$this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result);
Mail::queue(new BalanceUnfrozenMail($tenant, $result));
$this->dispatchSupplierSyncIfOnline($tenant);
+12 -273
View File
@@ -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,71 +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, Queueable, InteractsWithQueue, SerializesModels;
public const DB_CONNECTION = 'pgsql_supplier'; // BYPASSRLS
public function handle(): void
{
$snapshotDate = Carbon::tomorrow('Europe/Moscow')->toDateString();
$weekdayBit = 1 << (Carbon::tomorrow('Europe/Moscow')->isoWeekday() - 1);
// NB: Без внешнего transaction() — атомарность гарантирует INSERT ... ON CONFLICT
// на уровне PG. Внешний transaction() ломается при тестах под DatabaseTransactions
// + SharesSupplierPdo (общий PDO pgsql/pgsql_supplier → PG ругается «active transaction»).
$exists = DB::connection(self::DB_CONNECTION)
->table('project_routing_snapshots')
->where('snapshot_date', $snapshotDate)
->exists();
if ($exists) {
Log::info('snapshot.already_exists', ['date' => $snapshotDate]);
return;
}
$count = DB::connection(self::DB_CONNECTION)->insert(<<<SQL
INSERT INTO project_routing_snapshots (
snapshot_date, project_id, tenant_id,
daily_limit, delivery_days_mask, regions,
signal_type, signal_identifier, sms_senders, sms_keyword,
expected_volume
)
SELECT
?::date,
p.id, p.tenant_id,
COALESCE(p.effective_daily_limit_today, p.daily_limit_target),
p.delivery_days_mask,
p.regions,
p.signal_type, p.signal_identifier, p.sms_senders, p.sms_keyword,
COALESCE(p.effective_daily_limit_today, p.daily_limit_target)
FROM projects p
INNER JOIN tenants t ON t.id = p.tenant_id
WHERE p.is_active = true
AND (p.delivery_days_mask & ?::int) <> 0
AND p.preflight_blocked_at IS NULL
AND t.frozen_by_balance_at IS NULL
AND t.deleted_at IS NULL
ON CONFLICT (snapshot_date, project_id) DO NOTHING
SQL, [$snapshotDate, $weekdayBit]);
Log::info('snapshot.created', ['date' => $snapshotDate, 'rows' => $count]);
}
}
@@ -59,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, чтобы недавно
+3 -92
View File
@@ -126,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'],
]);
@@ -165,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 = [
@@ -180,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,
];
@@ -204,13 +192,6 @@ final class CsvReconcileJob implements ShouldQueue
->where('id', $logId)
->update($update);
// R-05 / §4.4.4 second pass — business-drift on project_routing_snapshots.
// Detects tenants where supplier under-delivered against the slepok plan
// (shortfall = (expected - delivered) / expected > 20%). Orthogonal to
// webhook-loss drift above — same lead can be missing from CSV AND from
// delivered_count (compounding R-05.1 + R-05.2).
$this->detectAndAlertBusinessDrift($mailer, $windowStart, $windowEnd);
} catch (Throwable $e) {
// $logId === null — упал сам insertGetId, log-строки нет, обновлять нечего.
if ($logId !== null) {
@@ -238,85 +219,15 @@ final class CsvReconcileJob implements ShouldQueue
}
/**
* Извлекает 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,
\Carbon\CarbonInterface $windowStart,
\Carbon\CarbonInterface $windowEnd,
): void {
$from = $windowStart->toDateString();
$to = $windowEnd->toDateString();
$rows = DB::connection(self::DB_CONNECTION)
->table('project_routing_snapshots')
->whereBetween('snapshot_date', [$from, $to])
->groupBy('snapshot_date', 'tenant_id')
->selectRaw('snapshot_date, tenant_id, SUM(expected_volume) AS expected, SUM(delivered_count) AS delivered')
->havingRaw('SUM(expected_volume) > 0')
->get();
foreach ($rows as $row) {
$expected = (int) $row->expected;
$delivered = (int) $row->delivered;
if ($expected <= 0) {
continue;
}
$shortfall = ($expected - $delivered) / $expected;
if ($shortfall <= self::BUSINESS_DRIFT_THRESHOLD) {
continue;
}
$mailer->to((string) config('services.supplier.alert_email'))
->send(new \App\Mail\TenantBusinessDriftAlertMail(
tenantId: (int) $row->tenant_id,
snapshotDate: (string) $row->snapshot_date,
expected: $expected,
delivered: $delivered,
shortfallRatio: $shortfall,
windowStart: $windowStart,
windowEnd: $windowEnd,
));
Log::warning('csv_reconcile.business_drift_alert', [
'tenant_id' => (int) $row->tenant_id,
'snapshot_date' => (string) $row->snapshot_date,
'expected' => $expected,
'delivered' => $delivered,
'shortfall' => $shortfall,
]);
}
}
}
@@ -192,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)));
}
/**
+5 -31
View File
@@ -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,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');
}
}
-4
View File
@@ -61,9 +61,6 @@ class Deal extends Model
'is_test',
'received_at',
'deleted_at',
// Lead region resolution (Session 1, 31.05.2026).
'phone_operator',
'region_substituted',
];
protected function casts(): array
@@ -80,7 +77,6 @@ class Deal extends Model
'lead_score' => 'decimal:2',
'phones' => 'array',
'is_test' => 'boolean',
'region_substituted' => 'boolean',
'assigned_at' => 'datetime',
'received_at' => 'datetime',
'created_at' => 'datetime',
-2
View File
@@ -29,8 +29,6 @@ use Illuminate\Support\Facades\DB;
* @property string $deadline_at
* @property string|null $completed_at
* @property bool $processing_restricted
*
* @mixin IdeHelperPdSubjectRequest
*/
class PdSubjectRequest extends Model
{
-2
View File
@@ -40,7 +40,6 @@ class Project extends Model
'tag',
'type',
'is_active',
'paused_at',
'daily_limit_target',
'effective_daily_limit_today',
'effective_limit_calculated_at',
@@ -72,7 +71,6 @@ class Project extends Model
{
return [
'is_active' => 'boolean',
'paused_at' => 'datetime',
'daily_limit_target' => 'integer',
'effective_daily_limit_today' => 'integer',
'region_mask' => 'integer',
-7
View File
@@ -41,11 +41,6 @@ class SupplierLead extends Model
'recovered_from_csv_at',
'deals_created_count',
'error',
// Lead region resolution (Session 1, 31.05.2026) — persistent idempotency + display.
'resolved_subject_code',
'region_source',
'dadata_qc',
'phone_operator',
];
protected function casts(): array
@@ -57,8 +52,6 @@ class SupplierLead extends Model
'recovered_from_csv_at' => 'datetime',
'vid' => 'integer',
'deals_created_count' => 'integer',
'resolved_subject_code' => 'integer',
'dadata_qc' => 'integer',
];
}
-3
View File
@@ -8,15 +8,12 @@ use Illuminate\Database\Eloquent\Model;
/**
* Замок «поставка клиент» (Billing v2 Spec B). Композитный PK без автоинкремента.
*
* Пишется в шеринг-пути (RouteSupplierLeadJob) через insertOrIgnore под RLS-контекстом.
*
* @property int $supplier_lead_id
* @property int $tenant_id
* @property int|null $deal_id
* @property string $created_at
*
* @mixin IdeHelperSupplierLeadDelivery
*/
class SupplierLeadDelivery extends Model
{
@@ -25,8 +25,6 @@ use Illuminate\Support\Carbon;
* @property int|null $resolved_by_user_id
* @property Carbon|null $created_at
* @property Carbon|null $resolved_at
*
* @mixin IdeHelperSupplierManualSyncQueue
*/
class SupplierManualSyncQueue extends Model
{
+3 -62
View File
@@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
/**
* Тенант клиент SaaS-портала Лидерра.
@@ -91,67 +90,9 @@ class Tenant extends Model
*/
public function requiredLeadsForTomorrow(): int
{
// R-19 (Stage 4 §4.4.3): share-aware preflight. For each active project
// count the tenant's PROPORTIONAL share of the supplier group order (not
// the raw daily_limit_target), since the supplier caps the group at
// max(max(limits), ceil(Σ/3)) and splits it across all clients sharing
// the same signal_identifier. Legacy projects (signal_type=null —
// webhook-only, no supplier sharing) still count their full limit.
$projects = $this->projects()->where('is_active', true)->get();
if ($projects->isEmpty()) {
return 0;
}
$total = 0;
foreach ($projects as $p) {
// Webhook-only legacy projects don't participate in supplier sharing.
if (! in_array($p->signal_type, ['site', 'call', 'sms'], true)) {
$total += (int) $p->daily_limit_target;
continue;
}
$groupLimits = DB::connection('pgsql_supplier')
->table('projects')
->where('is_active', true)
->where('signal_type', $p->signal_type)
->where(function ($q) use ($p): void {
if (in_array($p->signal_type, ['site', 'call'], true)) {
$q->where('signal_identifier', $p->signal_identifier);
} else {
// sms: agnostic group is (first sender, keyword-or-NULL).
$firstSender = (string) ($p->sms_senders[0] ?? '');
$q->whereJsonContains('sms_senders', $firstSender);
if ($p->sms_keyword !== null && $p->sms_keyword !== '') {
$q->where('sms_keyword', $p->sms_keyword);
} else {
$q->whereNull('sms_keyword');
}
}
})
->pluck('daily_limit_target')
->all();
if ($groupLimits === []) {
// Edge: project not yet visible from pgsql_supplier view (cross-conn race).
// Conservatively count full limit — avoids underestimating preflight.
$total += (int) $p->daily_limit_target;
continue;
}
$intLimits = array_map('intval', $groupLimits);
$sum = (int) array_sum($intLimits);
$max = (int) max($intLimits);
$groupOrder = max($max, (int) ceil($sum / 3));
if ($sum > 0) {
$share = (int) ceil($groupOrder * ((int) $p->daily_limit_target / $sum));
$total += $share;
}
}
return $total;
return (int) $this->projects()
->where('is_active', true)
->sum('daily_limit_target');
}
/** @return BelongsTo<TariffPlan, $this> */
-104
View File
@@ -1,104 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Audit;
use InvalidArgumentException;
/**
* Shared config hash-chain for 6 audit tables.
*
* Single source of truth for writer (db/schema.sql trigger audit_chain_hash()),
* verify (App\Console\Commands\VerifyAuditChains) and rebuild
* (App\Console\Commands\AuditRebuildChain).
*
* ADR-018: per-tenant via RLS scope for tenant tables,
* global for BYPASSRLS tables.
*
* columns: list in ordinal_position order from db/schema.sql.
* '__log_hash__' -- marker for log_hash position -> NULL::bytea in ROW().
*
* partition: SQL fragment for OVER (PARTITION BY ... ORDER BY id),
* reproducing the RLS-scope of the trigger.
* '' = global chain within partition (for BYPASSRLS tables).
*/
final class AuditChainConfig
{
/**
* @var array<string, array{columns: list<string>, partition: string}>
*/
public const TABLES = [
'auth_log' => [
'columns' => [
'id', 'actor_type', 'tenant_id', 'user_id', 'saas_admin_user_id',
'email', 'event', 'ip_address', 'user_agent', 'failure_reason',
'__log_hash__', 'created_at',
],
'partition' => '',
],
'activity_log' => [
'columns' => [
'id', 'tenant_id', 'user_id', 'deal_id', 'event',
'old_value', 'new_value', 'context', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
],
'tenant_operations_log' => [
'columns' => [
'id', 'tenant_id', 'user_id', 'entity_type', 'entity_id',
'event', 'payload_before', 'payload_after', 'ip_address', 'user_agent',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
],
'balance_transactions' => [
'columns' => [
'id', 'tenant_id', 'type', 'amount_rub', 'amount_leads',
'balance_rub_after', 'balance_leads_after', 'description',
'related_type', 'related_id', 'user_id', 'admin_user_id',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
],
'pd_processing_log' => [
'columns' => [
'id', 'tenant_id', 'subject_type', 'subject_id', 'action',
'purpose', 'actor_tenant_user_id', 'actor_admin_user_id', 'ip_address',
'__log_hash__', 'created_at',
],
'partition' => 'PARTITION BY tenant_id',
],
'saas_admin_audit_log' => [
'columns' => [
'id', 'admin_user_id', 'action', 'target_type', 'target_id',
'target_tenant_id', 'payload_before', 'payload_after', 'reason',
'ip_address', 'user_agent', 'requires_approval', 'approved_by', 'approved_at',
'__log_hash__', 'created_at',
],
'partition' => '',
],
];
/**
* Build ROW(col1, col2, ..., NULL::bytea, ..., coln) with NULL::bytea at log_hash position.
*
* @throws InvalidArgumentException if table is not registered in TABLES
*/
public static function rowExpression(string $table): string
{
if (! isset(self::TABLES[$table])) {
throw new InvalidArgumentException(
"Table '{$table}' is not registered in AuditChainConfig::TABLES"
);
}
$parts = [];
foreach (self::TABLES[$table]['columns'] as $col) {
$parts[] = ($col === '__log_hash__') ? 'NULL::bytea' : "t.{$col}";
}
return 'ROW('.implode(', ', $parts).')';
}
}
+4 -28
View File
@@ -56,17 +56,6 @@ final class LedgerService
);
$priceKopecks = (int) $tier->price_per_lead_kopecks;
// R-03 (Stage 3 §4.3.1): frozen tenant must not receive new charges even
// if balance_rub > 0. Throwing here triggers the same auto-pause flow as
// InsufficientBalance — RouteSupplierLeadJob::handleInsufficientBalance
// flips projects.is_active=false and queues ZeroBalancePausedMail rate-limited.
if ($lockedTenant->frozen_by_balance_at !== null) {
throw new InsufficientBalanceException(
priceKopecks: $priceKopecks,
balanceRub: (string) $lockedTenant->balance_rub,
);
}
// bcmath: balance_rub × 100 ≥ priceKopecks — единственный путь списания.
// Billing v2 Spec A: prepaid-лиды убраны, balance_leads НЕ читается и НЕ изменяется.
$balanceKopecks = bcmul((string) $lockedTenant->balance_rub, '100', 0);
@@ -139,17 +128,10 @@ final class LedgerService
{
if ($lead->supplier_project_id !== null) {
$sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first();
if ($sp !== null) {
if (in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
if ($supplier !== null) {
return (int) $supplier->id;
}
}
if ($sp->platform === 'DIRECT') {
$supplier = Supplier::where('code', 'direct')->first();
return $supplier?->id;
if ($sp !== null && in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
if ($supplier !== null) {
return (int) $supplier->id;
}
}
}
@@ -161,12 +143,6 @@ final class LedgerService
return $supplier?->id;
}
// Phase 3: project без B-префикса (и не пустой) → DIRECT.
if ($project !== '') {
$supplier = Supplier::where('code', 'direct')->first();
return $supplier?->id;
}
return null;
}
@@ -1,47 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use Illuminate\Support\Facades\Cache;
/**
* Дневной бюджет на платные вызовы DaData (spec §5.3 / §11).
*
* Расход копится в копейках под дневным ключом `phone_resolution:dadata:spent_kopecks:<YYYY-MM-DD>`.
* `Cache::increment` на redis-сторе атомарен (INCRBY) корректно при параллельных
* RouteSupplierLeadJob. Дневной ключ сам обнуляется со сменой даты; TTL 2 дня чистит старые.
*
* При canSpend()=false LeadRegionResolver минует DaData и идёт сразу в Россвязь (spec §3.3).
*/
class DaDataBudgetGuard
{
public function canSpend(): bool
{
$capKopecks = ((int) config('services.dadata.daily_cap_rub', 10000)) * 100;
return $this->spentTodayKopecks() < $capKopecks;
}
public function recordSpend(int $kopecks): void
{
if ($kopecks <= 0) {
return;
}
$key = $this->dailyKey();
Cache::add($key, 0, now()->addDays(2));
Cache::increment($key, $kopecks);
}
public function spentTodayKopecks(): int
{
return (int) Cache::get($this->dailyKey(), 0);
}
private function dailyKey(): string
{
return 'phone_resolution:dadata:spent_kopecks:'.now()->format('Y-m-d');
}
}
@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use RuntimeException;
/**
* Не-2xx ответ DaData (после исчерпания retry) или иная ошибка вызова.
* LeadRegionResolver ловит её и деградирует на Россвязь (spec §3.3).
*/
class DaDataException extends RuntimeException {}
@@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Factory as HttpFactory;
/**
* HTTP-обёртка над DaData clean/phone (spec §3.6).
*
* POST https://cleaner.dadata.ru/api/v1/clean/phone
* Authorization: Token <key> ; X-Secret: <secret> ; body ["<phone>"]
*
* Retry только на сетевые ошибки и 5xx (4xx сразу DaDataException, без retry).
* Сеть/таймаут после исчерпания retry DaDataTimeoutException; 5xx DaDataException.
* Конвенция клиента зеркалит App\Services\Supplier\SupplierPortalClient (inject HttpFactory).
*/
class DaDataPhoneClient
{
private const URL = 'https://cleaner.dadata.ru/api/v1/clean/phone';
public function __construct(
private readonly HttpFactory $http,
) {}
public function cleanPhone(string $phone): DaDataPhoneResponse
{
$cfg = (array) config('services.dadata');
$timeoutSec = max(1, (int) round(((int) ($cfg['timeout_ms'] ?? 2000)) / 1000));
$attempts = max(1, (int) ($cfg['retries'] ?? 1) + 1);
$apiKey = (string) ($cfg['api_key'] ?? '');
$secret = (string) ($cfg['secret'] ?? '');
$lastException = null;
for ($attempt = 0; $attempt < $attempts; $attempt++) {
try {
$response = $this->http
->asJson()
->acceptJson()
->timeout($timeoutSec)
->withHeaders([
'Authorization' => 'Token '.$apiKey,
'X-Secret' => $secret,
])
->post(self::URL, [$phone]);
} catch (ConnectionException $e) {
$lastException = new DaDataTimeoutException(
'DaData connection failed: '.$e->getMessage(), 0, $e,
);
continue; // сеть → retry
}
if ($response->serverError()) {
$lastException = new DaDataException('DaData 5xx: HTTP '.$response->status());
continue; // 5xx → retry
}
if (! $response->successful()) {
// 4xx — клиентская ошибка, retry бессмыслен.
throw new DaDataException('DaData HTTP '.$response->status().': '.$response->body());
}
return $this->parse($response->json());
}
throw $lastException ?? new DaDataException('DaData failed without a response');
}
/**
* @param mixed $body декодированный JSON (ожидается массив с одним объектом)
*/
private function parse($body): DaDataPhoneResponse
{
$row = (is_array($body) && isset($body[0]) && is_array($body[0])) ? $body[0] : [];
return new DaDataPhoneResponse(
qc: isset($row['qc']) ? (int) $row['qc'] : null,
qcConflict: isset($row['qc_conflict']) ? (int) $row['qc_conflict'] : null,
type: isset($row['type']) ? (string) $row['type'] : null,
phone: isset($row['phone']) ? (string) $row['phone'] : null,
provider: isset($row['provider']) ? (string) $row['provider'] : null,
region: isset($row['region']) ? (string) $row['region'] : null,
city: isset($row['city']) ? (string) $row['city'] : null,
timezone: isset($row['timezone']) ? (string) $row['timezone'] : null,
raw: $row,
);
}
}
@@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Распарсенный ответ DaData clean/phone (один номер один объект), spec §3.6.
*/
final class DaDataPhoneResponse
{
/**
* @param array<string, mixed> $raw полный сырой объект ответа (для маскированного лога)
*/
public function __construct(
public readonly ?int $qc,
public readonly ?int $qcConflict,
public readonly ?string $type,
public readonly ?string $phone,
public readonly ?string $provider,
public readonly ?string $region,
public readonly ?string $city,
public readonly ?string $timezone,
public readonly array $raw,
) {}
}
@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Код качества (`qc`) ответа DaData clean/phone.
*
* Семантика DaData:
* 0 телефон распознан уверенно;
* 1 распознан с допущениями (требует проверки);
* 2 пустой / невозможно распознать;
* 3 несколько телефонов в одном поле;
* 7 иностранный номер.
*
* Решения каскада по qc в LeadRegionResolver (spec §3.4). Enum используется
* для читаемости и tryFrom() при парсинге; необъявленные значения остаются как int.
*/
enum DaDataQualityCode: int
{
case RECOGNIZED = 0;
case ASSUMPTIONS = 1;
case EMPTY = 2;
case MULTIPLE = 3;
case FOREIGN = 7;
}
@@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\DaData;
/**
* Сетевая ошибка / таймаут DaData (после исчерпания retry на сетевые сбои).
* Подкласс DaDataException catch(DaDataException) покрывает оба случая.
*/
class DaDataTimeoutException extends DaDataException {}
-118
View File
@@ -1,118 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Dto;
use App\Models\SupplierLead;
/**
* Результат резолва региона лида (LeadRegionResolver, spec §3.3).
*
* `subjectCode` итоговый код субъекта (используется маршрутизатором);
* `actualSubjectCode` настоящий резолв (для лога actual_subject_code; на этапе
* резолва равен subjectCode, подмена региона концерн RouteSupplierLeadJob §3.10).
* `source` dadata|rossvyaz|tag|unknown ранг см. SOURCE_RANK (CSV-merge §3.12).
*/
final readonly class RegionResolution
{
/** @var array<string, int> ранг источника для CSV-merge (выше = достовернее) */
public const SOURCE_RANK = [
'dadata' => 4,
'rossvyaz' => 3,
'tag' => 2,
'unknown' => 1,
];
/**
* @param array<string, mixed>|null $dadataResponseMasked
*/
public function __construct(
public ?int $subjectCode,
public ?int $actualSubjectCode,
public string $source,
public ?string $phoneOperator,
public ?int $qc,
public bool $cacheHit,
public ?array $dadataResponseMasked,
public ?int $durationMs,
public bool $rossvyazMatched,
) {}
/**
* @param array<string, mixed>|null $dadataMasked
*/
public static function make(
?int $subjectCode,
string $source,
?string $operator = null,
?int $qc = null,
bool $cacheHit = false,
?array $dadataMasked = null,
?int $durationMs = null,
bool $rossvyazMatched = false,
): self {
return new self(
subjectCode: $subjectCode,
actualSubjectCode: $subjectCode,
source: $source,
phoneOperator: $operator,
qc: $qc,
cacheHit: $cacheHit,
dadataResponseMasked: $dadataMasked,
durationMs: $durationMs,
rossvyazMatched: $rossvyazMatched,
);
}
public static function fromTag(?int $subjectCode): self
{
return self::make($subjectCode, $subjectCode !== null ? 'tag' : 'unknown');
}
/**
* Восстановление из persistent state лида (retry-идемпотентность §3.11) без DaData-вызова.
*/
public static function fromSupplierLead(SupplierLead $lead): self
{
return self::make(
subjectCode: $lead->resolved_subject_code !== null ? (int) $lead->resolved_subject_code : null,
source: (string) ($lead->region_source ?? 'unknown'),
operator: $lead->phone_operator,
qc: $lead->dadata_qc !== null ? (int) $lead->dadata_qc : null,
);
}
public function withCacheHit(bool $hit): self
{
return new self(
subjectCode: $this->subjectCode,
actualSubjectCode: $this->actualSubjectCode,
source: $this->source,
phoneOperator: $this->phoneOperator,
qc: $this->qc,
cacheHit: $hit,
dadataResponseMasked: null, // §3.11: cache-hit лог не несёт masked-ответ
durationMs: $this->durationMs,
rossvyazMatched: $this->rossvyazMatched,
);
}
/**
* Версия для записи в кэш (§7.3): без per-call полей (masked-ответ, длительность, cache-флаг).
*/
public function forCache(): self
{
return new self(
subjectCode: $this->subjectCode,
actualSubjectCode: $this->actualSubjectCode,
source: $this->source,
phoneOperator: $this->phoneOperator,
qc: $this->qc,
cacheHit: false,
dadataResponseMasked: null,
durationMs: null,
rossvyazMatched: $this->rossvyazMatched,
);
}
}
-20
View File
@@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Dto;
/**
* Read-only результат поиска по реестру нумерации Россвязи (`phone_ranges`).
*
* `subjectCode` код субъекта РФ 1..89 (см. App\Support\RussianRegions) либо
* null, если для диапазона он не был промаплен при импорте.
*/
final readonly class RossvyazRecord
{
public function __construct(
public ?int $subjectCode,
public string $region,
public string $operator,
) {}
}
-176
View File
@@ -1,176 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\SupplierLead;
use App\Services\DaData\DaDataBudgetGuard;
use App\Services\DaData\DaDataException;
use App\Services\DaData\DaDataPhoneClient;
use App\Services\DaData\DaDataPhoneResponse;
use App\Services\Dto\RegionResolution;
use App\Support\DaDataRegionMap;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
/**
* Оркестратор резолва региона лида: DaData Россвязь tag-fallback (spec §3.3, §3.4).
*
* Каскад решений по qc:
* qc 0/3 + region не-ambiguous и маппится source=dadata;
* qc 0/3 + region ambiguous/null/не-маппится Россвязь (оператор от DaData сохраняем, §3.4.1);
* qc 1 / таймаут / 5xx / бюджет исчерпан Россвязь;
* qc 2/7 tag (Россвязь бессмысленна).
* Если ничего не дало код source=tag (или unknown при пустом теге).
*
* Кэш по sha256(phone) (без сырого номера в ключе/значении, §7.3). Persistent-idempotency
* по supplier_leads.resolved_subject_code (защита от двойной оплаты DaData на retry, §3.11).
* Feature-flag services.dadata.enabled=false сразу tag (текущее поведение, §6.5).
*/
class LeadRegionResolver
{
public function __construct(
private readonly DaDataPhoneClient $dadataClient,
private readonly DaDataBudgetGuard $budgetGuard,
private readonly RossvyazPrefixLookup $rossvyazLookup,
private readonly RegionTagResolver $tagResolver,
private readonly CacheRepository $cache,
) {}
public function resolve(SupplierLead $lead): RegionResolution
{
// Feature-flag: резолвер выключен → текущее tag-поведение.
if (! (bool) config('services.dadata.enabled', false)) {
return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null);
}
// Persistent-idempotency: уже резолвили на предыдущем try → без DaData.
if ($lead->resolved_subject_code !== null || $lead->region_source !== null) {
return RegionResolution::fromSupplierLead($lead);
}
$phone = (string) $lead->phone;
if (! preg_match('/^7\d{10}$/', $phone)) {
return $this->tagFallback($lead, provider: null, qc: null, masked: null, start: null);
}
$cacheKey = $this->cacheKey($phone);
$cached = $this->cache->get($cacheKey);
if ($cached instanceof RegionResolution) {
return $cached->withCacheHit(true);
}
$resolution = $this->doResolve($lead, $phone);
$ttlDays = max(1, (int) config('services.dadata.cache_ttl_days', 30));
$this->cache->put($cacheKey, $resolution->forCache(), now()->addDays($ttlDays));
return $resolution;
}
private function doResolve(SupplierLead $lead, string $phone): RegionResolution
{
$start = microtime(true);
$provider = null;
$qc = null;
$masked = null;
// 1. DaData (под дневным бюджетом).
if ($this->budgetGuard->canSpend()) {
try {
$dadata = $this->dadataClient->cleanPhone($phone);
$this->budgetGuard->recordSpend((int) config('services.dadata.call_cost_kopecks', 60));
$qc = $dadata->qc;
$provider = $dadata->provider;
$masked = $this->maskResponse($dadata);
if (in_array($dadata->qc, [0, 3], true)) {
$region = (string) ($dadata->region ?? '');
if ($region !== '' && ! DaDataRegionMap::isAmbiguous($region)) {
$code = DaDataRegionMap::toSubjectCode($region);
if ($code !== null) {
return RegionResolution::make(
$code, 'dadata',
operator: $provider, qc: $qc,
dadataMasked: $masked, durationMs: $this->ms($start),
);
}
// qc=0/3, но регион не маппится → страховка Россвязью.
}
// ambiguous / region=null / не-маппится → Россвязь (provider от DaData сохраняем).
} elseif ($dadata->qc === 2 || $dadata->qc === 7) {
// Мусор / иностранец → Россвязь не поможет, сразу tag.
return $this->tagFallback($lead, $provider, $qc, $masked, $start);
}
// qc=1 → Россвязь.
} catch (DaDataException) {
// Сеть / таймаут / 5xx → деградируем на Россвязь, не падаем.
}
}
// 2. Россвязь.
$rossvyaz = $this->rossvyazLookup->find($phone);
if ($rossvyaz !== null) {
$code = $rossvyaz->subjectCode ?? DaDataRegionMap::toSubjectCode($rossvyaz->region);
if ($code !== null) {
return RegionResolution::make(
$code, 'rossvyaz',
operator: $provider ?? $rossvyaz->operator, // оператор от DaData приоритетнее (MNP)
qc: $qc, dadataMasked: $masked,
durationMs: $this->ms($start), rossvyazMatched: true,
);
}
}
// 3. Tag-fallback.
return $this->tagFallback($lead, $provider, $qc, $masked, $start);
}
private function tagFallback(SupplierLead $lead, ?string $provider, ?int $qc, ?array $masked, ?float $start): RegionResolution
{
$tag = (string) (is_array($lead->raw_payload) ? ($lead->raw_payload['tag'] ?? '') : '');
$tagCode = $this->tagResolver->resolve($tag);
return RegionResolution::make(
$tagCode,
$tagCode !== null ? 'tag' : 'unknown',
operator: $provider,
qc: $qc,
dadataMasked: $masked,
durationMs: $start !== null ? $this->ms($start) : null,
);
}
private function cacheKey(string $phone): string
{
return 'phone-region:'.hash('sha256', $phone);
}
private function ms(float $start): int
{
return (int) round((microtime(true) - $start) * 1000);
}
/**
* @return array<string, mixed> сырой ответ DaData с маскированным телефоном (§7.1)
*/
private function maskResponse(DaDataPhoneResponse $response): array
{
$raw = $response->raw;
if (isset($raw['phone']) && is_string($raw['phone'])) {
$raw['phone'] = $this->maskPhone($raw['phone']);
}
return $raw;
}
private function maskPhone(string $phone): string
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
if (strlen($digits) < 8) {
return '***';
}
return substr($digits, 0, 4).'***'.substr($digits, -4);
}
}
+44 -229
View File
@@ -9,254 +9,69 @@ use App\Models\SupplierProject;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Random\Randomizer;
/**
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6) с
* каскадной маршрутизацией по региону (lead region resolution §3.9).
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
*
* Eligibility структурно через snapshot `project_routing_snapshots` за активную
* дату слепка (slepok-инвариант): до 21:00 МСК активен snapshot сегодняшней даты,
* с 21:00 МСК завтрашней. Все эффективные параметры маршрутизации берутся из
* snapshot; из live `projects` только `delivered_today` (остаток лимита),
* из `tenants` `balance_rub` + `frozen_by_balance_at` (live auto-pause).
* Eligibility структурно через pivot project_supplier_links: проект eligible,
* если связан с пришедшим supplier_project (= источник × субъект) + активен +
* сегодня рабочий день + есть остаток лимита + у тенанта есть баланс.
*
* Каскад (§3.9): один SQL оборачивается тремя фазами по убыванию точности региона:
* 1) точное совпадение субъекта (`?::int = ANY(snap.regions)`);
* 2) «вся РФ» (`snap.regions = '{}'`), добор недостающих слотов;
* 3) запасной канал (без фильтра региона) только если первые две пусты;
* сделкам в этой фазе подменяется subject_code (RouteSupplierLeadJob §3.10).
* Каждый Project помечается атрибутом `routing_step` (1/2/3).
*
* Отбор внутри фазы при кандидатах > cap **взвешенный жребий по остатку лимита**
* (вариант D1=В): шанс остатку, но у каждого кандидата шанс > 0 (вес 1)
* маленькие клиенты не отрезаются. cap = LeadDistributor::CAP (лид продаётся ≤3 раз).
* Жребий через инъектируемый \Random\Randomizer (тесты сидируют Mt19937).
* Регион сопоставляется самим supplier_project (тег = субъект) phone-prefix
* фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
* гарантирован тем, через какой supplier_project пришёл лид.
*
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) в
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
*
* Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.3
* + docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md §3.9.
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
*/
class LeadRouter
{
public function __construct(
private readonly Randomizer $randomizer = new Randomizer,
) {}
/**
* Возвращает cap проектов (по одному на tenant), отобранных каскадом
* по региону + взвешенным жребием. Каждый Project несёт `routing_step`.
* Возвращает ONE project per tenant_id тот, у которого наибольший остаток
* дневного лимита (DISTINCT ON (tenant_id) с ORDER BY remaining DESC, created_at, id).
*
* Семантика (Spec B Task 3): один лид продаётся не более чем 3 РАЗЛИЧНЫМ тенантам
* (клиентам), каждый тенант получает ровно ОДИН проект с наибольшим остатком.
* LeadDistributor::selectRecipients (CAP=3) теперь ограничивает число тенантов,
* а не число проектов, потому что входные данные уже one-per-tenant.
*
* Запрос через pgsql_supplier (BYPASSRLS crm_supplier_worker) tenant ещё не
* определён, SELECT видит проекты всех tenant'ов.
*
* @return Collection<int, Project>
*/
public function matchEligibleProjects(SupplierProject $supplierProject, ?int $resolvedSubjectCode = null): Collection
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
{
$activeDate = $this->activeSnapshotDate();
$cap = LeadDistributor::CAP;
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
// Фаза 1: точное совпадение региона (только если резолвер дал subject_code).
$exact = $resolvedSubjectCode !== null
? $this->queryCandidates($activeDate, $supplierProject, 'exact', $resolvedSubjectCode, [])
: collect();
$selected = $this->weightedPick($exact, $cap);
$this->tagStep($selected, 1);
if ($selected->count() >= $cap) {
return $selected->take($cap)->values();
}
// Фаза 2: «вся РФ», добор недостающих слотов (исключая уже выбранных tenant'ов).
$allRu = $this->queryCandidates(
$activeDate, $supplierProject, 'all_ru', null,
$selected->pluck('tenant_id')->all(),
);
$pickedRu = $this->weightedPick($allRu, $cap - $selected->count());
$this->tagStep($pickedRu, 2);
$combined = $selected->concat($pickedRu);
if ($combined->isNotEmpty()) {
return $combined->take($cap)->values();
}
// Фаза 3: запасной канал (никто не подписан на регион и нет «вся РФ»).
$fallback = $this->weightedPick(
$this->queryCandidates($activeDate, $supplierProject, 'any', null, []),
$cap,
);
$this->tagStep($fallback, 3);
$this->logIfNoSnapshot($fallback->all(), $supplierProject, $activeDate);
return $fallback->take($cap)->values();
}
/**
* Один SQL-запрос фазы каскада: DISTINCT ON (tenant_id) с фильтром региона.
* regionFilter exact|all_ru|any. Возвращает всех eligible (по одному на tenant),
* упорядоченных по остатку лимита DESC, created_at, id; жребий поверх в PHP.
*
* @param list<int> $excludeTenantIds
* @return Collection<int, Project>
*/
private function queryCandidates(string $activeDate, SupplierProject $sp, string $regionFilter, ?int $code, array $excludeTenantIds): Collection
{
$bindings = [$activeDate];
if ($sp->platform === 'DIRECT') {
// DIRECT supplier_projects не имеют pivot — матч по signal_type + identifier.
$sourceWhere = 'snap.signal_type = ? AND LOWER(snap.signal_identifier) = LOWER(?)';
$bindings[] = $sp->signal_type;
$bindings[] = $sp->unique_key;
} else {
$sourceWhere = 'EXISTS (SELECT 1 FROM project_supplier_links psl
WHERE psl.project_id = snap.project_id AND psl.supplier_project_id = ?)';
$bindings[] = $sp->id;
}
$regionWhere = '';
if ($regionFilter === 'exact') {
$regionWhere = 'AND ?::int = ANY(snap.regions)';
$bindings[] = $code;
} elseif ($regionFilter === 'all_ru') {
$regionWhere = "AND snap.regions = '{}'::int[]";
}
$excludeWhere = '';
if ($excludeTenantIds !== []) {
$placeholders = implode(',', array_fill(0, count($excludeTenantIds), '?'));
$excludeWhere = "AND snap.tenant_id NOT IN ($placeholders)";
foreach ($excludeTenantIds as $tid) {
$bindings[] = $tid;
}
}
$sql = <<<SQL
SELECT DISTINCT ON (snap.tenant_id)
projects.*,
snap.daily_limit AS snapshot_daily_limit,
snap.regions AS snapshot_regions
FROM project_routing_snapshots snap
INNER JOIN projects ON projects.id = snap.project_id
WHERE snap.snapshot_date = ?::date
AND $sourceWhere
AND projects.delivered_today < snap.daily_limit
AND EXISTS (
SELECT 1 FROM tenants
WHERE tenants.id = snap.tenant_id
AND tenants.balance_rub > 0
AND tenants.frozen_by_balance_at IS NULL
)
$regionWhere
$excludeWhere
ORDER BY snap.tenant_id,
(snap.daily_limit - projects.delivered_today) DESC,
projects.created_at,
projects.id
$sql = <<<'SQL'
SELECT DISTINCT ON (projects.tenant_id) projects.*
FROM projects
WHERE EXISTS (
SELECT 1 FROM project_supplier_links psl
WHERE psl.project_id = projects.id
AND psl.supplier_project_id = ?
)
AND projects.is_active = true
AND (projects.delivery_days_mask & ?) <> 0
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
AND EXISTS (
SELECT 1 FROM tenants
WHERE tenants.id = projects.tenant_id
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
)
ORDER BY
projects.tenant_id,
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
projects.created_at,
projects.id
SQL;
return Project::hydrate(DB::connection('pgsql_supplier')->select($sql, $bindings));
}
$rows = DB::connection('pgsql_supplier')->select($sql, [$supplierProject->id, $todayBit]);
/**
* Взвешенный жребий без возврата (вариант D1=В): отбирает $n кандидатов,
* вероятность остатку лимита, вес 1 у каждого (мелкие не отрезаются).
* При кандидатах $n возвращает всех в исходном SQL-порядке (детерминизм).
*
* @param Collection<int, Project> $candidates
* @return Collection<int, Project>
*/
private function weightedPick(Collection $candidates, int $n): Collection
{
if ($n <= 0) {
return collect();
}
$pool = $candidates->values()->all();
if (count($pool) <= $n) {
return collect($pool);
}
$picked = [];
for ($i = 0; $i < $n && $pool !== []; $i++) {
$total = 0;
foreach ($pool as $p) {
$total += $this->weightOf($p);
}
$roll = $this->randomizer->getInt(1, $total);
$acc = 0;
$winner = 0;
foreach ($pool as $idx => $p) {
$acc += $this->weightOf($p);
if ($roll <= $acc) {
$winner = $idx;
break;
}
}
$picked[] = $pool[$winner];
array_splice($pool, $winner, 1);
}
return collect($picked);
}
private function weightOf(Project $project): int
{
$remaining = (int) $project->snapshot_daily_limit - (int) $project->delivered_today;
return max(1, $remaining);
}
/**
* @param Collection<int, Project> $projects
*/
private function tagStep(Collection $projects, int $step): void
{
foreach ($projects as $project) {
$project->setAttribute('routing_step', $step);
}
}
/**
* Активная дата слепка: до 21:00 МСК сегодня, с 21:00 МСК завтра (§4.2.3).
*/
private function activeSnapshotDate(): string
{
$msk = Carbon::now('Europe/Moscow');
return $msk->hour >= 21
? $msk->copy()->addDay()->toDateString()
: $msk->toDateString();
}
/**
* Fail-loud: пишет в лог, если по активной дате слепка вообще нет ни одной строки
* snapshot'а (cron SnapshotProjectRoutingJob не отработал). Пустой валидный
* результат при наличии snapshot'ов не алерт.
*
* @param array<int, mixed> $rows
*/
private function logIfNoSnapshot(array $rows, SupplierProject $supplierProject, string $activeDate): void
{
if ($rows !== []) {
return;
}
$snapshotEmpty = DB::connection('pgsql_supplier')
->table('project_routing_snapshots')
->where('snapshot_date', $activeDate)
->doesntExist();
if ($snapshotEmpty) {
Log::error('lead_router.no_snapshot_for_active_date', [
'active_date' => $activeDate,
'supplier_project_id' => $supplierProject->id,
'platform' => $supplierProject->platform,
]);
}
return Project::hydrate($rows)->values();
}
}
@@ -57,10 +57,6 @@ class MonthlyPartitionManager
'balance_transactions' => 'created_at',
'pd_processing_log' => 'created_at',
'saas_admin_audit_log' => 'created_at',
// Slepok routing (Этап 2, 27.05.2026)
'project_routing_snapshots' => 'snapshot_date',
// Lead region resolution (Session 1, 31.05.2026)
'lead_region_resolution_log' => 'received_at',
];
/**
@@ -108,16 +104,7 @@ class MonthlyPartitionManager
if ($exists !== null) {
return false;
}
// Родитель-партиционированная таблица может ещё не существовать
// (создаётся более поздней миграцией) — тогда пропускаем.
$parentExists = DB::selectOne(
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'p'",
[$table],
);
if ($parentExists === null) {
return false;
}
DB::connection(self::DDL_CONNECTION)->statement(sprintf(
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
$partition,
+4 -51
View File
@@ -18,7 +18,6 @@ class ProjectService
{
public function __construct(
private readonly OperationsLogger $ops = new OperationsLogger,
private readonly SupplierSnapshotGuard $snapshotGuard = new SupplierSnapshotGuard,
) {}
public function update(Project $project, array $data): Project
@@ -31,15 +30,6 @@ class ProjectService
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
);
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
// Если меняем источник (signal_identifier / sms_senders / sms_keyword) — guard.
$sourceFieldsTouched = array_key_exists('signal_identifier', $data)
|| array_key_exists('sms_senders', $data)
|| array_key_exists('sms_keyword', $data);
if ($sourceFieldsTouched) {
$this->snapshotGuard->assertCanMutateSource($project, 'change_source');
}
if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) {
throw new HttpResponseException(response()->json([
'errors' => [
@@ -106,26 +96,7 @@ class ProjectService
SyncSupplierProjectJob::dispatch($project->id);
}
// Task 2.8 (Spec §4.2.5): для каждого изменённого slepok-sensitive поля
// вычислить applies_from — момент, с которого правка реально вступит в силу
// (slepok-инвариант: до 18:00 МСК → сегодня 21:00 МСК, после → завтра 21:00 МСК).
// Берём максимум среди затронутых полей. NULL = применяется немедленно.
$appliesFrom = null;
foreach (SupplierSnapshotGuard::SLEPOK_SENSITIVE_FIELDS as $field) {
if (! array_key_exists($field, $data)) {
continue;
}
$candidate = $this->snapshotGuard->appliesFrom($project, $field);
if ($candidate !== null && ($appliesFrom === null || $candidate->gt($appliesFrom))) {
$appliesFrom = $candidate;
}
}
$fresh = $project->fresh();
// Dynamic attribute — не в БД, сериализуется ProjectResource (Task 2.11).
$fresh->applies_from = $appliesFrom;
return $fresh;
return $project->fresh();
}
/**
@@ -178,11 +149,6 @@ class ProjectService
public function delete(Project $project): void
{
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
// Guard поставщикова слепка ПЕРЕД has-deals (приоритетней) — клиент должен
// увидеть формулировку про «уже заказали лиды», а не «есть сделки».
$this->snapshotGuard->assertCanMutateSource($project, 'delete');
$hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
if ($hasDeals) {
throw new HttpResponseException(response()->json([
@@ -295,13 +261,7 @@ class ProjectService
private function bulkPauseResume($query, bool $isActive): array
{
$ids = (clone $query)->pluck('id')->all();
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта. Mass-update НЕ
// триггерит model events, поэтому пишем явно в одном UPDATE.
$updated = $query->update([
'is_active' => $isActive,
'paused_at' => $isActive ? null : DB::raw('NOW()'),
]);
$updated = $query->update(['is_active' => $isActive]);
foreach ($ids as $id) {
SyncSupplierProjectJob::dispatch((int) $id);
}
@@ -331,15 +291,8 @@ class ProjectService
try {
$this->delete($model);
$deleted++;
} catch (HttpResponseException $e) {
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 12).
// Разделяем причину: guard поставщика (нужно подождать) vs has-deals.
$body = json_decode((string) $e->getResponse()->getContent(), true);
$message = (string) ($body['errors']['project'][0] ?? '');
$reason = str_contains($message, 'Мы уже начали сбор лидов')
? 'supplier_snapshot_locked'
: 'has_deals';
$skipped[] = ['id' => $p->id, 'reason' => $reason];
} catch (HttpResponseException) {
$skipped[] = ['id' => $p->id, 'reason' => 'has_deals'];
}
}
@@ -1,137 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services\Project;
use App\Models\Project;
use Carbon\CarbonImmutable;
use Carbon\CarbonInterface;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Support\Facades\DB;
/**
* Защита проекта от удаления/смены источника, пока поставщик crm.bp-gr.ru
* может прислать по нему лиды по уже сделанному слепку.
*
* Slepok-час поставщика: 21:00 МСК (поставщик в 21:00 формирует заказ на завтра).
* Grace: до следующего 21:00 МСК после pause + 24h на доставку хвоста.
*
* Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
*/
class SupplierSnapshotGuard
{
/** Час МСК, в который поставщик заказывает лиды на следующий день. */
public const SUPPLIER_ORDER_HOUR_MSK = 21;
/** Сколько часов после слепка летит хвост лидов (одни сутки). */
public const TAIL_DELIVERY_HOURS = 24;
public function computeGraceUntil(CarbonInterface $pausedAt): CarbonImmutable
{
$pausedMsk = CarbonImmutable::instance($pausedAt)->setTimezone('Europe/Moscow');
$next21 = $pausedMsk->setTime(self::SUPPLIER_ORDER_HOUR_MSK, 0, 0);
if ($pausedMsk->gte($next21)) {
$next21 = $next21->addDay();
}
return $next21->addHours(self::TAIL_DELIVERY_HOURS);
}
public function isProtected(Project $project, ?CarbonImmutable $now = null): bool
{
$hasLinks = DB::table('project_supplier_links')
->where('project_id', $project->id)
->exists();
if (! $hasLinks) {
return false;
}
if ($project->is_active) {
return true;
}
if ($project->paused_at === null) {
return false;
}
$graceUntil = $this->computeGraceUntil($project->paused_at);
$effectiveNow = $now ?? CarbonImmutable::now('Europe/Moscow');
return $effectiveNow->lt($graceUntil);
}
/**
* Slepok-sensitive поля проекта изменения этих полей попадают в slepok №NЛ
* (фиксируется в 18:00 МСК) и начинают действовать с N.21:00 МСК.
*
* Spec §4.2.5 Task 2.7.
*/
public const SLEPOK_SENSITIVE_FIELDS = [
'is_active',
'daily_limit_target',
'delivery_days_mask',
'regions',
'signal_identifier',
'sms_senders',
'sms_keyword',
];
/**
* Возвращает момент, с которого правка `$field` вступит в силу:
* правка до 18:00 МСК сегодня в 21:00 МСК;
* правка с 18:00 МСК и позже завтра в 21:00 МСК.
*
* Возвращает null когда правка применяется немедленно:
* поле не slepok-sensitive (см. SLEPOK_SENSITIVE_FIELDS), либо
* проект не связан с поставщиком (нет project_supplier_links нет slepok-риска).
*
* Используется ProjectService (Task 2.8) для прикрепления к UI-ответу
* метки «изменения вступят в силу с DD.MM HH:MM».
*
* Spec §4.2.5.
*/
public function appliesFrom(Project $project, string $field): ?CarbonImmutable
{
if (! in_array($field, self::SLEPOK_SENSITIVE_FIELDS, true)) {
return null;
}
$hasLinks = DB::table('project_supplier_links')
->where('project_id', $project->id)
->exists();
if (! $hasLinks) {
return null;
}
$nowMsk = CarbonImmutable::now('Europe/Moscow');
$todayCutoff = $nowMsk->setTime(18, 0, 0);
if ($nowMsk->gte($todayCutoff)) {
return $nowMsk->addDay()->setTime(21, 0, 0);
}
return $nowMsk->setTime(21, 0, 0);
}
/**
* @param 'delete'|'change_source' $action
*/
public function assertCanMutateSource(Project $project, string $action): void
{
if (! $this->isProtected($project)) {
return;
}
$verb = $action === 'delete' ? 'Удалить' : 'Изменить источник';
$message = 'Мы уже начали сбор лидов по этому проекту на завтра. '
.'Пока поставьте на паузу — мы увидим это сегодня в 18:00 и завтра '
.'не будем запускать сбор лидов по этому проекту. '
.$verb.' можно будет послезавтра.';
throw new HttpResponseException(response()->json([
'errors' => ['project' => [$message]],
], 422));
}
}
-60
View File
@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Services\Dto\RossvyazRecord;
use Illuminate\Support\Facades\DB;
/**
* Локальный fallback резолва региона/оператора по телефону через реестр
* нумерации Россвязи (`phone_ranges`).
*
* Используется LeadRegionResolver когда DaData недоступна/неуверена (qc=1,
* timeout, бюджет исчерпан). Алгоритм (spec §3.7):
* - def_code = 3 цифры кода ABC/DEF (позиции 1..3 нормализованного номера);
* - subscriber = остаток номера как BIGINT;
* - выбираем самый УЗКИЙ диапазон, накрывающий номер (ORDER BY width ASC),
* т.к. узкие переопределения операторов точнее широких региональных блоков.
*
* Запрос идёт через `pgsql_supplier` (BYPASSRLS на проде, как LeadRouter):
* `phone_ranges` SaaS-level публичные данные без RLS.
*/
class RossvyazPrefixLookup
{
/** Connection для чтения реестра (на проде BYPASSRLS, на dev/test — superuser fallback). */
public const CONNECTION = 'pgsql_supplier';
public function find(string $phone): ?RossvyazRecord
{
$digits = preg_replace('/\D+/', '', $phone) ?? '';
// Российский номер: 7|8 + ABC/DEF (3) + абонент (7) = 11 цифр.
if (strlen($digits) !== 11) {
return null;
}
$defCode = (int) substr($digits, 1, 3);
$subscriber = (int) substr($digits, 4);
$row = DB::connection(self::CONNECTION)->selectOne(
'SELECT region, operator, subject_code
FROM phone_ranges
WHERE def_code = ? AND from_num <= ? AND to_num >= ?
ORDER BY (to_num - from_num) ASC
LIMIT 1',
[$defCode, $subscriber, $subscriber],
);
if ($row === null) {
return null;
}
return new RossvyazRecord(
subjectCode: $row->subject_code !== null ? (int) $row->subject_code : null,
region: (string) $row->region,
operator: (string) $row->operator,
);
}
}
@@ -178,11 +178,9 @@ class SupplierProjectImporter
]);
$createdProjects++;
// R-17 (Stage 4 §4.4.1): unified agnostic key — was per-platform divergence
// for SMS (B3 used sender alone, B2 sender+keyword) creating orphan rows.
$uniqueKey = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
foreach ($item['platforms'] as $pl) {
$platform = (string) $pl['platform'];
$uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform);
/** @var SupplierProject $sp */
$sp = SupplierProject::on(self::DB_CONNECTION)->firstOrCreate(
@@ -19,14 +19,37 @@ use App\Models\Project;
final class SupplierProjectGrouping
{
/**
* Unique identifier key единая агностическая формула для всех платформ
* (Stage 4 §4.4.1 R-17, ранее разделялась на platform-specific buildUniqueKey:
* B3 использовал sender alone, B2 sender+keyword, что создавало orphan
* supplier_projects при rebalance шеринга мы не могли сопоставить B2/B3
* как одну группу):
* Строит unique_key для пары (project, platform):
* site/call signal_identifier (домен / телефон)
* sms B2 sender + '+' + keyword
* sms B3 sender
*
* Для ночного батч-джоба используйте buildUniqueKeyNoplatform() он
* выбирает B2-ключ автоматически при наличии keyword.
*/
public static function buildUniqueKey(Project $project, string $platform): string
{
if (in_array($project->signal_type, ['site', 'call'], true)) {
return (string) $project->signal_identifier;
}
// sms
$sender = (string) ($project->sms_senders[0] ?? '');
if ($platform === 'B2') {
return $sender.'+'.($project->sms_keyword ?? '');
}
// B3
return $sender;
}
/**
* Unique identifier key без привязки к конкретной платформе
* (для группировки в ночном батч-джобе):
* site/call signal_identifier
* sms+keyword sender+keyword
* sms без keyword sender
* sms+keyword sender+keyword (B2 ключ)
* sms без keyword sender (B3 ключ)
*/
public static function buildUniqueKeyAgnostic(Project $project): string
{
@@ -72,6 +95,7 @@ final class SupplierProjectGrouping
public static function subjectsOf(Project $project): array
{
$regions = array_values((array) $project->regions);
// @phpstan-ignore-next-line identical.alwaysFalse — PostgresIntArray PHPDoc non-empty, runtime can be empty
if (count($regions) === 0) {
return [null];
}
@@ -21,7 +21,7 @@ use InvalidArgumentException;
*/
class SupplierProjectResolver
{
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3', 'DIRECT'];
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3'];
private const ALLOWED_SIGNAL_TYPES = ['site', 'call', 'sms'];
-53
View File
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* Маппинг строки региона из ответа DaData код субъекта РФ (1..89).
*
* DaData возвращает регион в поле `region` (например «Москва», «Московская область»).
* Большинство имён точно совпадают с App\Support\RussianRegions::CODE_TO_NAME;
* расхождения (если найдутся на staging) кладутся в OVERRIDES.
*
* «Объединённые» агломерации («Санкт-Петербург и область») DaData не различает
* город и область внутри поля region. Такие строки помечаются isAmbiguous()
* LeadRegionResolver уходит за точным subject_code в Россвязь (spec §3.4.1).
*/
final class DaDataRegionMap
{
/**
* Строки-агломерации, по которым нельзя однозначно определить субъект.
* Расширяется по реальным наблюдениям на staging (spec §3.4.1).
*
* @var list<string>
*/
public const AMBIGUOUS_REGIONS = [
'Санкт-Петербург и область',
'Москва и область',
];
/**
* Ручные переопределения для имён DaData, не совпадающих с RussianRegions.
* На старте пуст заполняется по findings со staging-smoke.
*
* @var array<string, int>
*/
public const OVERRIDES = [];
public static function toSubjectCode(string $name): ?int
{
$name = trim($name);
if ($name === '') {
return null;
}
return self::OVERRIDES[$name] ?? RussianRegions::nameToCode()[$name] ?? null;
}
public static function isAmbiguous(string $name): bool
{
return in_array(trim($name), self::AMBIGUOUS_REGIONS, true);
}
}
-88
View File
@@ -114,97 +114,9 @@ final class RussianRegions
89 => 'Ямало-Ненецкий автономный округ',
];
/**
* Алиасы нестандартных форм реестра Россвязи каноничное имя субъекта.
* Города фед. значения приходят с префиксом «г. »; «Республика Удмуртская»
* перевёрнутый порядок слов; «Кемеровская область - Кузбасс обл.» спец-форма.
*
* @var array<string, string>
*/
private const REGION_ALIASES = [
'г. Москва' => 'Москва',
'Город Москва' => 'Москва',
'г. Санкт-Петербург' => 'Санкт-Петербург',
'г. Санкт - Петербург' => 'Санкт-Петербург',
'г. Севастополь' => 'Севастополь',
'Республика Саха /Якутия/' => 'Республика Саха (Якутия)',
'Чувашская Республика - Чувашия' => 'Чувашская Республика',
'Кемеровская область - Кузбасс обл.' => 'Кемеровская область',
'Кемеровская область - Кузбасс область' => 'Кемеровская область',
'Кемеровская область - Кузбасс' => 'Кемеровская область',
];
/** @return array<string, int> name => code (обратный индекс) */
public static function nameToCode(): array
{
return array_flip(self::CODE_TO_NAME);
}
/**
* Нормализует строку региона реестра Россвязи в каноничное имя субъекта (или null).
*
* Реестр кодирует субъект как ПОСЛЕДНИЙ сегмент после «|»
* (напр. «г. Воскресенск|р-н Воскресенский|Московская обл.» «Московская обл.»),
* с сокращением «обл.» вместо «область» и рядом нестандартных форм (см. REGION_ALIASES).
* Безнадёжные/неоднозначные строки («-», «Российская Федерация»,
* «Москва и Московская область», «г.о. Тольятти») null.
*/
public static function canonicalRegionName(string $raw): ?string
{
$segment = self::lastRegionSegment($raw);
if ($segment === '') {
return null;
}
// ХМАО приходит в множестве форм (em-dash/дефис, «Югра», « АО», капитализация) —
// ловим по двум устойчивым маркерам до общих правил.
if (mb_stripos($segment, 'Ханты') !== false && mb_stripos($segment, 'Мансийск') !== false) {
return 'Ханты-Мансийский автономный округ — Югра';
}
if (isset(self::REGION_ALIASES[$segment])) {
return self::REGION_ALIASES[$segment];
}
// «обл.» → «область»; « АО» → « автономный округ».
$name = (string) preg_replace('/\s*обл\.$/u', ' область', $segment);
$name = (string) preg_replace('/\s+АО$/u', ' автономный округ', $name);
// Дефис с пробелами → длинное тире (эталон: «Республика Северная Осетия — Алания»).
// Безопасно: ни одно каноническое имя не содержит дефис, окружённый пробелами
// (составные имена вроде «Кабардино-Балкарская» используют дефис без пробелов).
$name = str_replace(' - ', ' — ', $name);
if (isset(self::nameToCode()[$name])) {
return $name;
}
// Перевёрнутый порядок «Республика X» → «X Республика» (Удмуртская/Чеченская/
// Чувашская/Кабардино-Балкарская/Карачаево-Черкесская, Донецкая Народная/
// Луганская Народная). Республика-first каноны (Татарстан, Карелия…) уже
// отловлены прямым попаданием выше.
if (preg_match('/^Республика\s+(.+)$/u', $name, $m) === 1) {
$reordered = trim($m[1]).' Республика';
if (isset(self::nameToCode()[$reordered])) {
return $reordered;
}
}
return null;
}
/** Резолвит строку региона реестра Россвязи в subject_code (1..89) или null. */
public static function resolveSubjectCode(string $raw): ?int
{
$name = self::canonicalRegionName($raw);
return $name === null ? null : (self::nameToCode()[$name] ?? null);
}
/** Последний сегмент после «|» (субъект в формате Россвязи), trimmed. */
private static function lastRegionSegment(string $raw): string
{
$parts = explode('|', $raw);
return trim((string) end($parts));
}
}
+5 -52
View File
@@ -8,7 +8,6 @@ use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
@@ -34,43 +33,12 @@ return Application::configure(basePath: dirname(__DIR__))
]);
})
->withExceptions(function (Exceptions $exceptions): void {
// Reduce verbosity of constraint-violation logging (SQLSTATE 23xxx):
// data-validity errors do not need a full stack trace в laravel.log.
// Incident 2026-05-29: 420k повторов B1+SMS check_violation накопили
// 8.7 GB stack traces → disk full → 4h prod downtime.
// Solution: log a warning summary с sqlstate, return false to stop
// default reporting (which would write full stack trace).
// Ref: docs/incidents/2026-05-29-disk-full-pg-recovery.md §5
$exceptions->reportable(function (QueryException $e) {
$sqlState = $e->errorInfo[0] ?? '';
if (is_string($sqlState) && str_starts_with($sqlState, '23')) {
Log::warning('db.constraint_violation', [
'sqlstate' => $sqlState,
'message' => mb_substr($e->getMessage(), 0, 200),
]);
return false; // skip default reporting (no stack trace в laravel.log)
}
return null; // continue default reporting для non-constraint QueryExceptions
});
$exceptions->render(function (QueryException $e, Request $request) {
$sqlState = $e->errorInfo[0] ?? '';
$isConstraintViolation = is_string($sqlState) && str_starts_with($sqlState, '23');
if (! $isConstraintViolation) {
// Default verbose log для non-constraint QueryExceptions (table missing,
// syntax error, etc. — these are bugs needing investigation).
Log::error('db.query_exception', [
'message' => $e->getMessage(),
'sql' => $e->getSql(),
'path' => $request->path(),
]);
}
// Constraint violations уже залогированы в reportable() выше как warning,
// дублировать не нужно.
Log::error('db.query_exception', [
'message' => $e->getMessage(),
'sql' => $e->getSql(),
'path' => $request->path(),
]);
if ($request->expectsJson()) {
return response()->json([
'message' => 'Не удалось сохранить. Проверьте данные или попробуйте ещё раз.',
@@ -79,19 +47,4 @@ return Application::configure(basePath: dirname(__DIR__))
return null; // default render for non-JSON
});
// Supplier webhook always returns JSON, even when client omits Accept header.
// Without this render, Laravel's default ValidationException handler returns
// 302 redirect to /, which strips POST body — losing supplier leads.
// Confirmed 2026-05-25: 76 of 234 webhook hits today got 302 instead of 422.
$exceptions->render(function (ValidationException $e, Request $request) {
if ($request->is('api/webhook/supplier/*')) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->errors(),
], 422);
}
return null; // default render for other routes
});
})->create();
-13
View File
@@ -42,17 +42,4 @@ return [
'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'),
],
// DaData phone cleaner — резолв региона лида по телефону (lead region resolution).
// Ключи → YC Lockbox на проде; на dev/staging — .env. enabled=false до раскатки.
'dadata' => [
'api_key' => env('DADATA_API_KEY'),
'secret' => env('DADATA_SECRET'),
'timeout_ms' => (int) env('DADATA_TIMEOUT_MS', 2000),
'retries' => (int) env('DADATA_RETRIES', 1),
'daily_cap_rub' => (int) env('DADATA_DAILY_CAP_RUB', 10000),
'call_cost_kopecks' => (int) env('DADATA_CALL_COST_KOPECKS', 60), // ≈0.60 ₽/вызов, откалибровать по тарифу
'enabled' => filter_var(env('LEAD_REGION_RESOLVER_ENABLED', false), FILTER_VALIDATE_BOOL),
'cache_ttl_days' => (int) env('PHONE_REGION_CACHE_TTL_DAYS', 30),
],
];
@@ -18,7 +18,6 @@ use Illuminate\Support\Facades\DB;
*/
return new class extends Migration
{
public $withinTransaction = false;
public function up(): void
{
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
@@ -1,51 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* supplier_csv_reconcile_log + unparseable_count: количество CSV-строк
* за окно reconcile, у которых поле «project» не парсится в платформу
* (поставщик иногда кладёт телефон/URL в Name extractPlatform = null,
* строка скипается в csv_reconcile.unparseable_project_skipped).
*
* Раньше эти строки попадали в знаменатель drift_ratio и счётчик missing,
* стабильно завышая drift до ~40-50% (false-positive drift_alert каждый
* запуск). Теперь они учитываются отдельно и вычитаются из формулы.
*
* Используется в CsvReconcileJob + AdminSupplierIntegrationController.
* Таблица SaaS-level (без RLS), пишет/читает crm_supplier_worker
* (BYPASSRLS) pgsql_supplier connection.
*/
return new class extends Migration
{
public function up(): void
{
$conn = DB::connection('pgsql_supplier');
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
return;
}
$conn->unprepared(<<<'SQL'
ALTER TABLE supplier_csv_reconcile_log
ADD COLUMN IF NOT EXISTS unparseable_count INTEGER NOT NULL DEFAULT 0;
SQL);
}
public function down(): void
{
$conn = DB::connection('pgsql_supplier');
if (! $conn->getSchemaBuilder()->hasTable('supplier_csv_reconcile_log')) {
return;
}
$conn->unprepared(<<<'SQL'
ALTER TABLE supplier_csv_reconcile_log
DROP COLUMN IF EXISTS unparseable_count;
SQL);
}
};
@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Phase 3 supplier webhook reliability расширяет platform enum в
* supplier_projects и project_supplier_links до (B1,B2,B3,DIRECT).
*
* DIRECT это «прямая» платформа поставщика без B-префикса в имени
* проекта (e.g. `client.carmoney.ru`, `cashmotor.ru`, числовые телефоны).
* До Phase 3 такие webhook'и отвергались с 302-редиректом и терялись:
* наблюдалось 67 потерь/день на проде 25.05.2026 для tenant client1.
*
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
*
* NB: chk_supplier_projects_b1_not_for_sms (B1+SMS deny) НЕ трогаем
* DIRECT+SMS этим constraint'ом не блокируется (он специфичен для B1).
*/
return new class extends Migration
{
public function up(): void
{
// 1) Расширить platform-колонки до VARCHAR(8) (было VARCHAR(4): "DIRECT" не вмещается).
// supplier_manual_sync_queue.platform уже VARCHAR(8) — пропускаем.
DB::statement('ALTER TABLE supplier_projects ALTER COLUMN platform TYPE VARCHAR(8)');
DB::statement('ALTER TABLE project_supplier_links ALTER COLUMN platform TYPE VARCHAR(8)');
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN platform TYPE VARCHAR(8)');
// 2) Расширить CHECK constraints на enum значения.
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
DB::statement('ALTER TABLE supplier_leads DROP CONSTRAINT chk_supplier_leads_platform');
DB::statement("ALTER TABLE supplier_leads ADD CONSTRAINT chk_supplier_leads_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
}
public function down(): void
{
// Перед откатом — убедиться что в БД нет rows с platform='DIRECT',
// иначе constraint провалится при ADD. Это ответственность того, кто
// запускает migrate:rollback. На prod — отдельный cleanup SQL до отката:
// DELETE FROM project_supplier_links WHERE platform='DIRECT';
// DELETE FROM supplier_projects WHERE platform='DIRECT';
// DELETE FROM supplier_leads WHERE platform='DIRECT';
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3'))");
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3'))");
DB::statement('ALTER TABLE supplier_leads DROP CONSTRAINT chk_supplier_leads_platform');
DB::statement("ALTER TABLE supplier_leads ADD CONSTRAINT chk_supplier_leads_platform CHECK (platform IN ('B1','B2','B3'))");
// Сужение TYPE обратно к VARCHAR(4) — только если все значения помещаются (B1/B2/B3 = 2 символа).
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN platform TYPE VARCHAR(4)');
DB::statement('ALTER TABLE project_supplier_links ALTER COLUMN platform TYPE VARCHAR(4)');
DB::statement('ALTER TABLE supplier_projects ALTER COLUMN platform TYPE VARCHAR(4)');
}
};
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Phase 3 DIRECT supplier row (used by LedgerService::resolveSupplierId
* fallback for platform='DIRECT'). cost_rub matches B1 (same supplier,
* different routing).
*
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
*/
return new class extends Migration
{
public function up(): void
{
$b1 = DB::table('suppliers')->where('code', 'b1')->first();
if ($b1 === null) {
// Если B1 нет — significant prod drift, не должно произойти.
// Создаём с дефолтным cost_rub=1.00 (как на prod 25.05.2026).
$costRub = '1.00';
} else {
$costRub = (string) $b1->cost_rub;
}
// Используем raw SQL чтобы корректно сериализовать PG-array для accepts_types.
DB::insert(
"INSERT INTO suppliers (code, name, accepts_types, cost_rub, channel, is_active, sort_order, created_at)
VALUES (?, ?, ARRAY['websites','calls','sms'], ?, ?, true, 4, NOW())
ON CONFLICT (code) DO NOTHING",
[
'direct',
'DIRECT — Прямые проекты',
$costRub,
'sites', // принимает любые сигналы; channel='sites' допустим в suppliers_channel_check
]
);
}
public function down(): void
{
DB::table('suppliers')->where('code', 'direct')->delete();
}
};
@@ -1,36 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->timestampTz('paused_at')->nullable()->after('is_active');
$table->index('paused_at', 'projects_paused_at_idx');
});
// Backfill: для уже paused проектов используем updated_at как best-effort
// (для долго-paused — grace давно истёк; для свежих — близко к реальной паузе).
DB::statement(<<<'SQL'
UPDATE projects
SET paused_at = updated_at
WHERE is_active = false
AND paused_at IS NULL
SQL);
}
public function down(): void
{
Schema::table('projects', function (Blueprint $table): void {
$table->dropIndex('projects_paused_at_idx');
$table->dropColumn('paused_at');
});
}
};
@@ -1,95 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
// SET ROLE crm_migrator для прода (postgres superuser может SET ROLE).
// На dev/testing crm_migrator не имеет GRANT на public schema → пропускаем.
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
// На окружениях без роли — продолжаем как postgres superuser.
}
DB::unprepared(<<<'SQL'
CREATE TABLE project_routing_snapshots (
snapshot_date DATE NOT NULL,
project_id BIGINT NOT NULL,
tenant_id BIGINT NOT NULL,
daily_limit INT NOT NULL CHECK (daily_limit >= 0),
delivery_days_mask INT NOT NULL CHECK (delivery_days_mask BETWEEN 0 AND 127),
regions INT[] NOT NULL DEFAULT '{}',
signal_type TEXT NOT NULL CHECK (signal_type IN ('call','site','sms')),
signal_identifier TEXT,
sms_senders JSONB,
sms_keyword TEXT,
expected_volume INT NOT NULL CHECK (expected_volume >= 0),
delivered_count INT NOT NULL DEFAULT 0 CHECK (delivered_count >= 0),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
PRIMARY KEY (snapshot_date, project_id),
FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE
-- NB: НЕ ссылаемся на projects(id) проект может быть удалён,
-- а snapshot должен пережить (хвост слепка ещё летит).
) PARTITION BY RANGE (snapshot_date);
CREATE INDEX project_routing_snapshots_tenant_date_idx
ON project_routing_snapshots (tenant_id, snapshot_date);
CREATE INDEX project_routing_snapshots_signal_idx
ON project_routing_snapshots (snapshot_date, signal_type, lower(signal_identifier));
ALTER TABLE project_routing_snapshots ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_routing_snapshots_tenant_isolation
ON project_routing_snapshots
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
GRANT SELECT, INSERT, UPDATE ON project_routing_snapshots TO crm_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON project_routing_snapshots TO crm_supplier_worker;
-- Партиция для текущего месяца (создаётся также через partitions:create-months).
CREATE TABLE project_routing_snapshots_y2026_m05
PARTITION OF project_routing_snapshots
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE project_routing_snapshots_y2026_m06
PARTITION OF project_routing_snapshots
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
SQL);
// Регистрация в retention (system_settings).
$exists = DB::table('system_settings')
->where('key', 'partition_retention_months_project_routing_snapshots')
->exists();
if (! $exists) {
DB::table('system_settings')->insert([
'key' => 'partition_retention_months_project_routing_snapshots',
'value' => '3',
'type' => 'int',
'description' => 'Retention в месяцах для project_routing_snapshots (90 дней)',
'updated_at' => now(),
]);
}
}
public function down(): void
{
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
// На окружениях без роли — продолжаем как postgres superuser.
}
DB::statement('DROP TABLE IF EXISTS project_routing_snapshots CASCADE');
DB::table('system_settings')->where('key', 'partition_retention_months_project_routing_snapshots')->delete();
}
};
@@ -1,91 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Adds per-partition advisory lock to audit_chain_hash() trigger function.
*
* Root cause: concurrent INSERT workers (e.g. supplier-webhook handlers) all
* read the same prev_hash before any of them commits multiple rows derive
* their hash from the same predecessor hash chain branches validator finds
* mismatches (Finding 1 from Stage-5 Day-1 monitoring).
*
* Fix: derive a bigint lock key from the physical partition OID (TG_RELID).
* pg_advisory_xact_lock() serialises concurrent INSERTs into the SAME partition
* without blocking INSERTs to other partitions (distinct OIDs distinct keys).
* The lock is automatically released at transaction end.
*
* Hash formula: unchanged (verbatim from db/schema.sql:3107-3127):
* digest(COALESCE(prev_hash, ''::bytea) || NEW::text::bytea, 'sha256')
*
* Ref: docs/superpowers/plans/2026-05-29-audit-chain-race-fix.md Task 2
*/
return new class extends Migration
{
public function up(): void
{
DB::statement(<<<'SQL'
CREATE OR REPLACE FUNCTION public.audit_chain_hash() RETURNS trigger AS $$
DECLARE
prev_hash BYTEA;
lock_key BIGINT;
BEGIN
-- Derive a partition-specific advisory lock key from the physical
-- table OID (TG_RELID). Each child partition has a distinct OID,
-- so concurrent INSERTs to DIFFERENT partitions do not block each
-- other, while concurrent INSERTs to the SAME partition are
-- serialised preventing the race that branches the hash chain.
lock_key := ('x' || lpad(to_hex(TG_RELID::int), 16, '0'))::bit(64)::bigint;
PERFORM pg_advisory_xact_lock(lock_key);
-- Берём log_hash последней строки этой таблицы. NULL для первой записи.
-- TG_TABLE_NAME имя таблицы, через которое триггер сработал; используем
-- format/EXECUTE для полиморфности.
EXECUTE format(
'SELECT log_hash FROM %I ORDER BY id DESC LIMIT 1',
TG_TABLE_NAME
) INTO prev_hash;
-- log_hash = sha256(prev_hash || NEW::text). Если prev_hash NULL берём
-- пустую байтовую строку (первая запись цепочки).
NEW.log_hash := digest(
COALESCE(prev_hash, ''::bytea) || NEW::text::bytea,
'sha256'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
SQL);
}
public function down(): void
{
// Restore verbatim original from db/schema.sql:3107-3127 (without advisory lock).
DB::statement(<<<'SQL'
CREATE OR REPLACE FUNCTION public.audit_chain_hash() RETURNS trigger AS $$
DECLARE
prev_hash BYTEA;
BEGIN
-- Берём log_hash последней строки этой таблицы. NULL для первой записи.
-- TG_TABLE_NAME имя таблицы, через которое триггер сработал; используем
-- format/EXECUTE для полиморфности.
EXECUTE format(
'SELECT log_hash FROM %I ORDER BY id DESC LIMIT 1',
TG_TABLE_NAME
) INTO prev_hash;
-- log_hash = sha256(prev_hash || NEW::text). Если prev_hash NULL берём
-- пустую байтовую строку (первая запись цепочки).
NEW.log_hash := digest(
COALESCE(prev_hash, ''::bytea) || NEW::text::bytea,
'sha256'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
SQL);
}
};
@@ -1,169 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
// SET ROLE crm_migrator на проде (postgres superuser может SET ROLE).
// На dev/testing crm_migrator не имеет GRANT на public schema → RESET ROLE
// и продолжаем как postgres superuser.
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
// окружение без роли — продолжаем как superuser
}
DB::unprepared(<<<'SQL'
-- 1. phone_ranges_imports (журнал импортов; на него FK из phone_ranges, создаём первым)
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. Обновляется ежемесячным cron-импортом.';
GRANT SELECT ON phone_ranges, phone_ranges_imports TO crm_app_user, crm_supplier_worker;
-- 3. lead_region_resolution_log (SaaS-level, партиционирован по received_at, паттерн activity_log)
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, оператор, шаг каскада). Партиции помесячно по received_at (MonthlyPartitionManager).';
GRANT SELECT, INSERT ON lead_region_resolution_log TO crm_supplier_worker;
GRANT SELECT ON lead_region_resolution_log TO crm_app_user;
-- Стартовые партиции (далее их подхватывает partitions:create-months после Task 1.2).
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 колонки (denormalized display + persistent idempotency для retry).
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 колонки (UI-карточка + флаг подмены региона).
ALTER TABLE deals
ADD COLUMN phone_operator TEXT,
ADD COLUMN region_substituted BOOLEAN NOT NULL DEFAULT FALSE;
SQL);
// Регистрация retention для lead_region_resolution_log (system_settings, 12 месяцев ≈ 365 дней).
$exists = DB::table('system_settings')
->where('key', 'partition_retention_months_lead_region_resolution_log')
->exists();
if (! $exists) {
DB::table('system_settings')->insert([
'key' => 'partition_retention_months_lead_region_resolution_log',
'value' => '12',
'type' => 'int',
'description' => 'Retention в месяцах для lead_region_resolution_log (~365 дней)',
'updated_at' => now(),
]);
}
}
public function down(): void
{
try {
DB::statement('SET ROLE crm_migrator');
$canCreate = DB::selectOne("SELECT has_schema_privilege('crm_migrator', 'public', 'CREATE') AS ok");
if (!$canCreate || !$canCreate->ok) {
DB::statement('RESET ROLE');
}
} catch (\Throwable) {
// окружение без роли — продолжаем как superuser
}
DB::unprepared(<<<'SQL'
ALTER TABLE deals
DROP COLUMN IF EXISTS phone_operator,
DROP COLUMN IF EXISTS region_substituted;
ALTER TABLE supplier_leads
DROP COLUMN IF EXISTS resolved_subject_code,
DROP COLUMN IF EXISTS region_source,
DROP COLUMN IF EXISTS dadata_qc,
DROP COLUMN IF EXISTS phone_operator;
DROP TABLE IF EXISTS lead_region_resolution_log CASCADE;
DROP TABLE IF EXISTS phone_ranges CASCADE;
DROP TABLE IF EXISTS phone_ranges_imports CASCADE;
SQL);
DB::table('system_settings')
->where('key', 'partition_retention_months_lead_region_resolution_log')
->delete();
}
};
+1 -4
View File
@@ -41,9 +41,6 @@ deptrac:
Request: [Rule, Model]
Resource: [Model]
Rule: [Model]
# Mail может зависеть от Service value objects (PreflightResult и аналоги) —
# это legit dependency: template needs data DTO от Service для рендера.
# Decision: ADR-005 amend 2026-05-29 (incident-followup cleanup).
Mail: [Model, Service]
Mail: [Model]
Model: []
Provider: [Controller, Service, Job, Console, Repository, Model, Mail, Middleware, Request, Resource, Rule, Exception]
+2 -362
View File
@@ -51,7 +51,7 @@ parameters:
-
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 6
count: 5
path: app/Http/Controllers/Api/DealController.php
-
@@ -84,24 +84,6 @@ parameters:
count: 1
path: app/Http/Middleware/SetTenantContext.php
-
message: '#^Access to an undefined property App\\Http\\Resources\\ProjectResource\:\:\$applies_from\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/ProjectResource.php
-
message: '#^Parameter \#1 \$array \(non\-empty\-list\<int\>\) of array_values is already a list, call has no effect\.$#'
identifier: arrayValues.list
count: 1
path: app/Jobs/Supplier/SyncSupplierProjectsJob.php
-
message: '#^Parameter \#1 \$column of method Illuminate\\Database\\Eloquent\\Builder\<App\\Models\\Project\>\:\:where\(\) expects array\<int\|model property of App\\Models\\Project, mixed\>\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\<App\\Models\\Project\>\)\: Illuminate\\Database\\Eloquent\\Builder\<App\\Models\\Project\>\)\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\<App\\Models\\Project\>\)\: void\)\|Illuminate\\Contracts\\Database\\Query\\Expression\|model property of App\\Models\\Project, ''snap\.snapshot_date'' given\.$#'
identifier: argument.type
count: 1
path: app/Jobs/Supplier/SyncSupplierProjectsJob.php
-
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
@@ -120,12 +102,6 @@ parameters:
count: 1
path: app/Services/NotificationService.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$applies_from\.$#'
identifier: property.notFound
count: 1
path: app/Services/Project/ProjectService.php
-
message: '#^Match expression does not handle remaining value\: string$#'
identifier: match.unhandled
@@ -144,90 +120,6 @@ parameters:
count: 1
path: app/Services/Supplier/Channel/AjaxProjectChannel.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenDefineFunctions not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenFinalClasses not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenNormalClasses not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenPrivateMethods not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\ForbiddenTraits not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Insights\\SyntaxCheck not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class NunoMaduro\\PhpInsights\\Domain\\Metrics\\Architecture\\Classes not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\Commenting\\UselessFunctionDocCommentSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\Namespaces\\AlphabeticallySortedUsesSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DeclareStrictTypesSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\DisallowMixedTypeHintSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ParameterTypeHintSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\PropertyTypeHintSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Class SlevomatCodingStandard\\Sniffs\\TypeHints\\ReturnTypeHintSniff not found\.$#'
identifier: class.notFound
count: 1
path: config/insights.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
identifier: method.childReturnType
@@ -264,12 +156,6 @@ parameters:
count: 1
path: database/factories/UserFactory.php
-
message: '#^Offset ''SnapshotProjectRout…'' on null in isset\(\) does not exist\.$#'
identifier: isset.offset
count: 1
path: routes/console.php
-
message: '#^Offset ''projects\:reset…'' on null in isset\(\) does not exist\.$#'
identifier: isset.offset
@@ -558,18 +444,6 @@ parameters:
count: 3
path: tests/Feature/ApiKeyControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Audit/AuditChainRaceConditionTest.php
-
message: '#^Using nullsafe property access "\?\-\>cnt" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: tests/Feature/Audit/AuditRebuildChainTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
@@ -846,36 +720,6 @@ parameters:
count: 6
path: tests/Feature/Auth/UpdateProfileTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 7
path: tests/Feature/Billing/BalanceStatusTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Billing/BalanceStatusTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/BalanceStatusTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 6
path: tests/Feature/Billing/BalanceStatusTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/BalanceStatusTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -906,16 +750,10 @@ parameters:
count: 1
path: tests/Feature/Billing/BillingOverviewControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/BillingPreflightInitialSweepTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#'
identifier: property.notFound
count: 9
count: 8
path: tests/Feature/Billing/LedgerServiceTest.php
-
@@ -930,12 +768,6 @@ parameters:
count: 6
path: tests/Feature/Billing/PricingTierRepositoryTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/Billing/ProjectPreflightTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -1044,30 +876,6 @@ parameters:
count: 1
path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Console/SnapshotBackfillCommandTest.php
-
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
identifier: arguments.count
count: 2
path: tests/Feature/Console/SnapshotBackfillCommandTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Console/SnapshotRebuildCommandTest.php
-
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
identifier: arguments.count
count: 2
path: tests/Feature/Console/SnapshotRebuildCommandTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
@@ -1488,12 +1296,6 @@ parameters:
count: 5
path: tests/Feature/EndpointAuthHardeningTest.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$applies_from\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Http/Resources/ProjectResourceAppliesFromTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
@@ -1506,18 +1308,6 @@ parameters:
count: 2
path: tests/Feature/Http/Webhook/SupplierWebhookTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:call\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Http/Webhook/SupplierWebhookValidationFormatTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
identifier: property.notFound
@@ -1632,12 +1422,6 @@ parameters:
count: 8
path: tests/Feature/Incidents/IncidentsWatchFailuresExpandedTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/Feature/Incidents/SingleLeadStormTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
@@ -1650,48 +1434,12 @@ parameters:
count: 1
path: tests/Feature/Integration/SupplierLeadFlowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php
-
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
identifier: arguments.count
count: 2
path: tests/Feature/Jobs/RouteSupplierLeadJobSnapshotTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Jobs/RouteSupplierLeadJobTest.php
-
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
identifier: arguments.count
count: 1
path: tests/Feature/Jobs/SnapshotProjectRoutingJobTest.php
-
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
identifier: arguments.count
count: 4
path: tests/Feature/Jobs/Supplier/SyncSupplierProjectsJobSnapshotTest.php
-
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
identifier: arguments.count
count: 1
path: tests/Feature/LeadRouter/FrozenFilterTest.php
-
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
identifier: arguments.count
count: 4
path: tests/Feature/LeadRouter/SnapshotRoutingTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -2268,24 +2016,6 @@ parameters:
count: 3
path: tests/Feature/Security/WebhookUrlChangeAuditTest.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$applies_from\.$#'
identifier: property.notFound
count: 5
path: tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php
-
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
identifier: arguments.count
count: 4
path: tests/Feature/Services/Project/ProjectServiceAppliesFromTest.php
-
message: '#^Static method Carbon\\Carbon\:\:setTestNow\(\) invoked with 2 parameters, 0\-1 required\.$#'
identifier: arguments.count
count: 5
path: tests/Feature/Services/Project/SupplierSnapshotGuardAppliesFromTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
@@ -2334,48 +2064,12 @@ parameters:
count: 1
path: tests/Feature/Supplier/CsvReconcileJobTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Supplier/CsvWebhookRaceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$sp\.$#'
identifier: property.notFound
count: 7
path: tests/Feature/Supplier/CsvWebhookRaceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 8
path: tests/Feature/Supplier/CsvWebhookRaceTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/CsvWebhookRaceTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Supplier/DirectPlatformTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/DirectPlatformTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
identifier: method.notFound
@@ -2454,30 +2148,12 @@ parameters:
count: 1
path: tests/Feature/Supplier/SupplierProjectImporterTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Supplier/SupplierRekeyOrphansCommandTest.php
-
message: '#^Call to an undefined method App\\Services\\Supplier\\PlaywrightBridge\:\:shouldReceive\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$sharedProject\.$#'
identifier: property.notFound
count: 7
path: tests/Feature/Supplier/SupplierWebhookFastFailTest.php
-
message: '#^Using nullsafe property access "\?\-\>error" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 2
path: tests/Feature/Supplier/SupplierWebhookFastFailTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
identifier: method.notFound
@@ -2598,42 +2274,6 @@ parameters:
count: 6
path: tests/Unit/Services/Pd/ImpersonationAuditServiceTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:once\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
identifier: method.alreadyNarrowedType
count: 3
path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php
-
message: '#^Parameter \#2 \$snapshotGuard of class App\\Services\\Project\\ProjectService constructor expects App\\Services\\Project\\SupplierSnapshotGuard, Mockery\\MockInterface given\.$#'
identifier: argument.type
count: 3
path: tests/Unit/Services/Project/ProjectServiceGuardWiringTest.php
-
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:with\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Unit/Services/Project/SupplierSnapshotGuardTest.php
-
message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#'
identifier: method.alreadyNarrowedType
count: 1
path: tests/Unit/Services/Project/SupplierSnapshotGuardTest.php
-
message: '#^Property App\\Models\\IdeHelperProject\:\:\$paused_at \(Illuminate\\Support\\Carbon\|null\) does not accept Carbon\\CarbonImmutable\.$#'
identifier: assign.propertyType
count: 2
path: tests/Unit/Services/Project/SupplierSnapshotGuardTest.php
-
message: '#^Call to an undefined method App\\Services\\Supplier\\ProcessFactory\:\:shouldReceive\(\)\.$#'
identifier: method.notFound
@@ -103,22 +103,7 @@ async function confirmAndRun(action: 'pause' | 'resume' | 'delete') {
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
const result = await store.bulkUpdate(payload);
if (result.skipped.length > 0) {
const supplierLocked = result.skipped.filter((s) => s.reason === 'supplier_snapshot_locked').length;
const withDeals = result.skipped.filter((s) => s.reason === 'has_deals').length;
const groups: string[] = [];
if (supplierLocked > 0) {
groups.push(
`${supplierLocked} — мы уже начали сбор лидов на завтра (поставьте проект на паузу, удалить можно будет послезавтра)`,
);
}
if (withDeals > 0) {
groups.push(`${withDeals} — по проекту есть сделки`);
}
// Fallback на старый текст, если reason неизвестный (защита от регрессии при добавлении новых причин).
if (groups.length === 0) {
groups.push(`${result.skipped.length} (конфликт с уже доставленными лидами)`);
}
skipToastText.value = `Применено: ${result.updated}. Пропущено: ${groups.join('; ')}.`;
skipToastText.value = `Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`;
skipToastOpen.value = true;
}
}
@@ -7,7 +7,7 @@ import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
const props = defineProps<{ project: Project | null }>();
const emit = defineEmits<{ close: []; saved: [appliesFrom: string | null] }>();
const emit = defineEmits<{ close: []; saved: [] }>();
interface FormState {
name: string;
@@ -65,20 +65,11 @@ async function onPause(): Promise<void> {
async function onDelete(): Promise<void> {
if (!props.project) return;
const ok = window.confirm(
'Удалить проект? Действие необратимо. Если по проекту есть сделки или поставщик уже заказал лиды — удаление будет заблокировано.',
'Удалить проект? Действие необратимо. Если по проекту есть сделки — удаление будет заблокировано.',
);
if (!ok) return;
Object.keys(errors).forEach((k) => delete errors[k]);
try {
await store.del(props.project.id);
emit('close');
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
}
// НЕ закрываем drawer клиент видит ошибку и может поставить проект на паузу.
}
await store.del(props.project.id);
emit('close');
}
async function onSave(): Promise<void> {
@@ -100,11 +91,8 @@ async function onSave(): Promise<void> {
payload.sms_senders = form.sms_senders;
payload.sms_keyword = form.sms_keyword;
}
const { data } = await axios.patch(`/api/projects/${props.project.id}`, payload);
// Backend кладёт applies_from когда правка задела slepok-чувствительные поля
// (см. ProjectService::updateAndExposeAppliesFrom / Task 2.8).
const appliesFrom: string | null = data?.data?.applies_from ?? null;
emit('saved', appliesFrom);
await axios.patch(`/api/projects/${props.project.id}`, payload);
emit('saved');
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
if (err.response?.status === 422 && err.response.data?.errors) {
@@ -142,11 +130,6 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
</header>
<div class="pdd-body">
<!-- Общая ошибка уровня проекта (например, supplier-snapshot guard или has-deals на delete). -->
<div v-if="errors.project" class="pdd-error pdd-error-banner" data-testid="pdd-error-project">
{{ errors.project[0] }}
</div>
<label class="pdd-field">
<span class="pdd-label">Название</span>
<input v-model="form.name" data-testid="pdd-name" class="pdd-input" />
@@ -1,23 +0,0 @@
/**
* Форматирует сообщение для тоста после сохранения slepok-чувствительных
* правок проекта.
*
* Бизнес-инвариант: applies_from = N.21:00 МСК час, когда поставщик
* фиксирует свой slepok (см. docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md).
* Поэтому в UI всегда показываем «21:00 МСК», а календарную дату берём
* по часовому поясу Москвы независимо от локали браузера клиента.
*/
const moscowDateFmt = new Intl.DateTimeFormat('ru-RU', {
timeZone: 'Europe/Moscow',
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
export function formatAppliesFromMessage(appliesFrom: string | null | undefined): string {
if (!appliesFrom) {
return 'Сохранено.';
}
const date = moscowDateFmt.format(new Date(appliesFrom));
return `Сохранено. Изменения вступят в силу ${date} в 21:00 МСК.`;
}
+3 -30
View File
@@ -173,17 +173,8 @@
@close="onDrawerClose"
@saved="onDrawerSaved"
/>
<NewProjectDialog v-model="createOpen" mode="create" @saved="onProjectSaved" />
<EditProjectDialog v-model="editOpen" :project="editing" @saved="onProjectSaved" />
<v-snackbar
v-model="savedSnackbarOpen"
color="success"
:timeout="appliesFromShown ? 7000 : 3500"
data-testid="projects-saved-snackbar"
>
{{ savedSnackbarMessage }}
</v-snackbar>
<NewProjectDialog v-model="createOpen" mode="create" @saved="store.fetch()" />
<EditProjectDialog v-model="editOpen" :project="editing" @saved="store.fetch()" />
</v-container>
</template>
@@ -196,25 +187,12 @@ import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
import NewProjectDialog from './projects/NewProjectDialog.vue';
import EditProjectDialog from './projects/EditProjectDialog.vue';
import { REGIONS } from '../constants/regions';
import { formatAppliesFromMessage } from '../composables/appliesFromMessage';
const store = useProjectsStore();
const createOpen = ref(false);
const editOpen = ref(false);
const editing = ref<Project | null>(null);
// Тост «Сохранено» после правки проекта. Если правка задела slepok-чувствительные
// поля (regions / delivery_days_mask / daily_limit_target / источник), backend
// возвращает applies_from = N.21:00 МСК показываем расширенное сообщение.
const savedSnackbarOpen = ref(false);
const savedSnackbarMessage = ref('');
const appliesFromShown = ref(false);
function showSavedSnackbar(appliesFrom: string | null): void {
savedSnackbarMessage.value = formatAppliesFromMessage(appliesFrom);
appliesFromShown.value = appliesFrom !== null;
savedSnackbarOpen.value = true;
}
// Информационный баннер о сроке внесения изменений (синхронизация с поставщиком в 18:00 МСК).
// Закрытие запоминается, чтобы не показывать повторно.
const CUTOFF_BANNER_KEY = 'projects.cutoffBannerDismissed';
@@ -233,15 +211,10 @@ const singleSelectedProject = computed<Project | null>(() => {
function onDrawerClose(): void {
store.clearSelection();
}
function onDrawerSaved(appliesFrom: string | null): void {
function onDrawerSaved(): void {
// #4: после Save/Pause/Delete панель и галочка должны исчезнуть.
store.clearSelection();
void store.fetch();
showSavedSnackbar(appliesFrom);
}
function onProjectSaved(appliesFrom: string | null): void {
void store.fetch();
showSavedSnackbar(appliesFrom);
}
const typeFilters = [
@@ -4,7 +4,7 @@
mode="edit"
:project="project"
@update:model-value="$emit('update:modelValue', $event)"
@saved="(appliesFrom) => $emit('saved', appliesFrom)"
@saved="$emit('saved')"
/>
</template>
@@ -13,8 +13,5 @@ import NewProjectDialog from './NewProjectDialog.vue';
import type { Project } from '../../stores/projectsStore';
defineProps<{ modelValue: boolean; project: Project | null }>();
defineEmits<{
'update:modelValue': [value: boolean];
saved: [appliesFrom: string | null];
}>();
defineEmits(['update:modelValue', 'saved']);
</script>
@@ -215,10 +215,7 @@ const props = defineProps<{
mode?: 'create' | 'edit';
project?: Project | null;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
saved: [appliesFrom: string | null];
}>();
const emit = defineEmits(['update:modelValue', 'saved']);
// Plan 6: regions = subject codes (1..89) backend dual-writes region_mask/region_mode.
// Пустой массив = вся РФ.
@@ -329,17 +326,13 @@ async function persist(extra: Record<string, unknown> = {}): Promise<void> {
try {
await ensureCsrfCookie();
const body = { ...form, ...extra };
let appliesFrom: string | null = null;
if (props.mode === 'edit' && props.project) {
const { data } = await apiClient.patch(`/api/projects/${props.project.id}`, body);
// Backend кладёт applies_from только когда правка задела slepok-чувствительные поля.
appliesFrom = data?.data?.applies_from ?? null;
await apiClient.patch(`/api/projects/${props.project.id}`, body);
} else {
await apiClient.post('/api/projects', body);
// Create НЕ генерирует applies_from (новый проект сразу попадает в snapshot).
}
overloadOpen.value = false;
emit('saved', appliesFrom);
emit('saved');
close();
} catch (e: unknown) {
const err = e as {
@@ -1,15 +0,0 @@
<!DOCTYPE html>
<html lang="ru">
<head><meta charset="UTF-8"><title>Tenant business drift alert</title></head>
<body style="font-family: Arial, sans-serif;">
<h3>Business-shortfall тенанта Лидерры</h3>
<p>Тенант <strong>#{{ $tenantId }}</strong>, дата слепка: <strong>{{ $snapshotDate }}</strong></p>
<ul>
<li>Ожидалось по слепку: <strong>{{ $expected }}</strong> лидов</li>
<li>Доставлено фактически: <strong>{{ $delivered }}</strong> лидов</li>
<li>Shortfall ratio: <strong>{{ number_format($shortfallRatio * 100, 1, ',', ' ') }}%</strong> (порог 20%)</li>
</ul>
<p>Окно сверки: <strong>{{ $windowStart->format('Y-m-d H:i') }} {{ $windowEnd->format('Y-m-d H:i') }}</strong></p>
<p>Проверь причину поставщик не закрывает заказ, расхождение масок workdays или regions, либо проект потерял eligibility внутри slepok'а.</p>
</body>
</html>
-15
View File
@@ -3,7 +3,6 @@
use App\Jobs\Supplier\CleanupInactiveSupplierProjectsJob;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Jobs\SnapshotProjectRoutingJob;
use App\Jobs\Supplier\SyncSupplierProjectsJob;
use App\Services\SchedulerHeartbeatTracker;
use Illuminate\Foundation\Inspiring;
@@ -103,20 +102,6 @@ Schedule::job(new RefreshSupplierSessionJob)
->timezone('Europe/Moscow')
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', true, null, null))
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\RefreshSupplierSessionJob@daily', false, 'Job failed', null));
// Spec 2026-05-26-slepok-routing-protection §4.2.2:
// SnapshotProjectRoutingJob создаёт slepok №NЛ для дня N+1 в 18:02 МСК.
// Запускается ПОСЛЕ billing:preflight-sweep (18:00) и ДО SyncSupplierProjectsJob (18:05).
Schedule::job(new SnapshotProjectRoutingJob)
->dailyAt('18:02')
->timezone('Europe/Moscow')
->before(fn () => $startTimes['SnapshotProjectRoutingJob'] = microtime(true))
->onSuccess(function () use ($hb, &$startTimes): void {
$name = 'SnapshotProjectRoutingJob';
$ms = isset($startTimes[$name]) ? (int) ((microtime(true) - $startTimes[$name]) * 1000) : null;
$hb->recordRunResult($name, true, null, $ms);
})
->onFailure(fn () => $hb->recordRunResult('SnapshotProjectRoutingJob', false, 'Job failed', null));
// Billing v2 Spec C: сдвинут 18:00 → 18:05, чтобы billing:preflight-sweep (18:00)
// успел проставить frozen-флаги до формирования заказа поставщику.
Schedule::job(new SyncSupplierProjectsJob)

Some files were not shown because too many files have changed in this diff Show More