Compare commits
124 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fea1443b18 | |||
| da591b9c00 | |||
| e1601e7862 | |||
| fe4a409480 | |||
| fd877ab156 | |||
| 787df436a3 | |||
| df12ac6757 | |||
| d2ff39abc2 | |||
| 53f9020653 | |||
| b1c3f39e38 | |||
| 7332387c19 | |||
| e83ddaaf0f | |||
| 64b0a3d944 | |||
| 0789367742 | |||
| 9b7de8bf8c | |||
| c9614130e2 | |||
| f44de52e08 | |||
| 7b4da1477e | |||
| 9ca75a788a | |||
| 91c4ccc674 | |||
| 8f9ffc387d | |||
| 5215842304 | |||
| 165f1ed993 | |||
| 0902de96c7 | |||
| 5b7d958ecb | |||
| 06dc4a2a91 | |||
| fdfaa956bd | |||
| 675b7f2237 | |||
| 753c3901b2 | |||
| 38ecbc682f | |||
| 7e79bf714a | |||
| 69aeac3756 | |||
| 84272c5ccd | |||
| 7a56442149 | |||
| 0b07debb7a | |||
| 57a7f55bf1 | |||
| 0ea3b5d70d | |||
| e630976ae1 | |||
| d51ba5f57d | |||
| e2e300f4f6 | |||
| 08e2a969e8 | |||
| 5682926626 | |||
| a846eed9dc | |||
| 7e5c297394 | |||
| ce02d1adad | |||
| 8b6b410119 | |||
| 51966328c5 | |||
| ea9430d8a7 | |||
| 659f2b0757 | |||
| f48f79d2f3 | |||
| 0da72778c3 | |||
| d568bf84eb | |||
| 752d80af7c | |||
| 5265b82ad1 | |||
| 3318498587 | |||
| cbfd9738de | |||
| 4d6f92c649 | |||
| c7079ac8e4 | |||
| bfa228197d | |||
| cc444e7f53 | |||
| 982cd00678 | |||
| 97982f85fe | |||
| 3d5fb86e7c | |||
| 6cb8be6919 | |||
| 59c3ef4112 | |||
| fe338e09f9 | |||
| c9f2be37fe | |||
| d7fe7ba458 | |||
| bb41315df4 | |||
| b6a0938ccd | |||
| a3e7573387 | |||
| 9188e1cefd | |||
| 76cb825331 | |||
| 6f70cca90e | |||
| 48eaffece8 | |||
| 919971d085 | |||
| 6bf0ebfd1d | |||
| 5cad78b73d | |||
| 3bb2bf92e2 | |||
| 82b95f4bcb | |||
| 9a56d92440 | |||
| 0e5f47c5e9 | |||
| cbfb504a54 | |||
| 8d037e1f04 | |||
| e8782c47b3 | |||
| 3dfb96ba47 | |||
| b92d9b3bfc | |||
| 58784b182d | |||
| 4010495d19 | |||
| 2bf25db72e | |||
| da4ab729df | |||
| 4f362a9e62 | |||
| 633435e990 | |||
| 050b349af5 | |||
| 25ac64f9b0 | |||
| dcd7163738 | |||
| 30334aaa8c | |||
| 6cff2c3854 | |||
| 318e3ca75d | |||
| 763469c072 | |||
| b437597286 | |||
| cf97898833 | |||
| 12f88f32c1 | |||
| 8355f7a045 | |||
| df5f0118e9 | |||
| 9480c44092 | |||
| 831ea553fa | |||
| 530f2cb6d2 | |||
| fb0309d357 | |||
| 55123bfe9f | |||
| d512b8e6be | |||
| 3c3bdc2d3d | |||
| 808461295a | |||
| 41deac7bc8 | |||
| 2fe4e1c4bc | |||
| 975570e555 | |||
| 2b052ab1a7 | |||
| c6f9dc2d76 | |||
| b917360e9b | |||
| e5f20adcad | |||
| 32f9133e87 | |||
| f6b52df613 | |||
| 26999ca597 | |||
| 4357a0e732 |
+95
-2
@@ -65,6 +65,36 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-memory-coverage.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-tdd-gate.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-branch-switch.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-verify-before-push.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
@@ -85,6 +115,31 @@
|
||||
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-verify-record.mjs",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Edit|Write|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-rationalization-audit.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
@@ -93,7 +148,7 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/observer-stop-hook.mjs",
|
||||
"timeout": 5
|
||||
"timeout": 60
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -105,6 +160,24 @@
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-coverage-verify.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/enforce-classifier-match.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
@@ -113,7 +186,27 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/router-prehook.mjs",
|
||||
"timeout": 10
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: brain-retro
|
||||
description: Use ONCE PER SPRINT (or by explicit user invocation "брейн-ретро") to aggregate evidence from docs/observer/episodes-*.jsonl + notes/*.md and propose regulatory candidates. Read-only — never edits Tooling/Pravila/PSR_v1 automatically; only proposes.
|
||||
description: Use каждые 1-2 недели OR при триггере sanity-check threshold (Phase 3 cadence, spec §4.7). Also fires on explicit «брейн-ретро» / «/brain-retro». Aggregates evidence from docs/observer/episodes-*.jsonl + notes/*.md, asks 3-4 sanity questions via AskUserQuestion (PII-filtered), spawns reviewer-agent subagent per unreviewed episode (Opus, fallback to tools/brain-retro-opus-reviewer.mjs on subagent crash), and proposes regulatory candidates. Read-only — never edits Tooling/Pravila/PSR_v1 automatically; only proposes.
|
||||
---
|
||||
|
||||
# Brain Retro
|
||||
@@ -21,16 +21,39 @@ Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces can
|
||||
|
||||
## Procedure
|
||||
|
||||
> **MANDATORY DIGITAL ANALYSIS (added 2026-05-26 after retro #6 feedback).**
|
||||
> Каждый прогон /brain-retro ОБЯЗАН включать **количественные срезы**, не только causal narrative. Минимум 7 цифровых таблиц:
|
||||
> 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).
|
||||
>
|
||||
> Без этих 7 таблиц retro считается недоделанным. Narrative-выводы должны опираться на цифры из них, не на «общие ощущения». **Если classifier_output=NULL > 30% эпизодов** — это сигнал, что классификатор сломан; в retro отдельным блоком отчитаться о состоянии классификатора (timeouts/errors/source distribution).
|
||||
>
|
||||
> Запрет на жаргон для блока «Report to user»: цифры остаются техническими, словесные выводы пользователю — простым языком (см. memory `feedback_plain_language.md`).
|
||||
|
||||
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).
|
||||
6. **Aggregate** per `references/aggregation-template.md` — fill the Factor analysis matrix from the analyzer's `factorMatrix`, the task groups from `tasks`, the causal-chain candidates from `causalChains`.
|
||||
5a. **[Phase 3] Sanity questions (spec §4.7)** — `node tools/brain-retro-sanity-generator.mjs` (called as a module from analyzer-driven flow, OR direct via `import { generateCandidateQuestions } from '../../../tools/brain-retro-sanity-generator.mjs'`) returns up to 5 candidate questions. Pick 3-4, ask via AskUserQuestion (multiple-choice + free comment). **Вопросы заказчику — простым языком**, не «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.
|
||||
7. **Propose candidates** — clearly separated section «Candidates for owner review». Each candidate has rationale + suggested edit + rejection-option.
|
||||
8. **Save retro note**: `docs/observer/notes/YYYY-MM-DD-brain-retro.md` with full aggregation.
|
||||
8a. **Refresh STATUS.md**: `node tools/status-md-generator.mjs` — auto-rebuild dashboard so it reflects the just-finished retro (`Last /brain-retro: 0 day(s) ago`, current episode count, refreshed C1–C5 controller statuses). Without this, STATUS.md only updates on the next git commit.
|
||||
9. **Report to user**: high-signal summary.
|
||||
8a. **Refresh STATUS.md**: `node tools/status-md-generator.mjs` — auto-rebuild dashboard so it reflects the just-finished retro (`Last /brain-retro: 0 day(s) ago`, current episode count, refreshed C1–C5 controller statuses, cost report from `~/.claude/runtime/cost-daily.json`). Without this, STATUS.md only updates on the next git commit.
|
||||
9. **[Phase 3] Self-retrospect trigger (spec §4.8)** — read `docs/observer/.self-retrospect-counter.json`. If `episodes_since_last >= 50`, propose to the user invoking `/self-retrospect` (opt-in skill at `.claude/skills/self-retrospect/`). Bump `episodes_since_last` by the period's episode count regardless.
|
||||
10. **Cost report** — read `~/.claude/runtime/cost-daily.json`; include classifier + self_assessment + reviewer cost totals for the period in the retro note.
|
||||
11. **Report to user**: high-signal summary including sanity highlights, reviewer outcome distribution, and any escalations.
|
||||
|
||||
## Output anatomy
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: self-retrospect
|
||||
description: |
|
||||
Opt-in self-retrospect: один раз за период (по умолчанию ~50 эпизодов или
|
||||
«триггер от заказчика») контроллер прогоняется по своим эпизодам и
|
||||
отвечает на вопросы про собственные паттерны: где переоценил уверенность,
|
||||
где зря выбрал direct вместо навыка, где наоборот стоило выбрать direct
|
||||
но навык сработал лишним. Результат пишется как заметка в
|
||||
`docs/observer/notes/<YYYY-MM-DD>-self-retrospect.md`, НЕ как эпизод.
|
||||
|
||||
Triggers: явное «/self-retrospect» от заказчика, OR порог
|
||||
`docs/observer/.self-retrospect-counter.json:episodes_since_last >= 50`
|
||||
(контроллер видит порог в STATUS.md C5 и предлагает запуск).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md §4.8.
|
||||
tools: Read, Grep, Glob, AskUserQuestion, Write, Edit
|
||||
---
|
||||
|
||||
# self-retrospect — Phase 3 Task 19 stub
|
||||
|
||||
This is the **stub** for the opt-in self-retrospect skill (Phase 3 Task 19).
|
||||
The full procedure (read 50 episodes → answer 5-7 introspection questions
|
||||
via AskUserQuestion → write note → bump counter) is **wired in Phase 3 Task
|
||||
20** when the analyzer and STATUS.md generator surface the
|
||||
`episodes_since_last >= 50` threshold.
|
||||
|
||||
For now, when invoked:
|
||||
|
||||
1. Read `docs/observer/.self-retrospect-counter.json`.
|
||||
2. Read the last N episodes from `docs/observer/episodes-YYYY-MM.jsonl`
|
||||
(default N = `episodes_since_last`).
|
||||
3. Ask the user (via AskUserQuestion) 3-5 retrospective questions about
|
||||
own routing patterns over that window (template in `references/` —
|
||||
created in Task 20).
|
||||
4. Sanitize answers via `tools/observer-pii-filter.mjs` (`sanitize` export)
|
||||
before writing.
|
||||
5. Write `docs/observer/notes/YYYY-MM-DD-self-retrospect.md`.
|
||||
6. Reset counter: `episodes_since_last = 0`, `last_run_at = now`.
|
||||
|
||||
Until Task 20 wires steps 3 and the references template, invoking this
|
||||
skill should walk through steps 1-2 + 4-6 manually and ask the user the
|
||||
3-5 questions inline.
|
||||
@@ -2,6 +2,14 @@
|
||||
# .gitignore — Лидерра
|
||||
# =============================================================================
|
||||
|
||||
# ── Session junk (broken PS paths from parallel Claude sessions, deploy tarballs, ad-hoc screenshots) ──
|
||||
CTemp*
|
||||
CWindowsTemp*
|
||||
phase[0-9]*-update.tar.gz
|
||||
recheck-*.png
|
||||
.tmp-*.sql
|
||||
tools/cloudflared.*
|
||||
|
||||
# ── Node / npm ──────────────────────────────────────────────────────────────
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
@@ -39,3 +39,13 @@ 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
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Billing\BalanceFrozenReminderJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Daily: повторные письма заморозки баланса.
|
||||
* • reminder в окне 24-48ч после freeze;
|
||||
* • final в окне 72-96ч после freeze.
|
||||
*
|
||||
* Запускается @18:30 MSK (routes/console.php), после основного preflight-sweep @18:00.
|
||||
* Throttle живёт в BalanceFrozenReminderJob через balance_freeze_log markers.
|
||||
*/
|
||||
final class BillingFrozenReminderCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'billing:frozen-reminder';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Повторные письма заморозки баланса (reminder +1д, final +3д)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
$this->info('Повторные письма заморозки разосланы (если есть кандидаты в окнах).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Billing\BalancePreflightSweepJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* One-time: при выкатке преfflight прогнать всех тенантов и заморозить
|
||||
* недофинансированных. Запускается ОДИН раз вручную после миграции.
|
||||
*
|
||||
* См. спек §3.9: «Клиент уже в минусовом балансе на момент запуска
|
||||
* преfflight (legacy состояние) — одноразовая artisan-команда».
|
||||
*
|
||||
* Идемпотентна: повторный запуск не пере-замораживает уже замороженных
|
||||
* (логика sweep-джоба — переход active→frozen / frozen→active, стабильное
|
||||
* состояние не трогается).
|
||||
*/
|
||||
final class BillingPreflightInitialSweepCommand extends Command
|
||||
{
|
||||
/** @var string */
|
||||
protected $signature = 'billing:preflight-initial-sweep';
|
||||
|
||||
/** @var string */
|
||||
protected $description = 'Разовый преfflight при внедрении — заморозить недофинансированных тенантов';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->warn('Разовый преfflight всех тенантов. Запускать ОДИН раз после выкатки Spec C.');
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
$this->info('Initial sweep завершён.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Billing\BalancePreflightSweepJob;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class BillingPreflightSweepCommand extends Command
|
||||
{
|
||||
protected $signature = 'billing:preflight-sweep';
|
||||
|
||||
protected $description = 'Ежедневный преfflight баланса — заморозка/разморозка тенантов (cut-off 18:00 MSK)';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
$this->info('Преfflight sweep завершён.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,14 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BalanceTransaction;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Repositories\PricingTierRepository;
|
||||
use App\Services\Billing\BalanceToLeadsConverter;
|
||||
use App\Services\Billing\BillingTopupService;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -111,6 +114,101 @@ class BillingController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/billing/balance-status — лёгкий статус баланса для UI префлайта
|
||||
* (Billing v2 Spec C §3.6). Питает глобальный баннер заморозки
|
||||
* (BalanceFrozenBanner: frozen_by_balance_at + дефицит) и индикатор ёмкости
|
||||
* (BalanceCapacityIndicator: balance / capacity / required). Грузится в
|
||||
* AppLayout на всех страницах, поэтому без tiers_preview и истории.
|
||||
*/
|
||||
public function balanceStatus(Request $request): JsonResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
/** @var Tenant $tenant */
|
||||
$tenant = Tenant::query()->findOrFail((int) $user->tenant_id);
|
||||
|
||||
$activeTiers = app(PricingTierRepository::class)->activeAt(Carbon::now('Europe/Moscow'));
|
||||
$deliveredInMonth = (int) ($tenant->delivered_in_month ?? 0);
|
||||
|
||||
$capacityLeads = (int) app(BalanceToLeadsConverter::class)->convert(
|
||||
(string) $tenant->balance_rub,
|
||||
$deliveredInMonth,
|
||||
$activeTiers,
|
||||
)['leads'];
|
||||
|
||||
// Требуемые лиды/день — сумма лимитов активных не-заблокированных проектов
|
||||
// (та же выборка, что в ProjectController preflight).
|
||||
$requiredLeads = (int) Project::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
|
||||
$deficitLeads = max(0, $requiredLeads - $capacityLeads);
|
||||
$deficitRub = '0.00';
|
||||
if ($deficitLeads > 0) {
|
||||
$needed = $this->minBalanceForLeads($requiredLeads, $deliveredInMonth, $activeTiers);
|
||||
$deficitRub = bcsub($needed, (string) $tenant->balance_rub, 2);
|
||||
if (bccomp($deficitRub, '0', 2) < 0) {
|
||||
$deficitRub = '0.00';
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'frozen_by_balance_at' => $tenant->frozen_by_balance_at?->toISOString(),
|
||||
'balance_rub' => (string) $tenant->balance_rub,
|
||||
'capacity_leads' => $capacityLeads,
|
||||
'required_leads_per_day' => $requiredLeads,
|
||||
'deficit_leads' => $deficitLeads,
|
||||
'deficit_rub' => $deficitRub,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Минимальный баланс (₽, scale 2), чтобы позволить себе $leads лидов при уже
|
||||
* доставленных $deliveredInMonth в этом месяце — сумма цен ступеней по позициям
|
||||
* [delivered .. delivered+leads-1]. Зеркалит логику BalanceToLeadsConverter.
|
||||
*
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function minBalanceForLeads(int $leads, int $deliveredInMonth, $tiers): string
|
||||
{
|
||||
if ($leads <= 0) {
|
||||
return '0.00';
|
||||
}
|
||||
|
||||
$sorted = $tiers
|
||||
->filter(fn ($t) => (bool) $t->is_active)
|
||||
->sortBy('tier_no')
|
||||
->values();
|
||||
|
||||
$kopecks = '0';
|
||||
$remaining = $leads;
|
||||
$cumulative = 0; // позиции [0..cumulative) пройдены предыдущими ступенями
|
||||
$position = $deliveredInMonth;
|
||||
|
||||
foreach ($sorted as $tier) {
|
||||
$unlimited = $tier->leads_in_tier === null;
|
||||
$tierEnd = $unlimited ? PHP_INT_MAX : $cumulative + (int) $tier->leads_in_tier;
|
||||
|
||||
$slotsInTier = max(0, $tierEnd - max($cumulative, $position));
|
||||
if ($slotsInTier > 0) {
|
||||
$take = min($remaining, $slotsInTier);
|
||||
$kopecks = bcadd($kopecks, bcmul((string) (int) $tier->price_per_lead_kopecks, (string) $take, 0), 0);
|
||||
$remaining -= $take;
|
||||
$position += $take;
|
||||
}
|
||||
|
||||
if ($remaining <= 0 || $unlimited) {
|
||||
break;
|
||||
}
|
||||
$cumulative = $tierEnd;
|
||||
}
|
||||
|
||||
return bcdiv($kopecks, '100', 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/billing/transactions?type=topup|lead_charge|migration&page=N
|
||||
* — пагинированная история balance_transactions тенанта (20/страница).
|
||||
|
||||
@@ -10,7 +10,10 @@ use App\Http\Requests\StoreProjectRequest;
|
||||
use App\Http\Requests\UpdateProjectRequest;
|
||||
use App\Http\Resources\ProjectResource;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Project\ProjectService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -117,7 +120,35 @@ class ProjectController extends Controller
|
||||
/** POST /api/projects */
|
||||
public function store(StoreProjectRequest $request): JsonResponse
|
||||
{
|
||||
$project = $this->projects->create($request->user()->tenant, $request->validated());
|
||||
$validated = $request->validated();
|
||||
$tenant = $request->user()->tenant;
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
|
||||
// Spec C §3.4: преfflight баланса при создании. existingLimit учитывает только активные.
|
||||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
||||
|
||||
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
||||
|
||||
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
||||
return response()->json([
|
||||
'error' => 'balance_insufficient',
|
||||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||||
'would_be_required_leads' => $wouldBeRequired,
|
||||
'deficit_leads' => $preflight['deficit_leads'],
|
||||
], 409);
|
||||
}
|
||||
|
||||
if (! $preflight['passes'] && $forceSaveBlocked) {
|
||||
$validated['preflight_blocked_at'] = now();
|
||||
}
|
||||
|
||||
$project = $this->projects->create($tenant, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($project)], 201);
|
||||
}
|
||||
@@ -126,11 +157,70 @@ class ProjectController extends Controller
|
||||
public function update(UpdateProjectRequest $request, int $id): JsonResponse
|
||||
{
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$updated = $this->projects->update($project, $request->validated());
|
||||
$validated = $request->validated();
|
||||
$tenant = $request->user()->tenant;
|
||||
$forceSaveBlocked = (bool) ($validated['force_save_blocked'] ?? false);
|
||||
unset($validated['force_save_blocked']);
|
||||
|
||||
// Spec C §3.4: преfflight при изменении лимита — учитываем новое значение для ЭТОГО
|
||||
// проекта + лимиты остальных активных не-blocked.
|
||||
if (array_key_exists('daily_limit_target', $validated)) {
|
||||
$existingLimit = (int) Project::where('tenant_id', $tenant->id)
|
||||
->where('id', '!=', $project->id)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->sum('daily_limit_target');
|
||||
$wouldBeRequired = $existingLimit + (int) $validated['daily_limit_target'];
|
||||
|
||||
$preflight = $this->runPreflight($tenant, $wouldBeRequired);
|
||||
|
||||
if (! $preflight['passes'] && ! $forceSaveBlocked) {
|
||||
return response()->json([
|
||||
'error' => 'balance_insufficient',
|
||||
'current_balance_rub' => (string) $tenant->balance_rub,
|
||||
'current_capacity_leads' => $preflight['capacity_leads'],
|
||||
'would_be_required_leads' => $wouldBeRequired,
|
||||
'deficit_leads' => $preflight['deficit_leads'],
|
||||
], 409);
|
||||
}
|
||||
|
||||
if (! $preflight['passes'] && $forceSaveBlocked) {
|
||||
$validated['preflight_blocked_at'] = now();
|
||||
}
|
||||
}
|
||||
|
||||
$updated = $this->projects->update($project, $validated);
|
||||
|
||||
return response()->json(['data' => new ProjectResource($updated)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{passes: bool, capacity_leads: int, deficit_leads: int}
|
||||
*/
|
||||
private function runPreflight(Tenant $tenant, int $requiredLeads): array
|
||||
{
|
||||
$tiers = PricingTier::query()->where('is_active', true)->get();
|
||||
|
||||
// Safe fallback: без активных pricing_tiers биллинг не настроен —
|
||||
// преfflight не имеет смысла, пропускаем (legacy-окружения / тесты).
|
||||
if ($tiers->isEmpty()) {
|
||||
return ['passes' => true, 'capacity_leads' => PHP_INT_MAX, 'deficit_leads' => 0];
|
||||
}
|
||||
|
||||
$result = (new BalancePreflightService)->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $requiredLeads,
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
return [
|
||||
'passes' => $result->passes,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'deficit_leads' => $result->deficitLeads,
|
||||
];
|
||||
}
|
||||
|
||||
/** GET /api/projects/{id} */
|
||||
public function show(Request $request, int $id): JsonResponse
|
||||
{
|
||||
@@ -164,7 +254,14 @@ class ProjectController extends Controller
|
||||
{
|
||||
$request->validate(['is_active' => ['required', 'boolean']]);
|
||||
$project = Project::where('tenant_id', $request->user()->tenant_id)->findOrFail($id);
|
||||
$project->update(['is_active' => $request->boolean('is_active')]);
|
||||
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||||
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта.
|
||||
$newActive = $request->boolean('is_active');
|
||||
$project->update([
|
||||
'is_active' => $newActive,
|
||||
'paused_at' => $newActive ? null : now(),
|
||||
]);
|
||||
|
||||
// #10: pause/resume must reach the supplier. The job's group recompute pushes
|
||||
// status=paused when no active project of the group remains (resume → active).
|
||||
|
||||
@@ -83,7 +83,7 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'vid' => 'required|integer|min:1',
|
||||
'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'],
|
||||
'project' => ['required', 'string', 'max:255'], // Phase 3: regex /^B[123]_.+$/ снят — non-B → platform=DIRECT
|
||||
'phone' => ['required', 'string', 'regex:/^7\d{10}$/'],
|
||||
'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"],
|
||||
'tag' => 'nullable|string|max:255',
|
||||
@@ -182,8 +182,12 @@ class SupplierWebhookController extends Controller
|
||||
|
||||
private function parsePlatform(string $project): string
|
||||
{
|
||||
preg_match('/^(B[123])_/', $project, $m);
|
||||
// Phase 3: проекты без B-префикса → DIRECT (раньше silent fallback на 'B1'
|
||||
// приводил к неверной маршрутизации).
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
|
||||
return $m[1] ?? 'B1';
|
||||
return 'DIRECT';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ class StoreProjectRequest extends FormRequest
|
||||
'regions' => ['present', 'array'],
|
||||
'regions.*' => ['integer', 'between:1,89'],
|
||||
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
|
||||
// Spec C §3.4: при перегрузке преfflight UI шлёт force_save_blocked=true →
|
||||
// проект создаётся с preflight_blocked_at=now() вместо ответа 409.
|
||||
'force_save_blocked' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
||||
if ($signalType === 'site') {
|
||||
|
||||
@@ -28,6 +28,9 @@ class UpdateProjectRequest extends FormRequest
|
||||
'sms_senders' => ['sometimes', 'array', 'min:1'],
|
||||
'sms_senders.*' => ['string', 'max:11'],
|
||||
'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'],
|
||||
// Spec C §3.4: при перегрузке преfflight UI шлёт force_save_blocked=true →
|
||||
// проект помечается preflight_blocked_at=now() вместо ответа 409.
|
||||
'force_save_blocked' => ['sometimes', 'boolean'],
|
||||
];
|
||||
|
||||
// 18.05.2026 UX: редактирование источника (signal_identifier) для site/call.
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Billing;
|
||||
|
||||
use App\Mail\BalanceFrozenFinalMail;
|
||||
use App\Mail\BalanceFrozenReminderMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Повторные письма заморозки баланса:
|
||||
* • reminder в окне 24-48ч после freeze;
|
||||
* • final в окне 72-96ч после freeze.
|
||||
*
|
||||
* Throttle через balance_freeze_log markers (event_type 'reminder_sent' / 'final_sent') —
|
||||
* один marker-row на (tenant, тип) в течение окна 5 дней. Запускается daily @ 18:30 MSK
|
||||
* (routes/console.php). См. спек §3.7.
|
||||
*
|
||||
* Re-evaluate PreflightResult: показываем клиенту АКТУАЛЬНЫЙ дефицит (он мог частично
|
||||
* пополниться — reminder отразит обновлённую цифру).
|
||||
*/
|
||||
final class BalanceFrozenReminderJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
|
||||
private const REMINDER_MIN_HOURS = 24;
|
||||
|
||||
private const REMINDER_MAX_HOURS = 48;
|
||||
|
||||
private const FINAL_MIN_HOURS = 72;
|
||||
|
||||
private const FINAL_MAX_HOURS = 96;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$service = new BalancePreflightService;
|
||||
$tiers = PricingTier::query()->where('is_active', true)->get();
|
||||
|
||||
Tenant::query()
|
||||
->whereNotNull('frozen_by_balance_at')
|
||||
->whereNull('deleted_at')
|
||||
->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
/** @var Tenant $tenant */
|
||||
$this->processTenant($tenant, $service, $tiers);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function processTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
|
||||
{
|
||||
// diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK).
|
||||
$hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false));
|
||||
|
||||
$window = $this->matchWindow($hours);
|
||||
if ($window === null) {
|
||||
return; // вне окон reminder/final
|
||||
}
|
||||
|
||||
$marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent';
|
||||
if ($this->alreadySent($tenant->id, $marker)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-evaluate для актуального дефицита в тексте письма.
|
||||
$result = $service->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $tenant->requiredLeadsForTomorrow(),
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
$mail = $window === 'reminder'
|
||||
? new BalanceFrozenReminderMail($tenant, $result)
|
||||
: new BalanceFrozenFinalMail($tenant, $result);
|
||||
|
||||
Mail::queue($mail);
|
||||
$this->mark($tenant, $marker, $result);
|
||||
}
|
||||
|
||||
private function matchWindow(int $hours): ?string
|
||||
{
|
||||
if ($hours >= self::REMINDER_MIN_HOURS && $hours < self::REMINDER_MAX_HOURS) {
|
||||
return 'reminder';
|
||||
}
|
||||
if ($hours >= self::FINAL_MIN_HOURS && $hours < self::FINAL_MAX_HOURS) {
|
||||
return 'final';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function alreadySent(int $tenantId, string $marker): bool
|
||||
{
|
||||
return DB::connection('pgsql_supplier')->table('balance_freeze_log')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('event_type', $marker)
|
||||
->where('created_at', '>=', now()->subDays(5))
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function mark(Tenant $tenant, string $marker, PreflightResult $result): void
|
||||
{
|
||||
DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type' => $marker,
|
||||
'triggered_by' => 'reminder_cron',
|
||||
'balance_rub_at_event' => $tenant->balance_rub,
|
||||
'required_leads' => $result->requiredLeads,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'total_daily_limit' => $result->requiredLeads,
|
||||
'details' => json_encode(['deficit_leads' => $result->deficitLeads]),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Billing;
|
||||
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Mail\BalanceFrozenMail;
|
||||
use App\Mail\BalanceUnfrozenMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Ежедневный преfflight всех тенантов перед формированием заказа поставщику.
|
||||
* Запускается cron @18:00 MSK (routes/console.php). См. спек §3.5, §5.2.
|
||||
*
|
||||
* NB: бегает без tenant-RLS (системный контекст); запросы к projects/tenants
|
||||
* явные по tenant_id (урок Спека B). Переход active→frozen / frozen→active
|
||||
* шлёт письмо; стабильное состояние не трогается (идемпотентность).
|
||||
*/
|
||||
final class BalancePreflightSweepJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$service = new BalancePreflightService;
|
||||
$tiers = PricingTier::query()->where('is_active', true)->get();
|
||||
|
||||
Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void {
|
||||
foreach ($tenants as $tenant) {
|
||||
/** @var Tenant $tenant */
|
||||
$this->evaluateTenant($tenant, $service, $tiers);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void
|
||||
{
|
||||
// Spec C deploy hotfix (25.05.2026): CLI-команды и фоновые джобы не проходят
|
||||
// через SetTenantContext middleware → app.current_tenant_id не выставлен →
|
||||
// RLS-policy на projects падает с "unrecognized configuration parameter".
|
||||
// Зеркалим mechanic SetTenantContext: SET LOCAL внутри транзакции (PgBouncer-safe).
|
||||
DB::transaction(function () use ($tenant, $service, $tiers): void {
|
||||
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id);
|
||||
|
||||
$required = $tenant->requiredLeadsForTomorrow();
|
||||
$result = $service->evaluate(
|
||||
balanceRub: (string) $tenant->balance_rub,
|
||||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||||
requiredLeads: $required,
|
||||
tiers: $tiers,
|
||||
);
|
||||
|
||||
$isFrozen = $tenant->frozen_by_balance_at !== null;
|
||||
|
||||
// Переход active → frozen.
|
||||
if (! $result->passes && ! $isFrozen) {
|
||||
$tenant->frozen_by_balance_at = now();
|
||||
$tenant->save();
|
||||
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
|
||||
Mail::queue(new BalanceFrozenMail($tenant, $result));
|
||||
$this->dispatchSupplierSyncIfOnline($tenant);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Переход frozen → active.
|
||||
if ($result->passes && $isFrozen) {
|
||||
$tenant->frozen_by_balance_at = null;
|
||||
$tenant->save();
|
||||
$this->logEvent($tenant, 'unfrozen', 'cutoff_18msk', $result);
|
||||
Mail::queue(new BalanceUnfrozenMail($tenant, $result));
|
||||
$this->dispatchSupplierSyncIfOnline($tenant);
|
||||
|
||||
return;
|
||||
}
|
||||
// Иначе состояние не изменилось — ничего не делаем (идемпотентность).
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spec C extension (26.05.2026): при переходе freeze ↔ unfreeze в режиме 'online'
|
||||
* диспатчим точечный sync с поставщиком per-project (group-recalc внутри handleOnline
|
||||
* сам учтёт шеринг через signal_identifier). В режиме 'batch' изменения уезжают
|
||||
* cut-off cron'ом @18:00 MSK через SyncSupplierProjectsJob (множественный).
|
||||
* Привязка к админ-переключателю SupplierExportMode (system_settings.supplier_export_mode).
|
||||
*
|
||||
* Вызывается ВНУТРИ DB::transaction обёртки evaluateTenant — app.current_tenant_id выставлен,
|
||||
* RLS-фильтрация projects работает.
|
||||
*/
|
||||
private function dispatchSupplierSyncIfOnline(Tenant $tenant): void
|
||||
{
|
||||
if (! SupplierExportMode::isOnline()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$projectIds = $tenant->projects()
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->pluck('id');
|
||||
|
||||
foreach ($projectIds as $id) {
|
||||
SyncSupplierProjectJob::dispatch((int) $id);
|
||||
}
|
||||
}
|
||||
|
||||
private function logEvent(Tenant $tenant, string $event, string $trigger, PreflightResult $result): void
|
||||
{
|
||||
DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_type' => $event,
|
||||
'triggered_by' => $trigger,
|
||||
'balance_rub_at_event' => $tenant->balance_rub,
|
||||
'required_leads' => $result->requiredLeads,
|
||||
'capacity_leads' => $result->capacityLeads,
|
||||
'total_daily_limit' => $result->requiredLeads,
|
||||
'details' => json_encode(['deficit_leads' => $result->deficitLeads]),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -171,11 +171,16 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
*/
|
||||
private function parseProjectField(string $project): array
|
||||
{
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) !== 1) {
|
||||
throw new RuntimeException("Cannot parse supplier project field: '{$project}'");
|
||||
if (preg_match('/^(B[123])_(.+)$/', $project, $m) === 1) {
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
} else {
|
||||
// Phase 3: проекты без B-префикса попадают в DIRECT.
|
||||
// Весь project считается identifier-частью; signal_type определяется
|
||||
// тем же regex'ом, что для $rest у B-префиксных.
|
||||
$platform = 'DIRECT';
|
||||
$rest = $project;
|
||||
}
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
|
||||
// Домен с латинским TLD ≥2 букв (последний сегмент — только буквы), допускается
|
||||
// в любой позиции строки. Соответствует чистому rest и встроенному в текст домену.
|
||||
@@ -245,6 +250,57 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
}
|
||||
$project = $lockedProject;
|
||||
|
||||
// Phase 2 fix: merge с CSV-recovered deal если webhook догоняет.
|
||||
// Идемпотентность race condition между CsvReconcileJob (vid=NULL, recovered
|
||||
// from CSV) и webhook (vid=int, реальный supplier-id). До этой проверки они
|
||||
// создавали 2 deal'a (DD снят Spec B Phase 1). Merge выполняется только если:
|
||||
// - webhook ЕСТЬ настоящий vid (lead.vid !== null) — без vid merge'ить нечего;
|
||||
// - csv-recovered deal существует за последние 24h, тот же phone+project+tenant;
|
||||
// - csv-recovered deal БЕЗ source_crm_id (т.е. он именно CSV-recovered, не другой webhook).
|
||||
// При merge: UPDATE existing.source_crm_id, INSERT supplier_lead_deliveries,
|
||||
// БЕЗ chargeForDelivery (LeadCharge уже есть с момента CSV recovery).
|
||||
$existingMergeable = null;
|
||||
if ($lead->vid !== null) {
|
||||
$existingMergeable = Deal::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('phone', (string) $lead->phone)
|
||||
->where('project_id', $project->id)
|
||||
->whereNull('source_crm_id')
|
||||
->where('received_at', '>=', now()->subDay())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
}
|
||||
if ($existingMergeable !== null) {
|
||||
// Заполняем supplier_lead.id у обоих SupplierLead → одному Deal
|
||||
DB::table('supplier_lead_deliveries')->insert([
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'deal_id' => $existingMergeable->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Обновляем только source_crm_id + updated_at через DB::table.
|
||||
// NB (регрессия 26.05.2026 04:12-05:03 UTC, 9 failed_jobs):
|
||||
// received_at — partition key, и lead_charges имеет FK
|
||||
// (deal_id, deal_received_at) с ON DELETE CASCADE, но
|
||||
// ON UPDATE NO ACTION (default). Любое изменение received_at
|
||||
// ломает FK даже в той же месячной партиции (даже DEFERRABLE
|
||||
// INITIALLY DEFERRED не помогает — проверка падает на COMMIT).
|
||||
// CSV-recovered received_at сохраняем как есть — отличие на минуты
|
||||
// несущественно, чем риск каскадного DELETE lead_charges.
|
||||
DB::table('deals')
|
||||
->where('id', $existingMergeable->id)
|
||||
->where('received_at', $existingMergeable->received_at)
|
||||
->update(['source_crm_id' => $lead->vid, 'updated_at' => now()]);
|
||||
|
||||
Log::info('supplier_lead.merged_into_csv_recovered', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'merged_into_deal_id' => $existingMergeable->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
|
||||
return true; // считаем «доставленным», но без второго списания
|
||||
}
|
||||
|
||||
// Spec B: per-(supplier_lead, tenant) lock — одна поставка одному клиенту = один раз.
|
||||
// insertOrIgnore вернёт 0, если строка уже существует (повтор/гонка/CSV-recovery).
|
||||
$locked = DB::table('supplier_lead_deliveries')->insertOrIgnore([
|
||||
|
||||
@@ -231,14 +231,23 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает platform (B1/B2/B3) из имени проекта формата `B[123]_<rest>`.
|
||||
* Возвращает null если не парсится — caller пропустит строку с warning.
|
||||
* Извлекает platform из имени проекта:
|
||||
* - `B[123]_<rest>` → 'B1' / 'B2' / 'B3';
|
||||
* - Phase 3: иначе, если строка непустая и состоит из identifier-символов
|
||||
* (домены / телефоны / SMS-отправители) → 'DIRECT';
|
||||
* - откровенный мусор (только спец-символы, пусто) → null (unparseable).
|
||||
*/
|
||||
private function extractPlatform(string $project): ?string
|
||||
{
|
||||
if (preg_match('/^(B[123])_/', $project, $m) === 1) {
|
||||
return $m[1];
|
||||
}
|
||||
// Phase 3: всё что выглядит как разумный identifier (домен / телефон / SMS-sender) → DIRECT.
|
||||
// unparseable_count теперь только для откровенного мусора (пустые / только спец-символы).
|
||||
$trimmed = trim($project);
|
||||
if ($trimmed !== '' && preg_match('/^[\w\-.а-яА-Я0-9\/() +]+$/u', $trimmed) === 1) {
|
||||
return 'DIRECT';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -83,12 +83,8 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
$this->client = app(SupplierPortalClient::class);
|
||||
$consecutiveTransient = 0;
|
||||
|
||||
// 1. Load active Лидерра-projects via pgsql_supplier
|
||||
/** @var Collection<int, Project> $projects */
|
||||
$projects = Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
// 1. Load active Лидерра-projects via pgsql_supplier (фильтруя frozen, Billing v2 Spec C §3.10).
|
||||
$projects = $this->collectEligibleProjects();
|
||||
|
||||
// 2. Group by (signal_type, identifier) — no subject_code split.
|
||||
// Portal constraint: one identifier = one B1/B2/B3 group (status=Doubles on duplicate name).
|
||||
@@ -181,6 +177,35 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Собрать eligible Лидерра-проекты для расчёта заказа поставщику.
|
||||
*
|
||||
* Фильтры (Billing v2 Spec C §3.10 — преfflight баланса):
|
||||
* — is_active = true (базовый);
|
||||
* — preflight_blocked_at IS NULL (точечная блокировка проекта при «перегрузе» лимита);
|
||||
* — tenant.frozen_by_balance_at IS NULL (пассивная заморозка тенанта по пустому балансу).
|
||||
*
|
||||
* Запрос через pgsql_supplier (BYPASSRLS) — джоб бегает в системном контексте.
|
||||
* Метод публичный для unit-теста; никто из caller'ов кроме handle() его не зовёт.
|
||||
*
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
public function collectEligibleProjects(): Collection
|
||||
{
|
||||
// NB: whereIn-subquery вместо whereHas — whereHas строит relation-query
|
||||
// через default Eloquent connection (pgsql), а наш родительский Project::on
|
||||
// на pgsql_supplier; cross-connection JOIN ломал sync-тесты (8 fails).
|
||||
// FROM 'tenants' внутри subquery наследует connection родителя.
|
||||
return Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->whereNull('preflight_blocked_at')
|
||||
->whereIn('tenant_id', function ($q): void {
|
||||
$q->select('id')->from('tenants')->whereNull('frozen_by_balance_at');
|
||||
})
|
||||
->orderBy('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{signal_type: string, identifier: string, merged_regions: list<int>, has_all_russia: bool, platforms: list<string>, projects: list<Project>} $group
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Финальное письмо: приём лидов приостановлен 3 дня (Billing v2 Spec C §3.7, T+72h).
|
||||
* После него повторов нет до следующего цикла заморозки.
|
||||
*/
|
||||
final class BalanceFrozenFinalMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Tenant $tenant,
|
||||
public readonly PreflightResult $result,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Приём лидов приостановлен 3 дня',
|
||||
to: [$this->tenant->contact_email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.balance_frozen_final');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо клиенту: приём лидов приостановлен из-за нехватки баланса (Billing v2 Spec C §3.7).
|
||||
*
|
||||
* Триггер: BalancePreflightSweepJob при переходе тенанта active → frozen (cut-off 18:00 MSK).
|
||||
*/
|
||||
final class BalanceFrozenMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Tenant $tenant,
|
||||
public readonly PreflightResult $result,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Приём лидов приостановлен — недостаточно баланса',
|
||||
to: [$this->tenant->contact_email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.balance_frozen');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо-напоминание: приём лидов всё ещё приостановлен (Billing v2 Spec C §3.7, T+24h).
|
||||
*/
|
||||
final class BalanceFrozenReminderMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Tenant $tenant,
|
||||
public readonly PreflightResult $result,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Приём лидов всё ещё приостановлен',
|
||||
to: [$this->tenant->contact_email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.balance_frozen_reminder');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Письмо клиенту: приём лидов возобновлён (Billing v2 Spec C §3.7).
|
||||
*
|
||||
* Триггер: BalancePreflightSweepJob при переходе frozen → active (пополнение/снижение лимита).
|
||||
*/
|
||||
final class BalanceUnfrozenMail extends Mailable
|
||||
{
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Tenant $tenant,
|
||||
public readonly PreflightResult $result,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Приём лидов возобновлён',
|
||||
to: [$this->tenant->contact_email],
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(view: 'emails.balance_unfrozen');
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ class Project extends Model
|
||||
'tag',
|
||||
'type',
|
||||
'is_active',
|
||||
'paused_at',
|
||||
'daily_limit_target',
|
||||
'effective_daily_limit_today',
|
||||
'effective_limit_calculated_at',
|
||||
@@ -63,12 +64,15 @@ class Project extends Model
|
||||
// Plan 2/5 Task 1 (schema v8.18): дневной счётчик доставленных лидов
|
||||
// (сбрасывается cron'ом в 00:00 МСК, используется LeadRouter'ом).
|
||||
'delivered_today',
|
||||
// Billing v2 Spec C: флаг точечной блокировки проекта по преfflight (NULL = не заблокирован).
|
||||
'preflight_blocked_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_active' => 'boolean',
|
||||
'paused_at' => 'datetime',
|
||||
'daily_limit_target' => 'integer',
|
||||
'effective_daily_limit_today' => 'integer',
|
||||
'region_mask' => 'integer',
|
||||
@@ -79,6 +83,8 @@ class Project extends Model
|
||||
'delivery_days_mask' => 'integer',
|
||||
'ttfr_target_minutes' => 'integer',
|
||||
'effective_limit_calculated_at' => 'datetime',
|
||||
// Billing v2 Spec C: флаг преfflight-блокировки проекта.
|
||||
'preflight_blocked_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
// Supplier integration:
|
||||
|
||||
@@ -44,6 +44,7 @@ class Tenant extends Model
|
||||
'delivered_in_month',
|
||||
'api_key_limit',
|
||||
'limits',
|
||||
'frozen_by_balance_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -61,6 +62,8 @@ class Tenant extends Model
|
||||
'limits' => 'array',
|
||||
'last_activity_at' => 'datetime',
|
||||
'last_webhook_at' => 'datetime',
|
||||
// Billing v2 Spec C: флаг заморозки по балансу (NULL = не заморожен).
|
||||
'frozen_by_balance_at' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
'deleted_at' => 'datetime',
|
||||
@@ -79,6 +82,19 @@ class Tenant extends Model
|
||||
return $this->hasMany(Project::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сумма daily_limit_target активных проектов — «сколько лидов клиент хочет в день».
|
||||
* Используется преfflight'ом (Billing v2 Spec C §3.3) как requiredLeads.
|
||||
*
|
||||
* NB: фильтр по `is_active` (boolean), не `status` — у projects нет колонки status.
|
||||
*/
|
||||
public function requiredLeadsForTomorrow(): int
|
||||
{
|
||||
return (int) $this->projects()
|
||||
->where('is_active', true)
|
||||
->sum('daily_limit_target');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<TariffPlan, $this> */
|
||||
public function tariff(): BelongsTo
|
||||
{
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
/**
|
||||
* Pure: проходит ли клиент преfflight — хватает ли баланса на ПОЛНЫЙ дневной
|
||||
* лимит всех его eligible-проектов по текущему тарифу.
|
||||
*
|
||||
* Сравнение в ЛИДАХ (capacity vs required), не в рублях — переиспользует
|
||||
* BalanceToLeadsConverter::convert, который учитывает 7 ступеней и накопленный
|
||||
* месячный объём (deliveredInMonth).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md §3.3
|
||||
*/
|
||||
final class BalancePreflightService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BalanceToLeadsConverter $converter = new BalanceToLeadsConverter,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param Collection<int, PricingTier> $tiers
|
||||
*/
|
||||
public function evaluate(
|
||||
string $balanceRub,
|
||||
int $deliveredInMonth,
|
||||
int $requiredLeads,
|
||||
Collection $tiers,
|
||||
): PreflightResult {
|
||||
if ($requiredLeads <= 0) {
|
||||
return new PreflightResult(true, 0, 0, 0);
|
||||
}
|
||||
|
||||
$capacity = (int) $this->converter->convert($balanceRub, $deliveredInMonth, $tiers)['leads'];
|
||||
$passes = $capacity >= $requiredLeads;
|
||||
|
||||
return new PreflightResult(
|
||||
passes: $passes,
|
||||
requiredLeads: $requiredLeads,
|
||||
capacityLeads: $capacity,
|
||||
deficitLeads: $passes ? 0 : ($requiredLeads - $capacity),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -128,10 +128,17 @@ final class LedgerService
|
||||
{
|
||||
if ($lead->supplier_project_id !== null) {
|
||||
$sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first();
|
||||
if ($sp !== null && in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
|
||||
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
|
||||
if ($supplier !== null) {
|
||||
return (int) $supplier->id;
|
||||
if ($sp !== null) {
|
||||
if (in_array($sp->platform, ['B1', 'B2', 'B3'], true)) {
|
||||
$supplier = Supplier::where('code', strtolower($sp->platform))->first();
|
||||
if ($supplier !== null) {
|
||||
return (int) $supplier->id;
|
||||
}
|
||||
}
|
||||
if ($sp->platform === 'DIRECT') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +150,12 @@ final class LedgerService
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
// Phase 3: project без B-префикса (и не пустой) → DIRECT.
|
||||
if ($project !== '') {
|
||||
$supplier = Supplier::where('code', 'direct')->first();
|
||||
|
||||
return $supplier?->id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Billing;
|
||||
|
||||
/**
|
||||
* Результат преfflight-проверки платёжеспособности тенанта (Billing v2 Spec C).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md §3.3
|
||||
*/
|
||||
final class PreflightResult
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $passes,
|
||||
public readonly int $requiredLeads,
|
||||
public readonly int $capacityLeads,
|
||||
public readonly int $deficitLeads,
|
||||
) {}
|
||||
}
|
||||
@@ -47,6 +47,39 @@ class LeadRouter
|
||||
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
// Phase 3: для DIRECT-supplier_project — fallback на signal_type+signal_identifier
|
||||
// match с Лидерра-проектами, потому что project_supplier_links для DIRECT-row'ов
|
||||
// не создаются (новые DIRECT supplier_projects создаются автоматически при
|
||||
// получении webhook'а без B-префикса; explicit psl-link для них не настраивается).
|
||||
if ($supplierProject->platform === 'DIRECT') {
|
||||
$directSql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
WHERE projects.signal_type = ?
|
||||
AND LOWER(projects.signal_identifier) = LOWER(?)
|
||||
AND projects.is_active = true
|
||||
AND (projects.delivery_days_mask & ?) <> 0
|
||||
AND projects.delivered_today < COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM tenants
|
||||
WHERE tenants.id = projects.tenant_id
|
||||
AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)
|
||||
)
|
||||
ORDER BY
|
||||
projects.tenant_id,
|
||||
(COALESCE(projects.effective_daily_limit_today, projects.daily_limit_target) - projects.delivered_today) DESC,
|
||||
projects.created_at,
|
||||
projects.id
|
||||
SQL;
|
||||
$directRows = DB::connection('pgsql_supplier')->select(
|
||||
$directSql,
|
||||
[$supplierProject->signal_type, $supplierProject->unique_key, $todayBit]
|
||||
);
|
||||
|
||||
return Project::hydrate($directRows)->values();
|
||||
}
|
||||
|
||||
// Existing B1/B2/B3 path — explicit project_supplier_links pivot.
|
||||
$sql = <<<'SQL'
|
||||
SELECT DISTINCT ON (projects.tenant_id) projects.*
|
||||
FROM projects
|
||||
|
||||
@@ -18,6 +18,7 @@ class ProjectService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationsLogger $ops = new OperationsLogger,
|
||||
private readonly SupplierSnapshotGuard $snapshotGuard = new SupplierSnapshotGuard,
|
||||
) {}
|
||||
|
||||
public function update(Project $project, array $data): Project
|
||||
@@ -30,6 +31,15 @@ class ProjectService
|
||||
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
|
||||
);
|
||||
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
|
||||
// Если меняем источник (signal_identifier / sms_senders / sms_keyword) — guard.
|
||||
$sourceFieldsTouched = array_key_exists('signal_identifier', $data)
|
||||
|| array_key_exists('sms_senders', $data)
|
||||
|| array_key_exists('sms_keyword', $data);
|
||||
if ($sourceFieldsTouched) {
|
||||
$this->snapshotGuard->assertCanMutateSource($project, 'change_source');
|
||||
}
|
||||
|
||||
if (isset($data['daily_limit_target']) && $data['daily_limit_target'] < $project->delivered_today) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'errors' => [
|
||||
@@ -149,6 +159,11 @@ class ProjectService
|
||||
|
||||
public function delete(Project $project): void
|
||||
{
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
|
||||
// Guard поставщикова слепка ПЕРЕД has-deals (приоритетней) — клиент должен
|
||||
// увидеть формулировку про «уже заказали лиды», а не «есть сделки».
|
||||
$this->snapshotGuard->assertCanMutateSource($project, 'delete');
|
||||
|
||||
$hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
|
||||
if ($hasDeals) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
@@ -261,7 +276,13 @@ class ProjectService
|
||||
private function bulkPauseResume($query, bool $isActive): array
|
||||
{
|
||||
$ids = (clone $query)->pluck('id')->all();
|
||||
$updated = $query->update(['is_active' => $isActive]);
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||||
// paused_at — anchor для SupplierSnapshotGuard grace-расчёта. Mass-update НЕ
|
||||
// триггерит model events, поэтому пишем явно в одном UPDATE.
|
||||
$updated = $query->update([
|
||||
'is_active' => $isActive,
|
||||
'paused_at' => $isActive ? null : DB::raw('NOW()'),
|
||||
]);
|
||||
foreach ($ids as $id) {
|
||||
SyncSupplierProjectJob::dispatch((int) $id);
|
||||
}
|
||||
@@ -291,8 +312,15 @@ class ProjectService
|
||||
try {
|
||||
$this->delete($model);
|
||||
$deleted++;
|
||||
} catch (HttpResponseException) {
|
||||
$skipped[] = ['id' => $p->id, 'reason' => 'has_deals'];
|
||||
} catch (HttpResponseException $e) {
|
||||
// Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 12).
|
||||
// Разделяем причину: guard поставщика (нужно подождать) vs has-deals.
|
||||
$body = json_decode((string) $e->getResponse()->getContent(), true);
|
||||
$message = (string) ($body['errors']['project'][0] ?? '');
|
||||
$reason = str_contains($message, 'Мы уже начали сбор лидов')
|
||||
? 'supplier_snapshot_locked'
|
||||
: 'has_deals';
|
||||
$skipped[] = ['id' => $p->id, 'reason' => $reason];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Project;
|
||||
|
||||
use App\Models\Project;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Защита проекта от удаления/смены источника, пока поставщик crm.bp-gr.ru
|
||||
* может прислать по нему лиды по уже сделанному слепку.
|
||||
*
|
||||
* Slepok-час поставщика: 21:00 МСК (поставщик в 21:00 формирует заказ на завтра).
|
||||
* Grace: до следующего 21:00 МСК после pause + 24h на доставку хвоста.
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
|
||||
*/
|
||||
class SupplierSnapshotGuard
|
||||
{
|
||||
/** Час МСК, в который поставщик заказывает лиды на следующий день. */
|
||||
public const SUPPLIER_ORDER_HOUR_MSK = 21;
|
||||
|
||||
/** Сколько часов после слепка летит хвост лидов (одни сутки). */
|
||||
public const TAIL_DELIVERY_HOURS = 24;
|
||||
|
||||
public function computeGraceUntil(CarbonInterface $pausedAt): CarbonImmutable
|
||||
{
|
||||
$pausedMsk = CarbonImmutable::instance($pausedAt)->setTimezone('Europe/Moscow');
|
||||
|
||||
$next21 = $pausedMsk->setTime(self::SUPPLIER_ORDER_HOUR_MSK, 0, 0);
|
||||
if ($pausedMsk->gte($next21)) {
|
||||
$next21 = $next21->addDay();
|
||||
}
|
||||
|
||||
return $next21->addHours(self::TAIL_DELIVERY_HOURS);
|
||||
}
|
||||
|
||||
public function isProtected(Project $project, ?CarbonImmutable $now = null): bool
|
||||
{
|
||||
$hasLinks = DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->exists();
|
||||
if (! $hasLinks) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($project->is_active) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($project->paused_at === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$graceUntil = $this->computeGraceUntil($project->paused_at);
|
||||
$effectiveNow = $now ?? CarbonImmutable::now('Europe/Moscow');
|
||||
|
||||
return $effectiveNow->lt($graceUntil);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param 'delete'|'change_source' $action
|
||||
*/
|
||||
public function assertCanMutateSource(Project $project, string $action): void
|
||||
{
|
||||
if (! $this->isProtected($project)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$verb = $action === 'delete' ? 'Удалить' : 'Изменить источник';
|
||||
$message = 'Мы уже начали сбор лидов по этому проекту на завтра. '
|
||||
.'Пока поставьте на паузу — мы увидим это сегодня в 18:00 и завтра '
|
||||
.'не будем запускать сбор лидов по этому проекту. '
|
||||
.$verb.' можно будет послезавтра.';
|
||||
|
||||
throw new HttpResponseException(response()->json([
|
||||
'errors' => ['project' => [$message]],
|
||||
], 422));
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ use InvalidArgumentException;
|
||||
*/
|
||||
class SupplierProjectResolver
|
||||
{
|
||||
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3'];
|
||||
private const ALLOWED_PLATFORMS = ['B1', 'B2', 'B3', 'DIRECT'];
|
||||
|
||||
private const ALLOWED_SIGNAL_TYPES = ['site', 'call', 'sms'];
|
||||
|
||||
|
||||
@@ -47,4 +47,18 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
|
||||
return null; // default render for non-JSON
|
||||
});
|
||||
|
||||
// Supplier webhook always returns JSON, even when client omits Accept header.
|
||||
// Without this render, Laravel's default ValidationException handler returns
|
||||
// 302 redirect to /, which strips POST body — losing supplier leads.
|
||||
// Confirmed 2026-05-25: 76 of 234 webhook hits today got 302 instead of 422.
|
||||
$exceptions->render(function (\Illuminate\Validation\ValidationException $e, Request $request) {
|
||||
if ($request->is('api/webhook/supplier/*')) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed',
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
}
|
||||
return null; // default render for other routes
|
||||
});
|
||||
})->create();
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Всё DDL через pgsql_supplier — избегаем deadlock при смешивании соединений.
|
||||
// Laravel оборачивает миграцию в транзакцию на дефолтном pgsql; ALTER TABLE tenants
|
||||
// на pgsql + CREATE TABLE с FK на tenants через pgsql_supplier = взаимная блокировка.
|
||||
// На dev pgsql_supplier = postgres superuser (те же права), на prod — явные GRANT'ы ниже.
|
||||
$supplier = DB::connection('pgsql_supplier');
|
||||
|
||||
// Флаги заморозки. tenant-level — пассивный износ; project-level — точечная перегрузка.
|
||||
$supplier->statement('ALTER TABLE tenants ADD COLUMN IF NOT EXISTS frozen_by_balance_at TIMESTAMPTZ NULL');
|
||||
$supplier->statement('ALTER TABLE projects ADD COLUMN IF NOT EXISTS preflight_blocked_at TIMESTAMPTZ NULL');
|
||||
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL');
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL');
|
||||
|
||||
// Журнал заморозок/разморозок. Создаём через pgsql_supplier (урок Спека B — prod-роли).
|
||||
$supplier->statement(<<<'SQL'
|
||||
CREATE TABLE IF NOT EXISTS balance_freeze_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
event_type VARCHAR(30) NOT NULL,
|
||||
triggered_by VARCHAR(30) NOT NULL,
|
||||
balance_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
required_leads INTEGER NOT NULL,
|
||||
capacity_leads INTEGER NOT NULL,
|
||||
total_daily_limit INTEGER NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
SQL);
|
||||
$supplier->statement('ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY');
|
||||
$supplier->statement(<<<'SQL'
|
||||
CREATE POLICY tenant_isolation ON balance_freeze_log
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint)
|
||||
SQL);
|
||||
$supplier->statement('CREATE INDEX IF NOT EXISTS balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC)');
|
||||
|
||||
// Гранты для 4 ролей (mirror webhook_dedup_keys / supplier_lead_deliveries).
|
||||
foreach (['crm_app_user', 'crm_supplier_worker', 'crm_migrator', 'crm_admin_user'] as $role) {
|
||||
$supplier->statement(<<<SQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{$role}') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO {$role};
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO {$role};
|
||||
END IF;
|
||||
END
|
||||
\$\$
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$supplier = DB::connection('pgsql_supplier');
|
||||
$supplier->statement('DROP TABLE IF EXISTS balance_freeze_log');
|
||||
$supplier->statement('ALTER TABLE projects DROP COLUMN IF EXISTS preflight_blocked_at');
|
||||
$supplier->statement('ALTER TABLE tenants DROP COLUMN IF EXISTS frozen_by_balance_at');
|
||||
}
|
||||
};
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 supplier webhook reliability — расширяет platform enum в
|
||||
* supplier_projects и project_supplier_links до (B1,B2,B3,DIRECT).
|
||||
*
|
||||
* DIRECT — это «прямая» платформа поставщика без B-префикса в имени
|
||||
* проекта (e.g. `client.carmoney.ru`, `cashmotor.ru`, числовые телефоны).
|
||||
* До Phase 3 такие webhook'и отвергались с 302-редиректом и терялись:
|
||||
* наблюдалось 67 потерь/день на проде 25.05.2026 для tenant client1.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*
|
||||
* NB: chk_supplier_projects_b1_not_for_sms (B1+SMS deny) НЕ трогаем —
|
||||
* DIRECT+SMS этим constraint'ом не блокируется (он специфичен для B1).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 1) Расширить platform-колонки до VARCHAR(8) (было VARCHAR(4): "DIRECT" не вмещается).
|
||||
// supplier_manual_sync_queue.platform уже VARCHAR(8) — пропускаем.
|
||||
DB::statement('ALTER TABLE supplier_projects ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
DB::statement('ALTER TABLE project_supplier_links ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN platform TYPE VARCHAR(8)');
|
||||
|
||||
// 2) Расширить CHECK constraints на enum значения.
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
|
||||
DB::statement('ALTER TABLE supplier_leads DROP CONSTRAINT chk_supplier_leads_platform');
|
||||
DB::statement("ALTER TABLE supplier_leads ADD CONSTRAINT chk_supplier_leads_platform CHECK (platform IN ('B1','B2','B3','DIRECT'))");
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Перед откатом — убедиться что в БД нет rows с platform='DIRECT',
|
||||
// иначе constraint провалится при ADD. Это ответственность того, кто
|
||||
// запускает migrate:rollback. На prod — отдельный cleanup SQL до отката:
|
||||
// DELETE FROM project_supplier_links WHERE platform='DIRECT';
|
||||
// DELETE FROM supplier_projects WHERE platform='DIRECT';
|
||||
// DELETE FROM supplier_leads WHERE platform='DIRECT';
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT chk_supplier_projects_platform');
|
||||
DB::statement("ALTER TABLE supplier_projects ADD CONSTRAINT chk_supplier_projects_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
DB::statement('ALTER TABLE project_supplier_links DROP CONSTRAINT chk_psl_platform');
|
||||
DB::statement("ALTER TABLE project_supplier_links ADD CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
DB::statement('ALTER TABLE supplier_leads DROP CONSTRAINT chk_supplier_leads_platform');
|
||||
DB::statement("ALTER TABLE supplier_leads ADD CONSTRAINT chk_supplier_leads_platform CHECK (platform IN ('B1','B2','B3'))");
|
||||
|
||||
// Сужение TYPE обратно к VARCHAR(4) — только если все значения помещаются (B1/B2/B3 = 2 символа).
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
DB::statement('ALTER TABLE project_supplier_links ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
DB::statement('ALTER TABLE supplier_projects ALTER COLUMN platform TYPE VARCHAR(4)');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Phase 3 — DIRECT supplier row (used by LedgerService::resolveSupplierId
|
||||
* fallback for platform='DIRECT'). cost_rub matches B1 (same supplier,
|
||||
* different routing).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$b1 = DB::table('suppliers')->where('code', 'b1')->first();
|
||||
if ($b1 === null) {
|
||||
// Если B1 нет — significant prod drift, не должно произойти.
|
||||
// Создаём с дефолтным cost_rub=1.00 (как на prod 25.05.2026).
|
||||
$costRub = '1.00';
|
||||
} else {
|
||||
$costRub = (string) $b1->cost_rub;
|
||||
}
|
||||
|
||||
// Используем raw SQL чтобы корректно сериализовать PG-array для accepts_types.
|
||||
DB::insert(
|
||||
"INSERT INTO suppliers (code, name, accepts_types, cost_rub, channel, is_active, sort_order, created_at)
|
||||
VALUES (?, ?, ARRAY['websites','calls','sms'], ?, ?, true, 4, NOW())
|
||||
ON CONFLICT (code) DO NOTHING",
|
||||
[
|
||||
'direct',
|
||||
'DIRECT — Прямые проекты',
|
||||
$costRub,
|
||||
'sites', // принимает любые сигналы; channel='sites' допустим в suppliers_channel_check
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('suppliers')->where('code', 'direct')->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->timestampTz('paused_at')->nullable()->after('is_active');
|
||||
$table->index('paused_at', 'projects_paused_at_idx');
|
||||
});
|
||||
|
||||
// Backfill: для уже paused проектов используем updated_at как best-effort
|
||||
// (для долго-paused — grace давно истёк; для свежих — близко к реальной паузе).
|
||||
DB::statement(<<<'SQL'
|
||||
UPDATE projects
|
||||
SET paused_at = updated_at
|
||||
WHERE is_active = false
|
||||
AND paused_at IS NULL
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('projects', function (Blueprint $table): void {
|
||||
$table->dropIndex('projects_paused_at_idx');
|
||||
$table->dropColumn('paused_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -106,3 +106,27 @@ export async function topup(amountRub: number): Promise<TopupResult> {
|
||||
const { data } = await apiClient.post<TopupResult>('/api/billing/topup', { amount_rub: amountRub });
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ответ GET /api/billing/balance-status — лёгкий статус баланса для UI префлайта
|
||||
* (Billing v2 Spec C §3.6): питает баннер заморозки + индикатор ёмкости.
|
||||
*/
|
||||
export interface BalanceStatus {
|
||||
/** ISO-дата заморозки или null (не заморожен). */
|
||||
frozen_by_balance_at: string | null;
|
||||
balance_rub: string;
|
||||
/** Сколько лидов покрывает баланс по текущему тарифу. */
|
||||
capacity_leads: number;
|
||||
/** Суммарный дневной заказ активных не-заблокированных проектов. */
|
||||
required_leads_per_day: number;
|
||||
/** На сколько лидов заказ превышает ёмкость (0 если хватает). */
|
||||
deficit_leads: number;
|
||||
/** Сколько ₽ не хватает, чтобы покрыть дефицит (scale 2, "0.00" если хватает). */
|
||||
deficit_rub: string;
|
||||
}
|
||||
|
||||
/** GET /api/billing/balance-status — статус для баннера заморозки и индикатора ёмкости. */
|
||||
export async function getBalanceStatus(): Promise<BalanceStatus> {
|
||||
const { data } = await apiClient.get<BalanceStatus>('/api/billing/balance-status');
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Постоянная подсказка под балансом (Billing v2 Spec C §3.6, Task 1.10).
|
||||
*
|
||||
* Чистый presentational-компонент: показывает, на сколько дней хватит ёмкости
|
||||
* баланса (в лидах) при текущем дневном заказе всех eligible-проектов.
|
||||
* Зелёный — хватает на 3+ дня; жёлтый — меньше 3 дней; красный — не хватает.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
/** Баланс в рублях (строка scale 2, например "1000.00"). */
|
||||
balanceRub: string;
|
||||
/** Сколько лидов покрывает баланс по текущему тарифу. */
|
||||
capacityLeads: number;
|
||||
/** Суммарный дневной заказ всех активных проектов (лидов/день). */
|
||||
requiredLeadsPerDay: number;
|
||||
}>();
|
||||
|
||||
const daysLeft = computed(() =>
|
||||
props.requiredLeadsPerDay > 0 ? props.capacityLeads / props.requiredLeadsPerDay : Infinity,
|
||||
);
|
||||
|
||||
const statusClass = computed(() => {
|
||||
if (props.requiredLeadsPerDay > 0 && props.capacityLeads < props.requiredLeadsPerDay) {
|
||||
return 'capacity-insufficient';
|
||||
}
|
||||
if (daysLeft.value < 3) return 'capacity-warning';
|
||||
return 'capacity-ok';
|
||||
});
|
||||
|
||||
const daysLabel = computed(() => (Number.isFinite(daysLeft.value) ? daysLeft.value.toFixed(1) : '∞'));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="balance-capacity text-body-2" :class="statusClass" data-testid="balance-capacity-indicator">
|
||||
<div>Баланс: {{ balanceRub }}₽ = до {{ capacityLeads }} лидов по тарифу</div>
|
||||
<div>Проекты заказывают: {{ requiredLeadsPerDay }} лидов в день</div>
|
||||
<div v-if="statusClass === 'capacity-insufficient'" class="capacity-note">
|
||||
⚠️ Не хватает — пополните счёт
|
||||
</div>
|
||||
<div v-else-if="statusClass === 'capacity-warning'" class="capacity-note">
|
||||
Хватит на ~{{ daysLabel }} дн. — скоро потребуется пополнение
|
||||
</div>
|
||||
<div v-else class="capacity-note">✅ Хватит на ~{{ daysLabel }} дн.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.balance-capacity {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.capacity-note {
|
||||
font-weight: 600;
|
||||
}
|
||||
.capacity-ok .capacity-note {
|
||||
color: rgb(var(--v-theme-success));
|
||||
}
|
||||
.capacity-warning .capacity-note {
|
||||
color: rgb(var(--v-theme-warning));
|
||||
}
|
||||
.capacity-insufficient .capacity-note {
|
||||
color: rgb(var(--v-theme-error));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Красный баннер заморозки баланса (Billing v2 Spec C §3.6, Task 1.10).
|
||||
*
|
||||
* Показывается на всех страницах, когда tenant.frozen_by_balance_at !== null
|
||||
* (проп `frozen`). Источник данных — tenantStore, загружаемый глобально в
|
||||
* AppLayout. Чистый presentational-компонент.
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
frozen: boolean;
|
||||
/** Сколько ₽ не хватает на дневной заказ (строка scale 2). */
|
||||
deficitRub?: string;
|
||||
/** Сколько лидов превышают ёмкость баланса. */
|
||||
deficitLeads?: number;
|
||||
}>();
|
||||
|
||||
const hasDeficit = computed(() => (props.deficitLeads ?? 0) > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-alert
|
||||
v-if="frozen"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
rounded="lg"
|
||||
class="balance-frozen-banner ma-4 mb-0"
|
||||
data-testid="balance-frozen-banner"
|
||||
>
|
||||
<div class="text-subtitle-2 font-weight-bold">Приём лидов приостановлен</div>
|
||||
<div class="text-body-2 mb-2">
|
||||
Не хватает баланса на дневной заказ.<span v-if="hasDeficit">
|
||||
Нужно ещё {{ deficitRub }}₽ (или сократи лимиты на {{ deficitLeads }} лидов).</span>
|
||||
</div>
|
||||
<RouterLink to="/billing" data-testid="banner-topup-link" class="banner-link">
|
||||
Пополнить счёт
|
||||
</RouterLink>
|
||||
<RouterLink to="/projects" data-testid="banner-projects-link" class="banner-link ml-4">
|
||||
Перейти к проектам
|
||||
</RouterLink>
|
||||
</v-alert>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.banner-link {
|
||||
font-weight: 600;
|
||||
color: rgb(var(--v-theme-error));
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -103,7 +103,22 @@ async function confirmAndRun(action: 'pause' | 'resume' | 'delete') {
|
||||
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
|
||||
const result = await store.bulkUpdate(payload);
|
||||
if (result.skipped.length > 0) {
|
||||
skipToastText.value = `Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`;
|
||||
const supplierLocked = result.skipped.filter((s) => s.reason === 'supplier_snapshot_locked').length;
|
||||
const withDeals = result.skipped.filter((s) => s.reason === 'has_deals').length;
|
||||
const groups: string[] = [];
|
||||
if (supplierLocked > 0) {
|
||||
groups.push(
|
||||
`${supplierLocked} — мы уже начали сбор лидов на завтра (поставьте проект на паузу, удалить можно будет послезавтра)`,
|
||||
);
|
||||
}
|
||||
if (withDeals > 0) {
|
||||
groups.push(`${withDeals} — по проекту есть сделки`);
|
||||
}
|
||||
// Fallback на старый текст, если reason неизвестный (защита от регрессии при добавлении новых причин).
|
||||
if (groups.length === 0) {
|
||||
groups.push(`${result.skipped.length} (конфликт с уже доставленными лидами)`);
|
||||
}
|
||||
skipToastText.value = `Применено: ${result.updated}. Пропущено: ${groups.join('; ')}.`;
|
||||
skipToastOpen.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,11 +65,20 @@ async function onPause(): Promise<void> {
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!props.project) return;
|
||||
const ok = window.confirm(
|
||||
'Удалить проект? Действие необратимо. Если по проекту есть сделки — удаление будет заблокировано.',
|
||||
'Удалить проект? Действие необратимо. Если по проекту есть сделки или поставщик уже заказал лиды — удаление будет заблокировано.',
|
||||
);
|
||||
if (!ok) return;
|
||||
await store.del(props.project.id);
|
||||
emit('close');
|
||||
Object.keys(errors).forEach((k) => delete errors[k]);
|
||||
try {
|
||||
await store.del(props.project.id);
|
||||
emit('close');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
|
||||
if (err.response?.status === 422 && err.response.data?.errors) {
|
||||
Object.assign(errors, err.response.data.errors);
|
||||
}
|
||||
// НЕ закрываем drawer — клиент видит ошибку и может поставить проект на паузу.
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
@@ -130,6 +139,11 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
</header>
|
||||
|
||||
<div class="pdd-body">
|
||||
<!-- Общая ошибка уровня проекта (например, supplier-snapshot guard или has-deals на delete). -->
|
||||
<div v-if="errors.project" class="pdd-error pdd-error-banner" data-testid="pdd-error-project">
|
||||
{{ errors.project[0] }}
|
||||
</div>
|
||||
|
||||
<label class="pdd-field">
|
||||
<span class="pdd-label">Название</span>
|
||||
<input v-model="form.name" data-testid="pdd-name" class="pdd-input" />
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Диалог перегрузки лимита (Billing v2 Spec C §6.2, Task 1.10).
|
||||
*
|
||||
* Открывается, когда POST/PATCH /api/projects вернул 409 `balance_insufficient`.
|
||||
* Показывает дефицит и предлагает три исхода:
|
||||
* - «Сохранить и приостановить» → save-blocked (родитель пере-сабмитит с
|
||||
* force_save_blocked=true → проект создаётся с preflight_blocked_at);
|
||||
* - «Поставить лимит 0» → set-zero (родитель ставит daily_limit_target=0);
|
||||
* - «Отмена» → закрытие без сохранения.
|
||||
*/
|
||||
export interface OverloadPayload {
|
||||
current_balance_rub: string;
|
||||
current_capacity_leads: number;
|
||||
would_be_required_leads: number;
|
||||
deficit_leads: number;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean;
|
||||
payload: OverloadPayload | null;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
'save-blocked': [];
|
||||
'set-zero': [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-dialog
|
||||
:model-value="modelValue"
|
||||
max-width="520"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card v-if="payload" data-testid="overload-dialog">
|
||||
<v-card-title>Лимит превышает баланс</v-card-title>
|
||||
<v-card-text>
|
||||
<p>
|
||||
У тебя {{ payload.current_balance_rub }}₽ =
|
||||
{{ payload.current_capacity_leads }} лидов по текущему тарифу.
|
||||
</p>
|
||||
<p>После сохранения нужно {{ payload.would_be_required_leads }} лидов.</p>
|
||||
<p class="font-weight-medium">Не хватает: {{ payload.deficit_leads }} лидов.</p>
|
||||
<p class="text-medium-emphasis mt-2">
|
||||
Чтобы проект начал работать — пополни счёт, поставь его лимит 0
|
||||
или уменьши лимиты других проектов.
|
||||
</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" data-testid="overload-cancel" @click="$emit('update:modelValue', false)">
|
||||
Отмена
|
||||
</v-btn>
|
||||
<v-btn variant="text" data-testid="overload-set-zero" @click="$emit('set-zero')">
|
||||
Поставить лимит 0
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
data-testid="overload-save-blocked"
|
||||
@click="$emit('save-blocked')"
|
||||
>
|
||||
Сохранить и приостановить
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
@@ -14,16 +14,19 @@ import { RouterView, useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useNotificationsStore } from '../stores/notifications';
|
||||
import { useRemindersStore } from '../stores/reminders';
|
||||
import { useTenantStore } from '../stores/tenantStore';
|
||||
import { usePolling } from '../composables/usePolling';
|
||||
import { POLLING_INTERVAL_MS, POLLING_REMINDERS_INTERVAL_MS } from '../constants/polling';
|
||||
import AppSidebar from '../components/layout/AppSidebar.vue';
|
||||
import AppTopbar from '../components/layout/AppTopbar.vue';
|
||||
import DevIndexBadge from '../components/DevIndexBadge.vue';
|
||||
import CommandPalette from '../components/layout/CommandPalette.vue';
|
||||
import BalanceFrozenBanner from '../components/billing/BalanceFrozenBanner.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const notifications = useNotificationsStore();
|
||||
const reminders = useRemindersStore();
|
||||
const tenant = useTenantStore();
|
||||
const route = useRoute();
|
||||
|
||||
const drawerOpen = ref(true);
|
||||
@@ -60,12 +63,19 @@ async function loadReminderCounts(): Promise<void> {
|
||||
await reminders.refreshCounts();
|
||||
}
|
||||
|
||||
async function loadBalanceStatus(): Promise<void> {
|
||||
if (!auth.user) return;
|
||||
await tenant.load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadNotifications();
|
||||
void loadReminderCounts();
|
||||
void loadBalanceStatus();
|
||||
});
|
||||
usePolling(loadNotifications, { intervalMs: POLLING_INTERVAL_MS, enabled: true });
|
||||
usePolling(loadReminderCounts, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true });
|
||||
usePolling(loadBalanceStatus, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enabled: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -74,6 +84,11 @@ usePolling(loadReminderCounts, { intervalMs: POLLING_REMINDERS_INTERVAL_MS, enab
|
||||
<AppTopbar :page-title="currentPageTitle" @toggle-drawer="drawerOpen = !drawerOpen" />
|
||||
|
||||
<v-main class="app-main">
|
||||
<BalanceFrozenBanner
|
||||
:frozen="tenant.frozen"
|
||||
:deficit-rub="tenant.deficitRub"
|
||||
:deficit-leads="tenant.deficitLeads"
|
||||
/>
|
||||
<RouterView v-slot="{ Component, route: r }">
|
||||
<Transition :name="(r.meta.transition as string) ?? 'ld-route-fadeup'" mode="out-in">
|
||||
<component :is="Component" :key="r.fullPath" />
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import * as billingApi from '../api/billing';
|
||||
import type { BalanceStatus } from '../api/billing';
|
||||
|
||||
/**
|
||||
* Tenant-store: статус баланса текущего тенанта для UI префлайта (Billing v2
|
||||
* Spec C Task 1.10). Единый источник для глобального баннера заморозки
|
||||
* (BalanceFrozenBanner в AppLayout) и индикатора ёмкости (BalanceCapacityIndicator
|
||||
* в BillingView) — чтобы не делать два запроса.
|
||||
*
|
||||
* Загружается глобально в AppLayout (load + polling). `load()` молча проглатывает
|
||||
* ошибку: баннер/индикатор не критичны и не должны валить страницу.
|
||||
*/
|
||||
export const useTenantStore = defineStore('tenant', () => {
|
||||
const status = ref<BalanceStatus | null>(null);
|
||||
|
||||
const frozen = computed(() => status.value?.frozen_by_balance_at != null);
|
||||
const deficitRub = computed(() => status.value?.deficit_rub ?? '0.00');
|
||||
const deficitLeads = computed(() => status.value?.deficit_leads ?? 0);
|
||||
const balanceRub = computed(() => status.value?.balance_rub ?? '0.00');
|
||||
const capacityLeads = computed(() => status.value?.capacity_leads ?? 0);
|
||||
const requiredLeadsPerDay = computed(() => status.value?.required_leads_per_day ?? 0);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
status.value = await billingApi.getBalanceStatus();
|
||||
} catch {
|
||||
// Не критично — оставляем прошлый статус (или null → баннер скрыт).
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
frozen,
|
||||
deficitRub,
|
||||
deficitLeads,
|
||||
balanceRub,
|
||||
capacityLeads,
|
||||
requiredLeadsPerDay,
|
||||
load,
|
||||
};
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import BalanceCard from '../components/billing/BalanceCard.vue';
|
||||
import BalanceCapacityIndicator from '../components/billing/BalanceCapacityIndicator.vue';
|
||||
import TierPricesPanel from '../components/billing/TierPricesPanel.vue';
|
||||
import TransactionsTable from '../components/billing/TransactionsTable.vue';
|
||||
import InvoicesTable from '../components/billing/InvoicesTable.vue';
|
||||
@@ -19,8 +20,10 @@ import ChargesTab from './billing/ChargesTab.vue';
|
||||
import { formatPlain, featureLabel } from '../composables/billingFormatters';
|
||||
import { getWallet, type Wallet } from '../api/billing';
|
||||
import { extractErrorMessage } from '../api/client';
|
||||
import { useTenantStore } from '../stores/tenantStore';
|
||||
|
||||
const activeView = ref<'overview' | 'charges'>('overview');
|
||||
const tenant = useTenantStore();
|
||||
|
||||
const wallet = ref<Wallet | null>(null);
|
||||
const loading = ref(true);
|
||||
@@ -59,10 +62,15 @@ async function onTopupSuccess(): Promise<void> {
|
||||
topupOpen.value = false;
|
||||
topupSnackbar.value = true;
|
||||
await loadWallet();
|
||||
// Пополнение могло снять заморозку → обновляем статус баланса (баннер/индикатор).
|
||||
await tenant.load();
|
||||
txTableRef.value?.refresh();
|
||||
}
|
||||
|
||||
onMounted(loadWallet);
|
||||
onMounted(() => {
|
||||
void loadWallet();
|
||||
void tenant.load();
|
||||
});
|
||||
|
||||
defineExpose({ loadWallet, wallet, topupOpen });
|
||||
</script>
|
||||
@@ -117,6 +125,14 @@ defineExpose({ loadWallet, wallet, topupOpen });
|
||||
@topup="topupOpen = true"
|
||||
/>
|
||||
|
||||
<BalanceCapacityIndicator
|
||||
v-if="tenant.status"
|
||||
class="mt-3"
|
||||
:balance-rub="tenant.balanceRub"
|
||||
:capacity-leads="tenant.capacityLeads"
|
||||
:required-leads-per-day="tenant.requiredLeadsPerDay"
|
||||
/>
|
||||
|
||||
<TierPricesPanel :tiers="tiersPreview" :current-tier-no="currentTierNo" />
|
||||
|
||||
<TransactionsTable ref="txTableRef" />
|
||||
|
||||
@@ -190,6 +190,13 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<ProjectLimitOverloadDialog
|
||||
v-model="overloadOpen"
|
||||
:payload="overloadPayload"
|
||||
@save-blocked="onOverloadSaveBlocked"
|
||||
@set-zero="onOverloadSetZero"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -199,6 +206,7 @@ import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
|
||||
import type { Project } from '../../stores/projectsStore';
|
||||
import DevIndexBadge from '../../components/DevIndexBadge.vue';
|
||||
import ProjectLimitOverloadDialog from '../../components/projects/ProjectLimitOverloadDialog.vue';
|
||||
|
||||
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
|
||||
|
||||
@@ -225,6 +233,16 @@ const errors = reactive<Record<string, string[]>>({});
|
||||
const saving = ref(false);
|
||||
const generalError = ref<string | null>(null);
|
||||
|
||||
// Spec C §6.2: префлайт баланса — диалог перегрузки лимита по 409.
|
||||
interface OverloadPayloadShape {
|
||||
current_balance_rub: string;
|
||||
current_capacity_leads: number;
|
||||
would_be_required_leads: number;
|
||||
deficit_leads: number;
|
||||
}
|
||||
const overloadOpen = ref(false);
|
||||
const overloadPayload = ref<OverloadPayloadShape | null>(null);
|
||||
|
||||
// Plan 4 Task 4: обязательный выбор региона + явная «Вся РФ» с подтверждением.
|
||||
// vsyaRf — чекбокс выбран; vsyaRfConfirmed — подтверждён через предупреждение.
|
||||
// На бэке regions=[] (Вся РФ) и «забыл» неотличимы → гейт намеренно UI-only.
|
||||
@@ -303,6 +321,37 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function persist(extra: Record<string, unknown> = {}): Promise<void> {
|
||||
saving.value = true;
|
||||
try {
|
||||
await ensureCsrfCookie();
|
||||
const body = { ...form, ...extra };
|
||||
if (props.mode === 'edit' && props.project) {
|
||||
await apiClient.patch(`/api/projects/${props.project.id}`, body);
|
||||
} else {
|
||||
await apiClient.post('/api/projects', body);
|
||||
}
|
||||
overloadOpen.value = false;
|
||||
emit('saved');
|
||||
close();
|
||||
} catch (e: unknown) {
|
||||
const err = e as {
|
||||
response?: { status?: number; data?: { error?: string; errors?: Record<string, string[]> } };
|
||||
};
|
||||
// Spec C §6.2: лимит превышает баланс — открываем диалог перегрузки.
|
||||
if (err.response?.status === 409 && err.response.data?.error === 'balance_insufficient') {
|
||||
overloadPayload.value = err.response.data as OverloadPayloadShape;
|
||||
overloadOpen.value = true;
|
||||
} else if (err.response?.status === 422 && err.response.data?.errors) {
|
||||
Object.assign(errors, err.response.data.errors);
|
||||
} else {
|
||||
generalError.value = extractErrorMessage(e);
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
generalError.value = null;
|
||||
Object.keys(errors).forEach((k) => delete errors[k]);
|
||||
@@ -313,26 +362,18 @@ async function submit() {
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await ensureCsrfCookie();
|
||||
if (props.mode === 'edit' && props.project) {
|
||||
await apiClient.patch(`/api/projects/${props.project.id}`, { ...form });
|
||||
} else {
|
||||
await apiClient.post('/api/projects', { ...form });
|
||||
}
|
||||
emit('saved');
|
||||
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);
|
||||
} else {
|
||||
generalError.value = extractErrorMessage(e);
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
await persist();
|
||||
}
|
||||
|
||||
// Spec C §6.2 — исходы диалога перегрузки лимита.
|
||||
async function onOverloadSaveBlocked(): Promise<void> {
|
||||
await persist({ force_save_blocked: true });
|
||||
}
|
||||
|
||||
async function onOverloadSetZero(): Promise<void> {
|
||||
form.daily_limit_target = 0;
|
||||
overloadOpen.value = false;
|
||||
await persist();
|
||||
}
|
||||
|
||||
function close() {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
Здравствуйте, {{ $tenant->organization_name }}!
|
||||
|
||||
Приём лидов по вашим проектам приостановлен — на счёте недостаточно средств
|
||||
на завтрашний дневной заказ.
|
||||
|
||||
Сейчас баланса хватает на {{ $result->capacityLeads }} лидов, а ваши проекты
|
||||
заказывают {{ $result->requiredLeads }} лидов в день. Не хватает: {{ $result->deficitLeads }} лидов.
|
||||
|
||||
Чтобы возобновить приём, пополните счёт или уменьшите дневные лимиты проектов
|
||||
до 18:00 по московскому времени.
|
||||
|
||||
С уважением, команда Лидерра.
|
||||
@@ -0,0 +1,11 @@
|
||||
Здравствуйте, {{ $tenant->organization_name }}!
|
||||
|
||||
Приём лидов по вашим проектам приостановлен уже 3 дня из-за нехватки баланса.
|
||||
Это последнее напоминание.
|
||||
|
||||
Баланса хватает на {{ $result->capacityLeads }} лидов, проекты заказывают
|
||||
{{ $result->requiredLeads }} в день. Не хватает: {{ $result->deficitLeads }} лидов.
|
||||
|
||||
Чтобы возобновить приём — пополните счёт или уменьшите лимиты проектов.
|
||||
|
||||
С уважением, команда Лидерра.
|
||||
@@ -0,0 +1,10 @@
|
||||
Здравствуйте, {{ $tenant->organization_name }}!
|
||||
|
||||
Напоминаем: приём лидов по вашим проектам всё ещё приостановлен из-за нехватки баланса.
|
||||
|
||||
Баланса хватает на {{ $result->capacityLeads }} лидов, проекты заказывают
|
||||
{{ $result->requiredLeads }} в день. Не хватает: {{ $result->deficitLeads }} лидов.
|
||||
|
||||
Пополните счёт или уменьшите лимиты до 18:00 МСК, чтобы возобновить приём.
|
||||
|
||||
С уважением, команда Лидерра.
|
||||
@@ -0,0 +1,10 @@
|
||||
Здравствуйте, {{ $tenant->organization_name }}!
|
||||
|
||||
Приём лидов по вашим проектам возобновлён — баланса снова хватает на дневной заказ.
|
||||
|
||||
Текущий запас: {{ $result->capacityLeads }} лидов, проекты заказывают
|
||||
{{ $result->requiredLeads }} в день.
|
||||
|
||||
Заказ поставщику будет сформирован в ближайшем вечернем цикле (18:00 МСК).
|
||||
|
||||
С уважением, команда Лидерра.
|
||||
+22
-1
@@ -64,6 +64,25 @@ Schedule::command('partitions:drop-expired')
|
||||
->onSuccess(fn () => $hb->recordRunResult('partitions:drop-expired', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('partitions:drop-expired', false, 'Command failed', null));
|
||||
|
||||
// Billing v2 Spec C §3.2: преfflight баланса в 18:00 MSK — заморозка/разморозка
|
||||
// тенантов перед формированием заказа поставщику (без «бедных» клиентов).
|
||||
// ВАЖНО: идёт ДО SyncSupplierProjectsJob (сдвинут на 18:05) — фильтр frozen-проектов
|
||||
// должен примениться к расчёту заказа того же вечера.
|
||||
Schedule::command('billing:preflight-sweep')
|
||||
->dailyAt('18:00')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('billing:preflight-sweep', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('billing:preflight-sweep', false, 'Command failed', null));
|
||||
|
||||
// Billing v2 Spec C §3.7: повторные письма заморозки (reminder +1д, final +3д).
|
||||
// Идёт ПОСЛЕ основного sweep — если sweep только что заморозил тенанта, окно reminder
|
||||
// (24h+) ещё не открылось, повторного письма в тот же день не будет (correct).
|
||||
Schedule::command('billing:frozen-reminder')
|
||||
->dailyAt('18:30')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('billing:frozen-reminder', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('billing:frozen-reminder', false, 'Command failed', null));
|
||||
|
||||
// Plan 3 Task 8: 5 Schedule entries для supplier-flow.
|
||||
//
|
||||
// NB: ->onOneServer() требует cache_locks таблицу, которой у нас нет
|
||||
@@ -83,8 +102,10 @@ 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));
|
||||
// Billing v2 Spec C: сдвинут 18:00 → 18:05, чтобы billing:preflight-sweep (18:00)
|
||||
// успел проставить frozen-флаги до формирования заказа поставщику.
|
||||
Schedule::job(new SyncSupplierProjectsJob)
|
||||
->dailyAt('18:00')
|
||||
->dailyAt('18:05')
|
||||
->timezone('Europe/Moscow')
|
||||
->onSuccess(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', true, null, null))
|
||||
->onFailure(fn () => $hb->recordRunResult('App\Jobs\Supplier\SyncSupplierProjectsJob', false, 'Job failed', null));
|
||||
|
||||
@@ -189,6 +189,7 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->g
|
||||
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(function () {
|
||||
Route::post('/topup', 'App\Http\Controllers\Api\BillingController@topup');
|
||||
Route::get('/wallet', 'App\Http\Controllers\Api\BillingController@wallet');
|
||||
Route::get('/balance-status', 'App\Http\Controllers\Api\BillingController@balanceStatus');
|
||||
Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions');
|
||||
Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\BalanceFrozenMail;
|
||||
use App\Mail\BalanceUnfrozenMail;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\PreflightResult;
|
||||
|
||||
it('renders frozen mail with deficit', function () {
|
||||
$tenant = Tenant::factory()->create(['organization_name' => 'ООО Альфа']);
|
||||
$result = new PreflightResult(false, 30, 20, 10);
|
||||
|
||||
$rendered = (new BalanceFrozenMail($tenant, $result))->render();
|
||||
|
||||
expect($rendered)->toContain('приостановлен');
|
||||
expect($rendered)->toContain('10'); // дефицит лидов
|
||||
expect($rendered)->toContain('ООО Альфа');
|
||||
});
|
||||
|
||||
it('renders unfrozen mail', function () {
|
||||
$tenant = Tenant::factory()->create(['organization_name' => 'ООО Бета']);
|
||||
$result = new PreflightResult(true, 25, 40, 0);
|
||||
|
||||
$rendered = (new BalanceUnfrozenMail($tenant, $result))->render();
|
||||
|
||||
expect($rendered)->toContain('возобновлён');
|
||||
expect($rendered)->toContain('ООО Бета');
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Billing\BalanceFrozenReminderJob;
|
||||
use App\Mail\BalanceFrozenFinalMail;
|
||||
use App\Mail\BalanceFrozenReminderMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// Изоляция: liderra_testing persistent — DatabaseTransactions откатывает default-pgsql,
|
||||
// SharesSupplierPdo делает pgsql_supplier общим PDO (паттерн Спека B / Task 1.4).
|
||||
// Per-tenant Mail-фильтры обязательны — DemoSeeder тенанты могут попасть в sweep
|
||||
// (прецедент idempotent-fixup 55a1bc05).
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// RLS-контекст (системный tenant 0).
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
PricingTier::query()->create([
|
||||
'tier_no' => 1,
|
||||
'leads_in_tier' => null,
|
||||
'price_per_lead_kopecks' => 5000,
|
||||
'is_active' => true,
|
||||
'effective_from' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends reminder ~1 day after freeze', function () {
|
||||
Mail::fake();
|
||||
// frozen 25h назад — попадает в окно reminder (24-48h).
|
||||
Carbon::setTestNow('2026-05-25 12:00:00');
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '0.00',
|
||||
'frozen_by_balance_at' => Carbon::now()->subHours(25),
|
||||
]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
|
||||
Mail::assertQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
Mail::assertNotQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
|
||||
it('sends final ~3 days after freeze', function () {
|
||||
Mail::fake();
|
||||
// frozen 73h назад — попадает в окно final (72-96h).
|
||||
Carbon::setTestNow('2026-05-25 12:00:00');
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '0.00',
|
||||
'frozen_by_balance_at' => Carbon::now()->subHours(73),
|
||||
]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
|
||||
Mail::assertQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
|
||||
it('sends nothing for freshly frozen tenant', function () {
|
||||
Mail::fake();
|
||||
// frozen 2h назад — окно ещё не открылось.
|
||||
Carbon::setTestNow('2026-05-25 12:00:00');
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '0.00',
|
||||
'frozen_by_balance_at' => Carbon::now()->subHours(2),
|
||||
]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
|
||||
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
Mail::assertNotQueued(BalanceFrozenFinalMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
|
||||
it('is throttled — does not re-send reminder for same tenant in window', function () {
|
||||
Mail::fake();
|
||||
Carbon::setTestNow('2026-05-25 12:00:00');
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '0.00',
|
||||
'frozen_by_balance_at' => Carbon::now()->subHours(25),
|
||||
]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
// Первый прогон — отправляет reminder.
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
Mail::assertQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
|
||||
// Сброс fake и второй прогон в том же окне — повторного письма быть не должно.
|
||||
Mail::fake();
|
||||
(new BalanceFrozenReminderJob)->handle();
|
||||
Mail::assertNotQueued(BalanceFrozenReminderMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Billing\BalancePreflightSweepJob;
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Mail\BalanceFrozenMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// Изоляция: liderra_testing persistent (RefreshDatabase off). DatabaseTransactions
|
||||
// откатывает default-pgsql после каждого теста; SharesSupplierPdo делает pgsql_supplier
|
||||
// общим PDO с pgsql — иначе job-запись balance_freeze_log (pgsql_supplier) не видит
|
||||
// незакоммиченного tenant и падает на FK (паттерн Спека B / AutoPauseFlowTest).
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// RLS-контекст (системный tenant 0) — паттерн supplier-тестов.
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
PricingTier::query()->create(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true, 'effective_from' => now()]);
|
||||
});
|
||||
|
||||
it('freezes tenant whose balance no longer covers daily limit', function () {
|
||||
Mail::fake();
|
||||
// 500₽ / 50₽ = 10 лидов; проекты хотят 25 → заморозка.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
|
||||
Mail::assertQueued(BalanceFrozenMail::class);
|
||||
});
|
||||
|
||||
it('unfreezes tenant whose balance now covers daily limit', function () {
|
||||
Mail::fake();
|
||||
// 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('is idempotent — does not re-freeze already frozen tenant', function () {
|
||||
Mail::fake();
|
||||
$frozenAt = now()->subDay();
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => $frozenAt]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
// Дата заморозки не перезаписана; для ЭТОГО tenant повторного письма нет.
|
||||
// NB: per-tenant фильтр, т.к. liderra_testing persistent (DemoSeeder тенанты
|
||||
// могут попасть в sweep и тоже получить BalanceFrozenMail — не наш ответ).
|
||||
expect($tenant->fresh()->frozen_by_balance_at->timestamp)->toBe($frozenAt->timestamp);
|
||||
Mail::assertNotQueued(BalanceFrozenMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
|
||||
// Spec C extension (26.05.2026): freeze/unfreeze дёргают supplier sync в режиме 'online'.
|
||||
// Привязка к существующему админ-переключателю SupplierExportMode (system_settings.supplier_export_mode).
|
||||
// Online нужен сейчас для отладки (моментальный sync с поставщиком); batch будет рабочим режимом
|
||||
// при росте числа клиентов (накопленные изменения уезжают одним cut-off-cron'ом в 18:00 MSK).
|
||||
|
||||
it('dispatches SyncSupplierProjectJob for each active project on freeze when supplier mode is online', function () {
|
||||
Mail::fake();
|
||||
Queue::fake();
|
||||
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']);
|
||||
|
||||
// 500₽ / 50₽ = 10 лидов; 2 проекта по 15 = 30 → заморозка.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
|
||||
$p1 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
|
||||
$p2 = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
|
||||
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p1->id);
|
||||
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $p2->id);
|
||||
});
|
||||
|
||||
it('does NOT dispatch SyncSupplierProjectJob on freeze when supplier mode is batch', function () {
|
||||
Mail::fake();
|
||||
Queue::fake();
|
||||
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'batch']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
|
||||
// batch-режим: sync с поставщиком отложен до cut-off 18:00 MSK через SyncSupplierProjectsJob (множественный).
|
||||
Queue::assertNotPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === Project::query()->where('tenant_id', $tenant->id)->value('id'));
|
||||
});
|
||||
|
||||
it('dispatches SyncSupplierProjectJob on unfreeze when supplier mode is online', function () {
|
||||
Mail::fake();
|
||||
Queue::fake();
|
||||
DB::table('system_settings')->updateOrInsert(['key' => 'supplier_export_mode'], ['value' => 'online']);
|
||||
|
||||
// 2000₽ / 50₽ = 40 лидов; хотят 25 → разморозка.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00', 'frozen_by_balance_at' => now()->subDay()]);
|
||||
$project = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
(new BalancePreflightSweepJob)->handle();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->toBeNull();
|
||||
Queue::assertPushed(SyncSupplierProjectJob::class, fn (SyncSupplierProjectJob $job) => $job->projectId === $project->id);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* GET /api/billing/balance-status — статус баланса для UI префлайта (Billing v2
|
||||
* Spec C Task 1.10): питает баннер заморозки (BalanceFrozenBanner) и индикатор
|
||||
* ёмкости (BalanceCapacityIndicator).
|
||||
*
|
||||
* PricingTierSeeder: ступень 1 — 100 лидов × 500₽ (см. BillingOverviewControllerTest).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '5000.00',
|
||||
'delivered_in_month' => 0,
|
||||
'frozen_by_balance_at' => null,
|
||||
]);
|
||||
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
test('GET /api/billing/balance-status: структура ответа', function () {
|
||||
$this->getJson('/api/billing/balance-status')
|
||||
->assertOk()
|
||||
->assertJsonStructure([
|
||||
'frozen_by_balance_at',
|
||||
'balance_rub',
|
||||
'capacity_leads',
|
||||
'required_leads_per_day',
|
||||
'deficit_leads',
|
||||
'deficit_rub',
|
||||
]);
|
||||
});
|
||||
|
||||
test('balance-status: хватает баланса — deficit=0, не заморожен', function () {
|
||||
// 5000₽ при ступени 1 (500₽/лид) = 10 лидов ёмкости. Проект лимит 5 — впритык.
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 5,
|
||||
]);
|
||||
|
||||
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
|
||||
|
||||
expect($resp->json('balance_rub'))->toBe('5000.00');
|
||||
expect($resp->json('capacity_leads'))->toBe(10);
|
||||
expect($resp->json('required_leads_per_day'))->toBe(5);
|
||||
expect($resp->json('deficit_leads'))->toBe(0);
|
||||
expect($resp->json('deficit_rub'))->toBe('0.00');
|
||||
expect($resp->json('frozen_by_balance_at'))->toBeNull();
|
||||
});
|
||||
|
||||
test('balance-status: не хватает — deficit_leads + deficit_rub точные', function () {
|
||||
// Ёмкость = 10 лидов. Проект лимит 25 → нужно 25, дефицит 15 лидов.
|
||||
// minBalanceForLeads(25) = 25 × 500₽ = 12500₽ → deficit_rub = 12500 − 5000 = 7500.00.
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 25,
|
||||
]);
|
||||
|
||||
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
|
||||
|
||||
expect($resp->json('capacity_leads'))->toBe(10);
|
||||
expect($resp->json('required_leads_per_day'))->toBe(25);
|
||||
expect($resp->json('deficit_leads'))->toBe(15);
|
||||
expect($resp->json('deficit_rub'))->toBe('7500.00');
|
||||
});
|
||||
|
||||
test('balance-status: required исключает inactive и preflight_blocked проекты', function () {
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'is_active' => true, 'daily_limit_target' => 5,
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'is_active' => false, 'daily_limit_target' => 100,
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id, 'is_active' => true, 'daily_limit_target' => 100,
|
||||
'preflight_blocked_at' => now(),
|
||||
]);
|
||||
|
||||
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
|
||||
|
||||
expect($resp->json('required_leads_per_day'))->toBe(5);
|
||||
});
|
||||
|
||||
test('balance-status: возвращает frozen_by_balance_at когда установлен', function () {
|
||||
$this->tenant->update(['frozen_by_balance_at' => now()]);
|
||||
|
||||
$resp = $this->getJson('/api/billing/balance-status')->assertOk();
|
||||
|
||||
expect($resp->json('frozen_by_balance_at'))->not->toBeNull();
|
||||
});
|
||||
|
||||
test('GET /api/billing/balance-status без auth: 401', function () {
|
||||
auth()->logout();
|
||||
$this->getJson('/api/billing/balance-status')->assertStatus(401);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Mail\BalanceFrozenMail;
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
PricingTier::query()->create([
|
||||
'tier_no' => 1,
|
||||
'leads_in_tier' => null,
|
||||
'price_per_lead_kopecks' => 5000,
|
||||
'is_active' => true,
|
||||
'effective_from' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('freezes pre-existing underfunded tenant on first run', function () {
|
||||
Mail::fake();
|
||||
// 0₽ + проекты на 25 лидов → должен быть заморожен.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '0.00', 'frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 25]);
|
||||
|
||||
$this->artisan('billing:preflight-initial-sweep')->assertSuccessful();
|
||||
|
||||
expect($tenant->fresh()->frozen_by_balance_at)->not->toBeNull();
|
||||
Mail::assertQueued(BalanceFrozenMail::class, fn ($mail) => $mail->tenant->id === $tenant->id);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
PricingTier::query()->create([
|
||||
'tier_no' => 1,
|
||||
'leads_in_tier' => null,
|
||||
'price_per_lead_kopecks' => 5000, // 50₽/лид — capacity = balance/50
|
||||
'is_active' => true,
|
||||
'effective_from' => now(),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns 409 when new project would overload balance', function () {
|
||||
// 1000₽ / 50₽ = 20 лидов capacity; запрашиваем daily_limit_target=30 → дефицит 10.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'Перегрузка',
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'overload.ru',
|
||||
'daily_limit_target' => 30,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$response->assertStatus(409);
|
||||
$response->assertJsonPath('error', 'balance_insufficient');
|
||||
$response->assertJsonPath('deficit_leads', 10);
|
||||
$response->assertJsonPath('current_capacity_leads', 20);
|
||||
$response->assertJsonPath('would_be_required_leads', 30);
|
||||
expect(Project::where('signal_identifier', 'overload.ru')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('creates blocked project when force_save_blocked=true', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'Заблокированный',
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'force-blocked.ru',
|
||||
'daily_limit_target' => 30,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
'force_save_blocked' => true,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$project = Project::where('signal_identifier', 'force-blocked.ru')->first();
|
||||
expect($project)->not->toBeNull();
|
||||
expect($project->preflight_blocked_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('creates normally when within balance', function () {
|
||||
// 2000₽ / 50₽ = 40 лидов capacity; daily_limit_target=30 — passes.
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '2000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/projects', [
|
||||
'name' => 'Норма',
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'ok-balance.ru',
|
||||
'daily_limit_target' => 30,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$project = Project::where('signal_identifier', 'ok-balance.ru')->first();
|
||||
expect($project->preflight_blocked_at)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns 409 on update when increased limit overloads balance', function () {
|
||||
// существующий проект на 15 лидов, всё ок (capacity 20).
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->for($tenant)->create([
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 15,
|
||||
]);
|
||||
|
||||
// UPDATE до 30 → suma 30 > capacity 20 → 409.
|
||||
$response = $this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'daily_limit_target' => 30,
|
||||
]);
|
||||
|
||||
$response->assertStatus(409);
|
||||
$response->assertJsonPath('error', 'balance_insufficient');
|
||||
expect($project->fresh()->daily_limit_target)->toBe(15); // не изменилось
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
it('sums daily_limit_target of active projects for required leads', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '1000.00']);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 10]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 15]);
|
||||
Project::factory()->for($tenant)->create(['is_active' => false, 'daily_limit_target' => 100]); // не считается
|
||||
|
||||
expect($tenant->requiredLeadsForTomorrow())->toBe(25);
|
||||
});
|
||||
|
||||
it('casts frozen_by_balance_at to datetime', function () {
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
|
||||
expect($tenant->frozen_by_balance_at)->toBeInstanceOf(Carbon::class);
|
||||
});
|
||||
|
||||
it('casts project preflight_blocked_at to datetime', function () {
|
||||
$project = Project::factory()->create(['preflight_blocked_at' => now()]);
|
||||
expect($project->preflight_blocked_at)->toBeInstanceOf(Carbon::class);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SystemSetting;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
beforeEach(function () {
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_webhook_secret')
|
||||
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '[]']);
|
||||
});
|
||||
|
||||
it('returns 422 JSON when supplier posts invalid payload WITHOUT Accept: application/json header', function () {
|
||||
// Воспроизводит реальное поведение crm.bp-gr.ru: POST без Accept-JSON.
|
||||
// До фикса (302→422) Laravel редиректил на / с Set-Cookie, поставщик
|
||||
// терял тело запроса. После фикса всегда JSON.
|
||||
$response = $this->call(
|
||||
'POST',
|
||||
'/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa',
|
||||
[], // params
|
||||
[], // cookies
|
||||
[], // files
|
||||
['HTTP_CONTENT_TYPE' => 'application/x-www-form-urlencoded'], // server: НЕТ Accept JSON
|
||||
http_build_query([
|
||||
'vid' => 1,
|
||||
'project' => 'invalid_no_b_prefix',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
])
|
||||
);
|
||||
|
||||
$response->assertStatus(422);
|
||||
expect($response->headers->get('Content-Type'))->toContain('application/json');
|
||||
$response->assertJsonStructure(['message', 'errors' => ['project']]);
|
||||
});
|
||||
|
||||
it('still works correctly for postJson clients (regression)', function () {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 1,
|
||||
'project' => 'invalid_no_b_prefix',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)->assertJsonValidationErrors('project');
|
||||
});
|
||||
|
||||
it('non-webhook routes still use default render (no JSON forced)', function () {
|
||||
// Регрессионный тест: дефолтный render остальных routes не сломан
|
||||
// (например /login — должен возвращать redirect, а не JSON).
|
||||
$response = $this->call(
|
||||
'POST',
|
||||
'/login',
|
||||
['email' => 'bad', 'password' => ''],
|
||||
[], [], [],
|
||||
);
|
||||
// Любой не-200 кроме 422-JSON допустим — главное чтобы наш fix не перехватил
|
||||
expect($response->headers->get('Content-Type'))->not->toContain('application/json');
|
||||
});
|
||||
@@ -532,3 +532,94 @@ it('caps deal creation at 3 recipients and tags deal with subject from payload',
|
||||
expect($deals)->toHaveCount(3)
|
||||
->and($deals->pluck('subject_code')->unique()->all())->toBe([82]);
|
||||
});
|
||||
|
||||
it('merges webhook into csv-recovered deal even when received_at differs (Phase 2 FK fix)', function (): void {
|
||||
// Регрессия 26.05.2026 04:12-05:03 UTC: 9 RouteSupplierLeadJob упали с
|
||||
// SQLSTATE 23503 (FK violation) при попытке Phase 2 merge обновить deals.received_at.
|
||||
// Причина — lead_charges имеет FK на (deal_id, deal_received_at) с
|
||||
// ON DELETE CASCADE, но ON UPDATE NO ACTION (default). Даже DEFERRABLE INITIALLY
|
||||
// DEFERRED не помогает — проверка падает на COMMIT. Фикс: оставить received_at
|
||||
// CSV-recovered deal'а нетронутым (отличие на минуты несущественно).
|
||||
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'phase2-merge.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_rub' => '100000.00']);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'phase2-merge.ru',
|
||||
'is_active' => true,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
// CSV-recovered deal: source_crm_id=NULL, received_at в прошлом.
|
||||
$csvReceivedAt = now()->subMinutes(15);
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$csvDeal = Deal::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'source_crm_id' => null,
|
||||
'project_id' => $project->id,
|
||||
'phone' => '79991234567',
|
||||
'phones' => ['79991234567'],
|
||||
'status' => 'new',
|
||||
'received_at' => $csvReceivedAt,
|
||||
]);
|
||||
|
||||
// LeadCharge на CSV-recovered deal — это что триггерит FK при UPDATE received_at.
|
||||
\App\Models\LeadCharge::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'deal_id' => $csvDeal->id,
|
||||
'deal_received_at' => $csvDeal->received_at,
|
||||
'charge_source' => 'rub',
|
||||
]);
|
||||
|
||||
// Webhook lead: реальный vid, тот же phone+project, received_at позже CSV.
|
||||
$webhookVid = 999111;
|
||||
$webhookReceivedAt = now(); // > csvReceivedAt → старый код триггерил UPDATE received_at.
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'vid' => $webhookVid,
|
||||
'phone' => '79991234567',
|
||||
'received_at' => $webhookReceivedAt,
|
||||
'raw_payload' => [
|
||||
'vid' => $webhookVid,
|
||||
'project' => 'B1_phase2-merge.ru',
|
||||
'phone' => '79991234567',
|
||||
'phones' => ['79991234567'],
|
||||
'time' => $webhookReceivedAt->getTimestamp(),
|
||||
],
|
||||
]);
|
||||
|
||||
// Не должно бросать FK violation — merge обновляет ТОЛЬКО source_crm_id.
|
||||
runRouteJob($lead->id);
|
||||
|
||||
$lead->refresh();
|
||||
expect($lead->processed_at)->not->toBeNull();
|
||||
|
||||
// Deal обновлён: source_crm_id заполнен webhook vid, received_at не тронут.
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$merged = Deal::query()
|
||||
->whereKey($csvDeal->id)
|
||||
->where('received_at', $csvReceivedAt)
|
||||
->first();
|
||||
expect($merged)->not->toBeNull();
|
||||
expect($merged->source_crm_id)->toBe($webhookVid);
|
||||
|
||||
// Без второго списания — balance не изменился (chargeForDelivery в merge-ветке не вызывается).
|
||||
expect((string) $tenant->fresh()->balance_rub)->toBe('100000.00');
|
||||
|
||||
// supplier_lead_deliveries — линк создан.
|
||||
$deliveryCount = DB::table('supplier_lead_deliveries')
|
||||
->where('supplier_lead_id', $lead->id)
|
||||
->where('tenant_id', $tenant->id)
|
||||
->count();
|
||||
expect($deliveryCount)->toBe(1);
|
||||
|
||||
// Никаких дублей deals — только один с этим vid.
|
||||
expect(Deal::query()->where('source_crm_id', $webhookVid)->count())->toBe(1);
|
||||
});
|
||||
|
||||
@@ -12,7 +12,9 @@ use Illuminate\Console\Scheduling\Schedule;
|
||||
* Session refresh — на 15 мин раньше sync (17:45).
|
||||
*/
|
||||
|
||||
it('SyncSupplierProjectsJob is scheduled at 18:00 MSK', function (): void {
|
||||
it('SyncSupplierProjectsJob is scheduled at 18:05 MSK (after billing:preflight-sweep @18:00)', function (): void {
|
||||
// Billing v2 Spec C §3.8: sync сдвинут с 18:00 на 18:05, чтобы
|
||||
// billing:preflight-sweep (18:00) успел проставить frozen-флаги.
|
||||
$schedule = app(Schedule::class);
|
||||
$events = collect($schedule->events());
|
||||
|
||||
@@ -20,7 +22,7 @@ it('SyncSupplierProjectsJob is scheduled at 18:00 MSK', function (): void {
|
||||
|| str_contains((string) $e->command, 'SyncSupplierProjectsJob'));
|
||||
|
||||
expect($sync)->not->toBeNull();
|
||||
expect($sync->expression)->toBe('0 18 * * *');
|
||||
expect($sync->expression)->toBe('5 18 * * *');
|
||||
expect($sync->timezone)->toBe('Europe/Moscow');
|
||||
});
|
||||
|
||||
|
||||
@@ -272,14 +272,16 @@ it('unparseable CSV rows excluded from drift: 100 matched + 10 junk-project rows
|
||||
]);
|
||||
}
|
||||
|
||||
// CSV: те же 100 (matched) + 10 строк с мусорным project (extractPlatform = null).
|
||||
// Это реальный паттерн поставщика — телефон в поле «Name» вместо проекта (см. 22.05 в ПИЛОТ).
|
||||
// CSV: те же 100 (matched) + 10 строк с настоящим мусорным project (extractPlatform = null).
|
||||
// Phase 3 (2026-05-25): расширили DIRECT-распознавание — теперь цифровые callback-проекты
|
||||
// (79135551234) — валидный DIRECT, не junk. Реальный junk — это символы вне whitelist regex.
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79993'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 10; $j++) {
|
||||
$rows[] = ['project' => '79135551234', 'phone' => '7999500000'.$j];
|
||||
$junkProjects = ['???', '!@#', '%%%', '$$$', '???!!!', '~~~', '***', '|||', '^^^', '&&&'];
|
||||
foreach ($junkProjects as $j => $junk) {
|
||||
$rows[] = ['project' => $junk, 'phone' => '7999500000'.$j];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
@@ -314,8 +316,10 @@ it('mixed: 95 matched + 5 junk + 3 real-missing → unparseable_count=5, recover
|
||||
for ($i = 0; $i < 95; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79994'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
for ($j = 0; $j < 5; $j++) {
|
||||
$rows[] = ['project' => 'https://junk.example/'.$j, 'phone' => '7999600000'.$j];
|
||||
// Phase 3: реальный junk — символы вне whitelist (не \w/.-/cyrillic/digits/slash/parens/space/plus).
|
||||
$junkProjects = ['???', '!!!@@@', '%%%', '****', '???!!!'];
|
||||
foreach ($junkProjects as $j => $junk) {
|
||||
$rows[] = ['project' => $junk, 'phone' => '7999600000'.$j];
|
||||
}
|
||||
for ($k = 0; $k < 3; $k++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '7999700000'.$k];
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\LeadCharge;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Phase 2 — webhook ↔ CSV-recovered idempotency.
|
||||
*
|
||||
* Сценарий (наблюдался на prod 2026-05-25, 37 дублей tenant client1):
|
||||
* 1. Поставщик шлёт webhook → 302 (теряется тело) — Phase 1 уже починила.
|
||||
* 2. CsvReconcileJob через 30 мин видит лид в CSV, не находит supplier_lead
|
||||
* по (phone, project) → создаёт recovered SupplierLead (vid=NULL,
|
||||
* source='csv_recovery') → RouteSupplierLeadJob → Deal с source_crm_id=NULL.
|
||||
* 3. Поставщик ретраит webhook (ещё 15 мин) → новый SupplierLead с vid=<int>
|
||||
* → RouteSupplierLeadJob → создаёт второй Deal с тем же phone+project
|
||||
* → биллинг списывает второй раз.
|
||||
*
|
||||
* Phase 2 fix: шаг 3 находит существующий CSV-recovered deal, обновляет
|
||||
* source_crm_id, привязывает webhook supplier_lead к существующему deal через
|
||||
* supplier_lead_deliveries, НЕ создаёт второй Deal, НЕ списывает повторно.
|
||||
*/
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
|
||||
// Shared supplier_project для всех тестов (B1, site, domain race-csv.ru).
|
||||
$this->sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'race-csv.ru',
|
||||
]);
|
||||
|
||||
$this->tenant = Tenant::factory()->create([
|
||||
'balance_rub' => '10000.00',
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
|
||||
$this->project = Project::factory()->create([
|
||||
'tenant_id' => $this->tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'race-csv.ru',
|
||||
'supplier_b1_project_id' => $this->sp->id,
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 100,
|
||||
'effective_daily_limit_today' => 100,
|
||||
'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
linkProjectToSupplier($this->project, $this->sp);
|
||||
});
|
||||
|
||||
/**
|
||||
* Dispatch helper — mirrors runRouteJob() / dispatchJob() from other test files.
|
||||
*/
|
||||
function runRaceJob(int $supplierLeadId): void
|
||||
{
|
||||
(new RouteSupplierLeadJob($supplierLeadId))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1 — Main bug reproduction: CSV-recovery followed by webhook retry
|
||||
// ДОЛЖЕН дать 1 deal + 1 charge (сейчас даёт 2+2 → FAILING).
|
||||
// ---------------------------------------------------------------------------
|
||||
it('webhook after CSV-recovered merges into existing deal (no duplicate, no double-charge)', function (): void {
|
||||
$phone = '79991000001';
|
||||
|
||||
// ── Step 1: CSV-recovered SupplierLead (vid=null, source='csv_recovery') ──
|
||||
// Это то, что CsvReconcileJob создаёт: звонок найден в CSV поставщика,
|
||||
// но настоящего webhook_log'а нет → вид неизвестен (vid=null).
|
||||
$csvLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => null,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subHour()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subHour(),
|
||||
'recovered_from_csv_at' => now()->subHour(),
|
||||
'source' => 'csv_recovery',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
// RouteSupplierLeadJob обрабатывает CSV-recovered лид → создаёт Deal с source_crm_id=NULL.
|
||||
runRaceJob($csvLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
$csvDeal = Deal::where('phone', $phone)->first();
|
||||
expect($csvDeal)->not->toBeNull('CSV recovery должен был создать Deal');
|
||||
expect($csvDeal->source_crm_id)->toBeNull('CSV-recovered deal должен иметь source_crm_id=NULL');
|
||||
|
||||
$chargesAfterCsv = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||||
expect($chargesAfterCsv)->toBe(1, 'После CSV-recovery должен быть ровно 1 LeadCharge');
|
||||
|
||||
$balanceAfterCsv = (string) $this->tenant->fresh()->balance_rub;
|
||||
|
||||
// ── Step 2: поставщик ретраит webhook 15 мин спустя с настоящим vid ──
|
||||
// Это то, что создаёт дубль на проде: новый SupplierLead с vid != null,
|
||||
// phone + project те же → RouteSupplierLeadJob создаёт ВТОРОЙ Deal.
|
||||
$webhookLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 1672819986,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 1672819986,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subMinutes(15)->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subMinutes(15),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
runRaceJob($webhookLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
|
||||
// ── Assertions ──
|
||||
|
||||
// Assertion 1: по-прежнему ОДИН deal, но source_crm_id теперь заполнен.
|
||||
$deals = Deal::where('phone', $phone)->get();
|
||||
expect($deals)->toHaveCount(1, 'Phase 2: webhook после CSV-recovery должен ОБНОВИТЬ существующий deal, а не создать второй');
|
||||
expect($deals->first()->source_crm_id)->toBe(1672819986, 'source_crm_id должен быть обновлён от webhook vid');
|
||||
|
||||
// Assertion 2: НЕТ второго LeadCharge — биллинг не списывается дважды.
|
||||
$chargesAfterWebhook = LeadCharge::where('deal_id', $csvDeal->id)->count();
|
||||
expect($chargesAfterWebhook)->toBe(1, 'Phase 2: второй LeadCharge создан не должен быть');
|
||||
|
||||
// Assertion 3: баланс НЕ списан второй раз.
|
||||
$balanceAfterWebhook = (string) $this->tenant->fresh()->balance_rub;
|
||||
expect($balanceAfterWebhook)->toBe($balanceAfterCsv, 'Phase 2: баланс после webhook не должен уменьшиться');
|
||||
|
||||
// Assertion 4: supplier_lead_deliveries содержит ОБА supplier_lead_id,
|
||||
// привязанных к ОДНОМУ deal_id.
|
||||
$deliveries = DB::table('supplier_lead_deliveries')
|
||||
->where('deal_id', $csvDeal->id)
|
||||
->get();
|
||||
expect($deliveries)->toHaveCount(2, 'Оба SupplierLead (csv + webhook) должны быть в supplier_lead_deliveries');
|
||||
$deliveredLeadIds = $deliveries->pluck('supplier_lead_id')->sort()->values()->all();
|
||||
expect($deliveredLeadIds)->toContain($csvLead->id);
|
||||
expect($deliveredLeadIds)->toContain($webhookLead->id);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2 — Spec B regression: два webhook с РАЗНЫМИ vid → два deal (by-design).
|
||||
// Наш Phase 2 fix НЕ должен блокировать это.
|
||||
// ---------------------------------------------------------------------------
|
||||
it('two webhooks with DIFFERENT vids both create deals (Spec B — за повторы поставщика берём)', function (): void {
|
||||
$phone = '79991000002';
|
||||
|
||||
// Первый webhook, vid=100.
|
||||
$lead1 = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 100,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 100,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subHour()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($lead1->id);
|
||||
|
||||
// Второй webhook, vid=200 (другой лид поставщика, тот же телефон+проект).
|
||||
$lead2 = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 200,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 200,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subMinutes(30)->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subMinutes(30),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($lead2->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
|
||||
// Spec B: оба webhook'а имеют source_crm_id != null.
|
||||
// Условие merge (source_crm_id IS NULL) не срабатывает → два deal,
|
||||
// два LeadCharge. Spec B Phase 1 (commit ccfecd5e) за повторы поставщика берём.
|
||||
$deals = Deal::where('phone', $phone)->get();
|
||||
expect($deals)->toHaveCount(2, 'Два webhook с разными vid должны создавать два deal (Spec B)');
|
||||
$sourceCrmIds = $deals->pluck('source_crm_id')->sort()->values()->all();
|
||||
expect($sourceCrmIds)->toContain(100);
|
||||
expect($sourceCrmIds)->toContain(200);
|
||||
expect(LeadCharge::whereIn('deal_id', $deals->pluck('id'))->count())->toBe(2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 3 — Boundary: CSV-recovered deal старше 24h НЕ мержится с новым webhook.
|
||||
// Окно merge — 24h. Старый лид не считается «активным» duplicate.
|
||||
// ---------------------------------------------------------------------------
|
||||
it('csv-recovered deal older than 24h is NOT merged with new webhook', function (): void {
|
||||
$phone = '79991000003';
|
||||
|
||||
// CSV-recovered SupplierLead, обработанный 2 дня назад.
|
||||
$csvLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => null,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->subDays(2)->getTimestamp(),
|
||||
],
|
||||
'received_at' => now()->subDays(2),
|
||||
'recovered_from_csv_at' => now()->subDays(2),
|
||||
'source' => 'csv_recovery',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($csvLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
$csvDeal = Deal::where('phone', $phone)->first();
|
||||
expect($csvDeal)->not->toBeNull('CSV-recovered deal должен существовать');
|
||||
|
||||
// Сбросим processed_at у tenant-level проекта: delivered_today накопился,
|
||||
// нужно сбросить счётчик чтобы второй deal тоже прошёл лимит.
|
||||
$this->project->update(['delivered_today' => 0]);
|
||||
|
||||
// Webhook приходит сейчас — deal CSV-recovery старше 24h → не мержится.
|
||||
$webhookLead = SupplierLead::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => 999,
|
||||
'supplier_project_id' => $this->sp->id,
|
||||
'raw_payload' => [
|
||||
'vid' => 999,
|
||||
'project' => 'B1_race-csv.ru',
|
||||
'phone' => $phone,
|
||||
'time' => now()->getTimestamp(),
|
||||
],
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
'processed_at' => null,
|
||||
]);
|
||||
runRaceJob($webhookLead->id);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$this->tenant->id}'");
|
||||
|
||||
// Два deal: старый CSV-recovered (2 дня назад) + новый от webhook.
|
||||
// Merge НЕ происходит — CSV-recovered вне 24h окна.
|
||||
$deals = Deal::where('phone', $phone)->get();
|
||||
expect($deals)->toHaveCount(2, 'CSV-recovered deal старше 24h — merge не происходит, создаётся новый deal от webhook');
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Models\Deal;
|
||||
use App\Models\Project;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\SystemSetting;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Phase 3 — DIRECT platform end-to-end.
|
||||
*
|
||||
* Supplier crm.bp-gr.ru шлёт часть лидов на проекты БЕЗ B[123]_ префикса
|
||||
* (e.g. `client.carmoney.ru`, `cashmotor.ru`, числовой callback `79135191264`).
|
||||
* До Phase 3 такие webhook'и отвергались с 302 redirect и терялись —
|
||||
* наблюдалось 67 потерь/день для tenant client1 на проде 25.05.2026.
|
||||
*
|
||||
* Phase 3 принимает их как platform='DIRECT' end-to-end:
|
||||
* - controller regex снят, parsePlatform возвращает 'DIRECT' для не-B;
|
||||
* - SupplierProjectResolver принимает DIRECT;
|
||||
* - RouteSupplierLeadJob.parseProjectField парсит без B-префикса;
|
||||
* - LeadRouter для DIRECT использует signal_type+identifier match напрямую
|
||||
* (без project_supplier_links pivot — psl-rows для DIRECT не созданы).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md §3 Phase 3
|
||||
*/
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(PricingTierSeeder::class);
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_webhook_secret')
|
||||
->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']);
|
||||
SystemSetting::query()
|
||||
->where('key', 'supplier_ip_allowlist')
|
||||
->update(['value' => '[]']);
|
||||
});
|
||||
|
||||
function directDispatchJob(int $supplierLeadId): void
|
||||
{
|
||||
(new RouteSupplierLeadJob($supplierLeadId))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
it('webhook with non-B-prefix project is accepted (202) and platform=DIRECT', function (): void {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999001,
|
||||
'project' => 'client.carmoney.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(202);
|
||||
$lead = SupplierLead::where('vid', 9999001)->first();
|
||||
expect($lead)->not->toBeNull();
|
||||
expect($lead->platform)->toBe('DIRECT');
|
||||
});
|
||||
|
||||
it('SupplierProjectResolver creates DIRECT supplier_project for non-B project', function (): void {
|
||||
$resolver = app(SupplierProjectResolver::class);
|
||||
$sp = $resolver->resolveOrStub('DIRECT', 'site', 'client.carmoney.ru');
|
||||
|
||||
expect($sp->platform)->toBe('DIRECT');
|
||||
expect($sp->unique_key)->toBe('client.carmoney.ru');
|
||||
expect($sp->signal_type)->toBe('site');
|
||||
});
|
||||
|
||||
it('RouteSupplierLeadJob delivers DIRECT lead to matching project via signal_identifier fallback', function (): void {
|
||||
// Создаём Лидерра-проект с тем же signal_identifier, что и DIRECT-supplier_project.
|
||||
// ВАЖНО: НЕ создаём project_supplier_links — Phase 3 fallback должен матчить
|
||||
// только по signal_type+signal_identifier.
|
||||
$tenant = Tenant::factory()->create([
|
||||
'balance_leads' => 0,
|
||||
'balance_rub' => '1000.00',
|
||||
'delivered_in_month' => 0,
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'client.carmoney.ru',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'effective_daily_limit_today' => 10,
|
||||
'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'platform' => 'DIRECT',
|
||||
'phone' => '79991234567',
|
||||
'vid' => 9999002,
|
||||
'raw_payload' => ['vid' => 9999002, 'project' => 'client.carmoney.ru', 'phone' => '79991234567', 'time' => time()],
|
||||
'received_at' => now(),
|
||||
]);
|
||||
|
||||
directDispatchJob($lead->id);
|
||||
|
||||
$deal = Deal::where('tenant_id', $tenant->id)
|
||||
->where('phone', '79991234567')
|
||||
->first();
|
||||
expect($deal)->not->toBeNull();
|
||||
expect($deal->project_id)->toBe($project->id);
|
||||
expect($deal->source_crm_id)->toBe(9999002);
|
||||
});
|
||||
|
||||
it('numeric-only project (e.g. 79135191264 callback) accepted as DIRECT', function (): void {
|
||||
// Поставщик иногда шлёт project=телефонный номер для callback-проектов.
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999003,
|
||||
'project' => '79135191264',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(202);
|
||||
$lead = SupplierLead::where('vid', 9999003)->first();
|
||||
expect($lead->platform)->toBe('DIRECT');
|
||||
});
|
||||
|
||||
it('existing B1 webhooks still work as platform=B1 (regression)', function (): void {
|
||||
$response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [
|
||||
'vid' => 9999004,
|
||||
'project' => 'B1_krk-finance.ru',
|
||||
'phone' => '79991234567',
|
||||
'time' => time(),
|
||||
]);
|
||||
|
||||
$response->assertStatus(202);
|
||||
expect(SupplierLead::where('vid', 9999004)->first()->platform)->toBe('B1');
|
||||
});
|
||||
|
||||
it('SupplierProjectResolver still rejects unknown platforms other than DIRECT', function (): void {
|
||||
$resolver = app(SupplierProjectResolver::class);
|
||||
|
||||
expect(fn () => $resolver->resolveOrStub('UNKNOWN', 'site', 'foo.ru'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
// Изоляция: persistent liderra_testing, RefreshDatabase off. DatabaseTransactions
|
||||
// откатывает pgsql после теста; SharesSupplierPdo делает pgsql_supplier общим PDO
|
||||
// с pgsql, иначе query через Project::on('pgsql_supplier') не видит uncommitted
|
||||
// tenant/project (паттерн Спека B / AutoPauseFlowTest).
|
||||
uses(DatabaseTransactions::class);
|
||||
uses(SharesSupplierPdo::class);
|
||||
|
||||
it('excludes projects of frozen tenants from supplier order', function () {
|
||||
$frozenTenant = Tenant::factory()->create(['frozen_by_balance_at' => now()]);
|
||||
Project::factory()->for($frozenTenant)->create(['is_active' => true, 'daily_limit_target' => 50]);
|
||||
|
||||
$activeTenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
Project::factory()->for($activeTenant)->create(['is_active' => true, 'daily_limit_target' => 30]);
|
||||
|
||||
$eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects();
|
||||
|
||||
$tenantIds = $eligible->pluck('tenant_id')->unique()->all();
|
||||
expect($tenantIds)->not->toContain($frozenTenant->id);
|
||||
expect($tenantIds)->toContain($activeTenant->id);
|
||||
});
|
||||
|
||||
it('excludes individually preflight-blocked projects', function () {
|
||||
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
|
||||
$okProject = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 30, 'preflight_blocked_at' => null]);
|
||||
$blocked = Project::factory()->for($tenant)->create(['is_active' => true, 'daily_limit_target' => 20, 'preflight_blocked_at' => now()]);
|
||||
|
||||
$eligible = app(SyncSupplierProjectsJob::class)->collectEligibleProjects();
|
||||
|
||||
$ids = $eligible->pluck('id')->all();
|
||||
expect($ids)->toContain($okProject->id);
|
||||
expect($ids)->not->toContain($blocked->id);
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import BalanceCapacityIndicator from '../../resources/js/components/billing/BalanceCapacityIndicator.vue';
|
||||
|
||||
// Billing v2 Spec C Task 1.10 — подсказка под балансом (§3.6). Чистый
|
||||
// presentational-компонент: статус по соотношению ёмкости и требуемого/день.
|
||||
|
||||
describe('BalanceCapacityIndicator', () => {
|
||||
it('зелёный, когда ёмкости хватает на 3+ дня', () => {
|
||||
const w = mount(BalanceCapacityIndicator, {
|
||||
props: { balanceRub: '3000.00', capacityLeads: 90, requiredLeadsPerDay: 25 },
|
||||
});
|
||||
expect(w.text()).toContain('90');
|
||||
expect(w.classes()).toContain('capacity-ok');
|
||||
});
|
||||
|
||||
it('жёлтый, когда ёмкости хватает меньше чем на 3 дня', () => {
|
||||
const w = mount(BalanceCapacityIndicator, {
|
||||
props: { balanceRub: '1000.00', capacityLeads: 30, requiredLeadsPerDay: 25 },
|
||||
});
|
||||
expect(w.classes()).toContain('capacity-warning');
|
||||
});
|
||||
|
||||
it('красный, когда ёмкости меньше требуемого', () => {
|
||||
const w = mount(BalanceCapacityIndicator, {
|
||||
props: { balanceRub: '500.00', capacityLeads: 10, requiredLeadsPerDay: 25 },
|
||||
});
|
||||
expect(w.classes()).toContain('capacity-insufficient');
|
||||
});
|
||||
|
||||
it('зелёный (бесконечный запас), когда проекты ничего не заказывают', () => {
|
||||
const w = mount(BalanceCapacityIndicator, {
|
||||
props: { balanceRub: '500.00', capacityLeads: 10, requiredLeadsPerDay: 0 },
|
||||
});
|
||||
expect(w.classes()).toContain('capacity-ok');
|
||||
});
|
||||
|
||||
it('показывает баланс, ёмкость и дневной заказ', () => {
|
||||
const w = mount(BalanceCapacityIndicator, {
|
||||
props: { balanceRub: '1000.00', capacityLeads: 30, requiredLeadsPerDay: 25 },
|
||||
});
|
||||
expect(w.text()).toContain('1000.00');
|
||||
expect(w.text()).toContain('30');
|
||||
expect(w.text()).toContain('25');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import BalanceFrozenBanner from '../../resources/js/components/billing/BalanceFrozenBanner.vue';
|
||||
|
||||
// Billing v2 Spec C Task 1.10 — красный баннер заморозки (§3.6). Показывается
|
||||
// на всех страницах, когда tenant.frozen_by_balance_at !== null.
|
||||
|
||||
const routerLinkStub = {
|
||||
RouterLink: {
|
||||
props: ['to'],
|
||||
inheritAttrs: false,
|
||||
template: '<a v-bind="$attrs" :href="to"><slot /></a>',
|
||||
},
|
||||
};
|
||||
|
||||
const mountBanner = (props: { frozen: boolean; deficitRub?: string; deficitLeads?: number }) =>
|
||||
mount(BalanceFrozenBanner, {
|
||||
props,
|
||||
global: { plugins: [createVuetify()], stubs: routerLinkStub },
|
||||
});
|
||||
|
||||
describe('BalanceFrozenBanner', () => {
|
||||
it('не заморожен — баннер не рендерится', () => {
|
||||
const w = mountBanner({ frozen: false });
|
||||
expect(w.find('[data-testid="balance-frozen-banner"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('заморожен — красный баннер с текстом «приостановлен»', () => {
|
||||
const w = mountBanner({ frozen: true, deficitRub: '380.00', deficitLeads: 10 });
|
||||
const banner = w.find('[data-testid="balance-frozen-banner"]');
|
||||
expect(banner.exists()).toBe(true);
|
||||
expect(banner.text()).toContain('приостановлен');
|
||||
});
|
||||
|
||||
it('показывает дефицит в рублях и лидах', () => {
|
||||
const w = mountBanner({ frozen: true, deficitRub: '380.00', deficitLeads: 10 });
|
||||
const text = w.find('[data-testid="balance-frozen-banner"]').text();
|
||||
expect(text).toContain('380');
|
||||
expect(text).toContain('10');
|
||||
});
|
||||
|
||||
it('две ссылки — на /billing и на /projects', () => {
|
||||
const w = mountBanner({ frozen: true, deficitRub: '380.00', deficitLeads: 10 });
|
||||
expect(w.find('[data-testid="banner-topup-link"]').attributes('href')).toBe('/billing');
|
||||
expect(w.find('[data-testid="banner-projects-link"]').attributes('href')).toBe('/projects');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import BillingView from '../../resources/js/views/BillingView.vue';
|
||||
import * as billingApi from '../../resources/js/api/billing';
|
||||
@@ -31,13 +32,14 @@ function makeWallet(): Wallet {
|
||||
|
||||
describe('BillingView.vue', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.mocked(billingApi.getWallet).mockResolvedValue(makeWallet());
|
||||
});
|
||||
|
||||
const factory = () =>
|
||||
mount(BillingView, {
|
||||
global: {
|
||||
plugins: [vuetify],
|
||||
plugins: [vuetify, createPinia()],
|
||||
stubs: { TransactionsTable: true, InvoicesTable: true, ChargesTab: true, TopupDialog: true },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,6 +105,64 @@ describe('BulkActionsBar snackbar replacement (Sprint 1 C5)', () => {
|
||||
expect((wrapper.vm as any).skipToastText).toContain('Пропущено: 2');
|
||||
});
|
||||
|
||||
it('runBulk with supplier_snapshot_locked reason shows specific text', async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useProjectsStore();
|
||||
store.selectedIds.add(1);
|
||||
vi.spyOn(store, 'bulkUpdate').mockResolvedValue({
|
||||
updated: 0,
|
||||
skipped: [
|
||||
{ id: 7, reason: 'supplier_snapshot_locked' },
|
||||
{ id: 8, reason: 'supplier_snapshot_locked' },
|
||||
],
|
||||
warnings: [],
|
||||
} as never);
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
const wrapper = mount(BulkActionsBar, {
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: defaultStubs,
|
||||
},
|
||||
});
|
||||
await wrapper.find('[data-testid="bulk-delete"]').trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const text = (wrapper.vm as any).skipToastText as string;
|
||||
expect(text).toContain('2');
|
||||
expect(text.toLowerCase()).toContain('сбор лидов');
|
||||
});
|
||||
|
||||
it('runBulk with mixed reasons shows both groups', async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useProjectsStore();
|
||||
store.selectedIds.add(1);
|
||||
vi.spyOn(store, 'bulkUpdate').mockResolvedValue({
|
||||
updated: 3,
|
||||
skipped: [
|
||||
{ id: 7, reason: 'supplier_snapshot_locked' },
|
||||
{ id: 8, reason: 'has_deals' },
|
||||
],
|
||||
warnings: [],
|
||||
} as never);
|
||||
window.confirm = vi.fn(() => true);
|
||||
|
||||
const wrapper = mount(BulkActionsBar, {
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: defaultStubs,
|
||||
},
|
||||
});
|
||||
await wrapper.find('[data-testid="bulk-delete"]').trigger('click');
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const text = (wrapper.vm as any).skipToastText as string;
|
||||
expect(text.toLowerCase()).toContain('сбор лидов'); // supplier_snapshot_locked
|
||||
expect(text.toLowerCase()).toContain('сделки'); // has_deals
|
||||
});
|
||||
|
||||
it('runBulk with skipped=0 does NOT open snackbar', async () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useProjectsStore();
|
||||
|
||||
@@ -104,4 +104,42 @@ describe('NewProjectDialog', () => {
|
||||
await flushPromises();
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/projects', expect.objectContaining({ regions: [82, 83] }));
|
||||
});
|
||||
|
||||
it('409 balance_insufficient → открывает диалог перегрузки; save-blocked пере-сабмитит с force_save_blocked', async () => {
|
||||
// Spec C §6.2 (Task 1.10): первый POST падает 409, второй (после выбора
|
||||
// «Сохранить и приостановить») уходит с force_save_blocked=true.
|
||||
(apiClient.post as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
error: 'balance_insufficient',
|
||||
current_balance_rub: '1000.00',
|
||||
current_capacity_leads: 30,
|
||||
would_be_required_leads: 50,
|
||||
deficit_leads: 20,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = factory();
|
||||
await flushPromises();
|
||||
// Проходим гейт обязательного региона через подтверждённую «Вся РФ».
|
||||
(wrapper.vm as unknown as { confirmVsyaRf: () => void }).confirmVsyaRf();
|
||||
await (wrapper.vm as unknown as { submit: () => Promise<void> }).submit();
|
||||
await flushPromises();
|
||||
|
||||
const overload = wrapper.find('[data-testid="overload-dialog"]');
|
||||
expect(overload.exists()).toBe(true);
|
||||
expect(overload.text()).toContain('20');
|
||||
|
||||
await wrapper.find('[data-testid="overload-save-blocked"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledTimes(2);
|
||||
expect(apiClient.post).toHaveBeenLastCalledWith(
|
||||
'/api/projects',
|
||||
expect.objectContaining({ force_save_blocked: true }),
|
||||
);
|
||||
expect(wrapper.emitted('saved')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -178,6 +178,44 @@ describe('ProjectDetailsDrawer', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('Delete: 422 errors.project → drawer не закрывается, текст показан', async () => {
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
|
||||
const store = useProjectsStore();
|
||||
const message = 'Мы уже начали сбор лидов по этому проекту на завтра. Пока поставьте на паузу — мы увидим это сегодня в 18:00 и завтра не будем запускать сбор лидов по этому проекту. Удалить можно будет послезавтра.';
|
||||
vi.spyOn(store, 'del').mockRejectedValueOnce({
|
||||
response: { status: 422, data: { errors: { project: [message] } } },
|
||||
});
|
||||
vi.stubGlobal('confirm', () => true);
|
||||
|
||||
await wrapper.get('[data-testid="pdd-delete"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.emitted('close')).toBeUndefined();
|
||||
expect(wrapper.text()).toContain('Мы уже начали сбор лидов');
|
||||
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('Save: 422 errors.project → текст показан', async () => {
|
||||
// Сброс предыдущих очередей mock'ов, чтобы наш reject точно был первым в queue.
|
||||
vi.mocked(axios.patch).mockReset();
|
||||
(axios.patch as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce({
|
||||
response: {
|
||||
status: 422,
|
||||
data: { errors: { project: ['Мы уже начали сбор лидов по этому проекту на завтра. Изменить источник можно будет послезавтра.'] } },
|
||||
},
|
||||
});
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
|
||||
|
||||
await wrapper.get('[data-testid="pdd-save"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
|
||||
expect(wrapper.emitted('saved')).toBeUndefined();
|
||||
expect(wrapper.text()).toContain('Изменить источник можно будет послезавтра');
|
||||
});
|
||||
|
||||
it('renders region chips for project.regions = [1, 2]', async () => {
|
||||
const withRegions: Project = { ...sampleProject, regions: [1, 2] };
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import ProjectLimitOverloadDialog from '../../resources/js/components/projects/ProjectLimitOverloadDialog.vue';
|
||||
|
||||
// Billing v2 Spec C Task 1.10 — диалог перегрузки лимита (§6.2). Принимает
|
||||
// 409-payload `balance_insufficient`, эмитит решение пользователя.
|
||||
|
||||
const payload = {
|
||||
current_balance_rub: '1000.00',
|
||||
current_capacity_leads: 30,
|
||||
would_be_required_leads: 50,
|
||||
deficit_leads: 20,
|
||||
};
|
||||
|
||||
const mountDialog = () =>
|
||||
mount(ProjectLimitOverloadDialog, {
|
||||
props: { modelValue: true, payload },
|
||||
global: {
|
||||
plugins: [createVuetify()],
|
||||
stubs: { VDialog: { template: '<div><slot /></div>' } },
|
||||
},
|
||||
});
|
||||
|
||||
describe('ProjectLimitOverloadDialog', () => {
|
||||
it('рендерит данные 409-payload', () => {
|
||||
const w = mountDialog();
|
||||
const text = w.text();
|
||||
expect(text).toContain('1000.00');
|
||||
expect(text).toContain('30');
|
||||
expect(text).toContain('50');
|
||||
expect(text).toContain('20');
|
||||
});
|
||||
|
||||
it('«Сохранить и приостановить» эмитит save-blocked', async () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-testid="overload-save-blocked"]').trigger('click');
|
||||
expect(w.emitted('save-blocked')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('«Поставить лимит 0» эмитит set-zero', async () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-testid="overload-set-zero"]').trigger('click');
|
||||
expect(w.emitted('set-zero')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('«Отмена» закрывает диалог (update:modelValue=false)', async () => {
|
||||
const w = mountDialog();
|
||||
await w.find('[data-testid="overload-cancel"]').trigger('click');
|
||||
expect(w.emitted('update:modelValue')?.[0]).toEqual([false]);
|
||||
});
|
||||
|
||||
it('payload=null — без падения, ничего не рендерит', () => {
|
||||
const w = mount(ProjectLimitOverloadDialog, {
|
||||
props: { modelValue: true, payload: null },
|
||||
global: { plugins: [createVuetify()], stubs: { VDialog: { template: '<div><slot /></div>' } } },
|
||||
});
|
||||
expect(w.find('[data-testid="overload-save-blocked"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\PricingTier;
|
||||
use App\Services\Billing\BalancePreflightService;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
function makeTiers(): Collection
|
||||
{
|
||||
// 1 ступень: цена 50₽/лид (5000 копеек), безлимитная.
|
||||
return new Collection([
|
||||
new PricingTier(['tier_no' => 1, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 5000, 'is_active' => true]),
|
||||
]);
|
||||
}
|
||||
|
||||
it('passes when balance covers full daily limit', function () {
|
||||
$service = new BalancePreflightService;
|
||||
// 2000₽ / 50₽ = 40 лидов capacity; нужно 30 → проходит.
|
||||
$result = $service->evaluate(
|
||||
balanceRub: '2000.00',
|
||||
deliveredInMonth: 0,
|
||||
requiredLeads: 30,
|
||||
tiers: makeTiers(),
|
||||
);
|
||||
|
||||
expect($result->passes)->toBeTrue();
|
||||
expect($result->capacityLeads)->toBe(40);
|
||||
expect($result->requiredLeads)->toBe(30);
|
||||
expect($result->deficitLeads)->toBe(0);
|
||||
});
|
||||
|
||||
it('fails when balance below full daily limit', function () {
|
||||
$service = new BalancePreflightService;
|
||||
// 1000₽ / 50₽ = 20 лидов capacity; нужно 30 → не проходит, дефицит 10.
|
||||
$result = $service->evaluate(
|
||||
balanceRub: '1000.00',
|
||||
deliveredInMonth: 0,
|
||||
requiredLeads: 30,
|
||||
tiers: makeTiers(),
|
||||
);
|
||||
|
||||
expect($result->passes)->toBeFalse();
|
||||
expect($result->capacityLeads)->toBe(20);
|
||||
expect($result->deficitLeads)->toBe(10);
|
||||
});
|
||||
|
||||
it('passes trivially when requiredLeads is zero', function () {
|
||||
$service = new BalancePreflightService;
|
||||
$result = $service->evaluate('0.00', 0, 0, makeTiers());
|
||||
expect($result->passes)->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Models;
|
||||
|
||||
use App\Models\Project;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Гарантирует, что колонка `paused_at` mass-assignable и cast'ится в datetime.
|
||||
*
|
||||
* Связано: SupplierSnapshotGuard (docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md).
|
||||
*/
|
||||
class ProjectPausedAtTest extends TestCase
|
||||
{
|
||||
public function test_paused_at_is_in_fillable(): void
|
||||
{
|
||||
$fillable = (new Project)->getFillable();
|
||||
$this->assertContains('paused_at', $fillable);
|
||||
}
|
||||
|
||||
public function test_paused_at_is_cast_to_datetime(): void
|
||||
{
|
||||
$casts = (new Project)->getCasts();
|
||||
$this->assertArrayHasKey('paused_at', $casts);
|
||||
$this->assertSame('datetime', $casts['paused_at']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Services\Project;
|
||||
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
use App\Services\Project\ProjectService;
|
||||
use ReflectionMethod;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Гарантирует, что переключение `is_active` всегда сопровождается записью `paused_at`:
|
||||
* - is_active = false → paused_at := NOW()
|
||||
* - is_active = true → paused_at := null
|
||||
*
|
||||
* Без этого SupplierSnapshotGuard для bulk-paused проектов начнёт считать их
|
||||
* "защищёнными навсегда" (paused_at NULL trait), и удаление никогда не разблокируется.
|
||||
*
|
||||
* Тест читает исходник методов и проверяет наличие явной записи `paused_at` рядом
|
||||
* с записью `is_active`. Это структурный smoke — поведенческие тесты (через БД)
|
||||
* пишутся отдельно (Task 14 final regression).
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 11).
|
||||
*/
|
||||
class PausedAtWriteSideTest extends TestCase
|
||||
{
|
||||
public function test_project_service_bulk_pause_resume_writes_paused_at(): void
|
||||
{
|
||||
$body = $this->methodBody(ProjectService::class, 'bulkPauseResume');
|
||||
|
||||
$this->assertStringContainsString('paused_at', $body,
|
||||
'bulkPauseResume должен явно обновлять paused_at вместе с is_active');
|
||||
$this->assertStringContainsString('is_active', $body);
|
||||
}
|
||||
|
||||
public function test_project_controller_toggle_active_writes_paused_at(): void
|
||||
{
|
||||
$body = $this->methodBody(ProjectController::class, 'toggleActive');
|
||||
|
||||
$this->assertStringContainsString('paused_at', $body,
|
||||
'toggleActive должен явно обновлять paused_at вместе с is_active');
|
||||
$this->assertStringContainsString('is_active', $body);
|
||||
}
|
||||
|
||||
public function test_bulk_delete_distinguishes_supplier_snapshot_lock_from_has_deals(): void
|
||||
{
|
||||
$body = $this->methodBody(ProjectService::class, 'bulkDelete');
|
||||
|
||||
$this->assertStringContainsString('supplier_snapshot_locked', $body,
|
||||
'bulkDelete должен помечать пропущенные проекты reason="supplier_snapshot_locked" при guard-блоке');
|
||||
$this->assertStringContainsString('has_deals', $body);
|
||||
}
|
||||
|
||||
private function methodBody(string $class, string $method): string
|
||||
{
|
||||
$rm = new ReflectionMethod($class, $method);
|
||||
$lines = file($rm->getFileName());
|
||||
$body = array_slice($lines, $rm->getStartLine() - 1, $rm->getEndLine() - $rm->getStartLine() + 1);
|
||||
|
||||
return implode('', $body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Services\Project;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\Audit\OperationsLogger;
|
||||
use App\Services\Project\ProjectService;
|
||||
use App\Services\Project\SupplierSnapshotGuard;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Wiring-тесты: убеждаемся, что ProjectService::delete() и ProjectService::update()
|
||||
* зовут SupplierSnapshotGuard::assertCanMutateSource перед мутацией.
|
||||
*
|
||||
* Это не behaviour-тесты самого guard (они в SupplierSnapshotGuardTest), а контракт
|
||||
* интеграции — что переключение защиты на guard действительно произошло.
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md (Task 8 / Task 10).
|
||||
*/
|
||||
class ProjectServiceGuardWiringTest extends TestCase
|
||||
{
|
||||
public function test_delete_invokes_guard_with_delete_action(): void
|
||||
{
|
||||
$guard = Mockery::mock(SupplierSnapshotGuard::class);
|
||||
$guard->shouldReceive('assertCanMutateSource')
|
||||
->once()
|
||||
->with(Mockery::on(fn ($p) => $p instanceof Project && $p->id === 99), 'delete')
|
||||
->andThrow(new HttpResponseException(response()->json([], 422)));
|
||||
|
||||
$service = new ProjectService(new OperationsLogger, $guard);
|
||||
|
||||
$project = new Project(['tenant_id' => 1]);
|
||||
$project->id = 99;
|
||||
|
||||
// We expect guard to throw → ProjectService::delete bails out before touching DB.
|
||||
// Если guard НЕ вызывался — Mockery скажет shouldReceive missed → fail.
|
||||
try {
|
||||
$service->delete($project);
|
||||
$this->fail('Expected HttpResponseException');
|
||||
} catch (HttpResponseException) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_update_invokes_guard_with_change_source_action_when_signal_identifier_changes(): void
|
||||
{
|
||||
$guard = Mockery::mock(SupplierSnapshotGuard::class);
|
||||
$guard->shouldReceive('assertCanMutateSource')
|
||||
->once()
|
||||
->with(Mockery::on(fn ($p) => $p instanceof Project && $p->id === 100), 'change_source')
|
||||
->andThrow(new HttpResponseException(response()->json([], 422)));
|
||||
|
||||
$service = new ProjectService(new OperationsLogger, $guard);
|
||||
|
||||
$project = new Project([
|
||||
'tenant_id' => 1,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79161234567',
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
$project->id = 100;
|
||||
|
||||
try {
|
||||
$service->update($project, ['signal_identifier' => '79169999999']);
|
||||
$this->fail('Expected HttpResponseException');
|
||||
} catch (HttpResponseException) {
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_update_does_not_invoke_guard_when_only_non_source_fields_change(): void
|
||||
{
|
||||
$guard = Mockery::mock(SupplierSnapshotGuard::class);
|
||||
$guard->shouldNotReceive('assertCanMutateSource');
|
||||
|
||||
$service = new ProjectService(new OperationsLogger, $guard);
|
||||
|
||||
$project = new Project([
|
||||
'tenant_id' => 1,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79161234567',
|
||||
'delivered_today' => 0,
|
||||
'daily_limit_target' => 10,
|
||||
]);
|
||||
$project->id = 101;
|
||||
|
||||
// Меняем только daily_limit_target / regions — guard вызываться не должен.
|
||||
// Реальный update упадёт на $project->update() (нет таблицы) — это нормально,
|
||||
// нам важна только проверка mockery expectation на guard.
|
||||
try {
|
||||
$service->update($project, ['daily_limit_target' => 20]);
|
||||
} catch (\Throwable) {
|
||||
// ignore — нас интересует только что guard НЕ был вызван
|
||||
}
|
||||
|
||||
// mockery expectations проверятся в tearDown — если guard ВЫЗВАЛСЯ, тест провалится
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Services\Project;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\Project\SupplierSnapshotGuard;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Http\Exceptions\HttpResponseException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Unit-тесты для SupplierSnapshotGuard.
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md
|
||||
*/
|
||||
class SupplierSnapshotGuardTest extends TestCase
|
||||
{
|
||||
private SupplierSnapshotGuard $guard;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->guard = new SupplierSnapshotGuard;
|
||||
}
|
||||
|
||||
public function test_grace_until_for_pause_before_21_msk_is_next_day_21_msk(): void
|
||||
{
|
||||
$pausedAt = CarbonImmutable::parse('2026-05-25 14:00:00', 'Europe/Moscow');
|
||||
$graceUntil = $this->guard->computeGraceUntil($pausedAt);
|
||||
|
||||
$this->assertSame(
|
||||
'2026-05-26 21:00:00',
|
||||
$graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_grace_until_for_pause_after_21_msk_is_day_plus_two_21_msk(): void
|
||||
{
|
||||
$pausedAt = CarbonImmutable::parse('2026-05-25 22:00:00', 'Europe/Moscow');
|
||||
$graceUntil = $this->guard->computeGraceUntil($pausedAt);
|
||||
|
||||
$this->assertSame(
|
||||
'2026-05-27 21:00:00',
|
||||
$graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_grace_until_for_pause_exactly_at_21_msk_is_day_plus_two_21_msk(): void
|
||||
{
|
||||
$pausedAt = CarbonImmutable::parse('2026-05-25 21:00:00', 'Europe/Moscow');
|
||||
$graceUntil = $this->guard->computeGraceUntil($pausedAt);
|
||||
|
||||
$this->assertSame(
|
||||
'2026-05-27 21:00:00',
|
||||
$graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_grace_until_handles_utc_input(): void
|
||||
{
|
||||
// 14:00 UTC = 17:00 MSK (до 21:00) → grace = следующее 21:00 МСК +24ч
|
||||
$pausedAt = CarbonImmutable::parse('2026-05-25 14:00:00', 'UTC');
|
||||
$graceUntil = $this->guard->computeGraceUntil($pausedAt);
|
||||
|
||||
$this->assertSame(
|
||||
'2026-05-26 21:00:00',
|
||||
$graceUntil->setTimezone('Europe/Moscow')->format('Y-m-d H:i:s'),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------ isProtected ---------------------------------------
|
||||
|
||||
private function mockLinksExists(int $projectId, bool $exists): void
|
||||
{
|
||||
$builder = \Mockery::mock();
|
||||
$builder->shouldReceive('where')->with('project_id', $projectId)->andReturnSelf();
|
||||
$builder->shouldReceive('exists')->andReturn($exists);
|
||||
|
||||
DB::shouldReceive('table')->with('project_supplier_links')->andReturn($builder);
|
||||
}
|
||||
|
||||
public function test_is_protected_false_when_no_supplier_links(): void
|
||||
{
|
||||
$project = new Project(['is_active' => true]);
|
||||
$project->id = 1;
|
||||
|
||||
$this->mockLinksExists(1, false);
|
||||
|
||||
$this->assertFalse($this->guard->isProtected($project));
|
||||
}
|
||||
|
||||
public function test_is_protected_true_when_active_and_linked(): void
|
||||
{
|
||||
$project = new Project(['is_active' => true]);
|
||||
$project->id = 2;
|
||||
|
||||
$this->mockLinksExists(2, true);
|
||||
|
||||
$this->assertTrue($this->guard->isProtected($project));
|
||||
}
|
||||
|
||||
public function test_is_protected_false_when_paused_without_paused_at_legacy(): void
|
||||
{
|
||||
$project = new Project(['is_active' => false]);
|
||||
$project->id = 3;
|
||||
$project->paused_at = null;
|
||||
|
||||
$this->mockLinksExists(3, true);
|
||||
|
||||
$this->assertFalse($this->guard->isProtected($project));
|
||||
}
|
||||
|
||||
public function test_is_protected_true_when_paused_recently_within_grace(): void
|
||||
{
|
||||
$project = new Project(['is_active' => false]);
|
||||
$project->id = 4;
|
||||
// paused at 22:00 МСК → grace until +day-after-tomorrow 21:00 МСК
|
||||
$project->paused_at = CarbonImmutable::parse('2026-05-25 22:00:00', 'Europe/Moscow');
|
||||
|
||||
$this->mockLinksExists(4, true);
|
||||
|
||||
// current "now" is one hour after pause — well inside grace window
|
||||
$now = CarbonImmutable::parse('2026-05-25 23:00:00', 'Europe/Moscow');
|
||||
$this->assertTrue($this->guard->isProtected($project, $now));
|
||||
}
|
||||
|
||||
public function test_is_protected_false_when_grace_has_elapsed(): void
|
||||
{
|
||||
$project = new Project(['is_active' => false]);
|
||||
$project->id = 5;
|
||||
$project->paused_at = CarbonImmutable::parse('2026-05-25 14:00:00', 'Europe/Moscow');
|
||||
|
||||
$this->mockLinksExists(5, true);
|
||||
|
||||
// grace_until = 2026-05-26 21:00; "now" — позже
|
||||
$now = CarbonImmutable::parse('2026-05-26 22:00:00', 'Europe/Moscow');
|
||||
$this->assertFalse($this->guard->isProtected($project, $now));
|
||||
}
|
||||
|
||||
// ------------------ assertCanMutateSource -----------------------------
|
||||
|
||||
public function test_assert_no_throw_when_unprotected(): void
|
||||
{
|
||||
$project = new Project(['is_active' => true]);
|
||||
$project->id = 6;
|
||||
|
||||
$this->mockLinksExists(6, false);
|
||||
|
||||
// not throwing means success
|
||||
$this->guard->assertCanMutateSource($project, 'delete');
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
|
||||
public function test_assert_throws_422_with_delete_phrasing(): void
|
||||
{
|
||||
$project = new Project(['is_active' => true]);
|
||||
$project->id = 7;
|
||||
|
||||
$this->mockLinksExists(7, true);
|
||||
|
||||
try {
|
||||
$this->guard->assertCanMutateSource($project, 'delete');
|
||||
$this->fail('Expected HttpResponseException');
|
||||
} catch (HttpResponseException $e) {
|
||||
$this->assertSame(422, $e->getResponse()->getStatusCode());
|
||||
$body = json_decode((string) $e->getResponse()->getContent(), true);
|
||||
$msg = $body['errors']['project'][0];
|
||||
$this->assertStringContainsString('Мы уже начали сбор лидов', $msg);
|
||||
$this->assertStringContainsString('Удалить можно будет послезавтра', $msg);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_assert_throws_422_with_change_source_phrasing(): void
|
||||
{
|
||||
$project = new Project(['is_active' => true]);
|
||||
$project->id = 8;
|
||||
|
||||
$this->mockLinksExists(8, true);
|
||||
|
||||
try {
|
||||
$this->guard->assertCanMutateSource($project, 'change_source');
|
||||
$this->fail('Expected HttpResponseException');
|
||||
} catch (HttpResponseException $e) {
|
||||
$msg = json_decode((string) $e->getResponse()->getContent(), true)['errors']['project'][0];
|
||||
$this->assertStringContainsString('Изменить источник можно будет послезавтра', $msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
BEGIN;
|
||||
CREATE TEMP TABLE dups AS
|
||||
SELECT d.id AS deal_id, lc.id AS charge_id, lc.price_per_lead_kopecks
|
||||
FROM deals d
|
||||
JOIN lead_charges lc ON lc.deal_id = d.id
|
||||
WHERE d.tenant_id=2
|
||||
AND d.created_at::date = DATE '2026-05-25'
|
||||
AND d.source_crm_id IS NULL
|
||||
AND d.deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM deals d2
|
||||
WHERE d2.tenant_id=d.tenant_id
|
||||
AND d2.phone=d.phone
|
||||
AND d2.project_id=d.project_id
|
||||
AND d2.source_crm_id IS NOT NULL
|
||||
AND d2.created_at::date = DATE '2026-05-25'
|
||||
AND d2.deleted_at IS NULL
|
||||
);
|
||||
|
||||
\echo === dups to clean ===
|
||||
SELECT COUNT(*) AS dup_count, (SUM(price_per_lead_kopecks)/100.0)::numeric(12,2) AS refund_rub FROM dups;
|
||||
|
||||
\echo === refund balance ===
|
||||
UPDATE tenants
|
||||
SET balance_rub = balance_rub + (SELECT (SUM(price_per_lead_kopecks)/100.0)::numeric(14,2) FROM dups),
|
||||
delivered_in_month = GREATEST(0, delivered_in_month - (SELECT COUNT(*)::int FROM dups))
|
||||
WHERE id = 2
|
||||
RETURNING id, balance_rub, delivered_in_month;
|
||||
|
||||
\echo === insert refund txns ===
|
||||
WITH ins AS (
|
||||
INSERT INTO balance_transactions(tenant_id, type, amount_leads, amount_rub, balance_leads_after, balance_rub_after, related_type, related_id, created_at)
|
||||
SELECT 2, 'refund', NULL, (price_per_lead_kopecks/100.0)::numeric(14,2), NULL,
|
||||
(SELECT balance_rub FROM tenants WHERE id=2),
|
||||
'App\Models\Deal', deal_id, NOW()
|
||||
FROM dups
|
||||
RETURNING id
|
||||
)
|
||||
SELECT COUNT(*) AS refund_txns_inserted FROM ins;
|
||||
|
||||
\echo === soft delete deals ===
|
||||
WITH upd AS (
|
||||
UPDATE deals SET deleted_at = NOW(), updated_at = NOW()
|
||||
WHERE id IN (SELECT deal_id FROM dups)
|
||||
RETURNING id
|
||||
)
|
||||
SELECT COUNT(*) AS deals_soft_deleted FROM upd;
|
||||
|
||||
COMMIT;
|
||||
|
||||
\echo === verify ===
|
||||
SELECT id, balance_rub, delivered_in_month FROM tenants WHERE id=2;
|
||||
SELECT COUNT(*) AS refund_txns FROM balance_transactions WHERE tenant_id=2 AND type='refund' AND created_at > NOW() - interval '5 minutes';
|
||||
SELECT COUNT(*) AS remaining_active_dup_pairs FROM (SELECT phone, project_id FROM deals WHERE tenant_id=2 AND created_at::date = DATE '2026-05-25' AND deleted_at IS NULL GROUP BY phone, project_id HAVING COUNT(*) > 1) t;
|
||||
@@ -1755,3 +1755,21 @@ creds
|
||||
гэп
|
||||
misowned
|
||||
деплоями
|
||||
батчить
|
||||
recidive
|
||||
unban
|
||||
синкнуть
|
||||
забанен
|
||||
тригерит
|
||||
subdirs
|
||||
unwired
|
||||
инвокирую
|
||||
ключуется
|
||||
мoжибейк
|
||||
неизменённых
|
||||
неизменён
|
||||
адаптер
|
||||
доктринально
|
||||
маппингов
|
||||
флаговая
|
||||
мигрированы
|
||||
|
||||
+60
-1
@@ -2,7 +2,66 @@
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит тридцать записей в обратном хронологическом порядке (v8.33 → v8.32 → v8.31 → v8.30 → v8.29 → v8.28 → v8.27 → v8.26 → v8.25 → v8.24 → v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
|
||||
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.36, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.38, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
## v8.38 (2026-05-26) — projects.paused_at + projects_paused_at_idx (Supplier Snapshot Guard)
|
||||
|
||||
Защита от прямого убытка Лидерры при удалении/смене источника проекта в окне
|
||||
между слепком поставщика (21:00 МСК) и доставкой по этому слепку. Сценарий: клиент
|
||||
создал проект → ушёл к поставщику в 21:00 → клиент удалил после 21:00 → поставщик
|
||||
утром начал слать лиды по слепку → у нас нет проекта → лиды приняты (`202`), сделки
|
||||
не созданы, баланс не списан, но поставщик в CSV выставит за них счёт.
|
||||
|
||||
Полная спека и тесты: `docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md`.
|
||||
|
||||
**Изменено:**
|
||||
|
||||
- **`projects.paused_at TIMESTAMPTZ NULL`** — новая колонка. Anchor для SupplierSnapshotGuard.
|
||||
Устанавливается в `NOW()` при `is_active = false`, сбрасывается в `NULL` при `is_active = true`.
|
||||
- **`CREATE INDEX projects_paused_at_idx ON projects(paused_at)`** — индекс для grace-проверки.
|
||||
|
||||
**Backfill (delta-миграция):** `UPDATE projects SET paused_at = updated_at WHERE is_active = false AND paused_at IS NULL` —
|
||||
для уже paused проектов, `updated_at` — best-effort approximation момента паузы.
|
||||
|
||||
**Связано:** `app/database/migrations/2026_05_26_120000_add_paused_at_to_projects.php`,
|
||||
`app/app/Services/Project/SupplierSnapshotGuard.php`, `app/app/Services/Project/ProjectService.php`.
|
||||
|
||||
## v8.37 (2026-05-25) — supplier_*.platform: VARCHAR(4)→VARCHAR(8) + ENUM расширен на DIRECT
|
||||
|
||||
Phase 3 supplier webhook reliability — приём проектов без B[123]_ префикса как
|
||||
платформа `DIRECT`. На проде 25.05.2026 для tenant `client1` зафиксировано ~67
|
||||
потерянных лидов/сутки из-за того, что webhook-validation regex `'^B[123]_.+$'`
|
||||
отвергал проекты вида `client.carmoney.ru`, `cashmotor.ru`, `cabinet.caranga.ru`
|
||||
и числовые callback-IDs. Phase 3 принимает их end-to-end под новой платформой `DIRECT`.
|
||||
|
||||
**Изменено:**
|
||||
|
||||
- **`supplier_projects.platform` VARCHAR(4)→VARCHAR(8)** — `DIRECT` (6 символов) не вмещался.
|
||||
- **`project_supplier_links.platform` VARCHAR(4)→VARCHAR(8)** — то же.
|
||||
- **`supplier_leads.platform` VARCHAR(4)→VARCHAR(8)** — то же.
|
||||
- **`chk_supplier_projects_platform`**: `IN ('B1','B2','B3')` → `IN ('B1','B2','B3','DIRECT')`.
|
||||
- **`chk_psl_platform`**: то же расширение enum.
|
||||
- **`chk_supplier_leads_platform`**: то же расширение enum.
|
||||
|
||||
**Добавлено:**
|
||||
|
||||
- **`suppliers` row `code='direct'`** — `DIRECT — Прямые проекты`, `cost_rub=1.00`,
|
||||
`accepts_types={websites,calls,sms}`, `channel='sites'`. Используется
|
||||
`LedgerService::resolveSupplierId` fallback'ом для DIRECT-платформенных лидов.
|
||||
|
||||
**Не изменено:**
|
||||
|
||||
- `chk_supplier_projects_b1_not_for_sms` — деноминирует B1+SMS, DIRECT+SMS не блокирует.
|
||||
- Индексы, FK, RLS-политики — без изменений.
|
||||
|
||||
**Метрики:** 0 новых таблиц, 0 новых индексов; 3 CHECK расширены, 3 колонки расширены, 1 seed-row.
|
||||
|
||||
**Миграции:**
|
||||
|
||||
- `2026_05_25_120000_add_direct_platform_to_supplier_projects` — DDL (idempotent через DROP+ADD CHECK).
|
||||
- `2026_05_25_120100_seed_direct_supplier` — seed `suppliers.code='direct'` через raw SQL INSERT ON CONFLICT DO NOTHING.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md` §3 Phase 3.
|
||||
|
||||
## v8.36 (2026-05-25) — supplier_csv_reconcile_log.unparseable_count: drift-формула без junk-строк
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
-- =============================================================================
|
||||
-- balance_freeze_log — журнал заморозок/разморозок тенантов по балансу
|
||||
-- (Billing v2 Spec C). INSERT-only таблица: записи не обновляются, только
|
||||
-- добавляются. Используется PreflightBalanceService для аудит-трейла.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS balance_freeze_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
event_type VARCHAR(30) NOT NULL,
|
||||
triggered_by VARCHAR(30) NOT NULL,
|
||||
balance_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
required_leads INTEGER NOT NULL,
|
||||
capacity_leads INTEGER NOT NULL,
|
||||
total_daily_limit INTEGER NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON balance_freeze_log
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC);
|
||||
|
||||
-- Флаги заморозки на tenants и projects
|
||||
ALTER TABLE tenants ADD COLUMN IF NOT EXISTS frozen_by_balance_at TIMESTAMPTZ NULL;
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS preflight_blocked_at TIMESTAMPTZ NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL;
|
||||
|
||||
-- Явные GRANT'ы для 4 ролей: на prod таблица создаётся через pgsql_supplier
|
||||
-- (default privileges от postgres-superuser не наследуются на чужие creator-role).
|
||||
-- Mirror supplier_lead_deliveries grant pattern. DO block — idempotent + dev-safe
|
||||
-- (на dev ролей нет → silent skip). balance_freeze_log: SELECT+INSERT только
|
||||
-- (INSERT-only по дизайну — не UPDATE, не DELETE).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_user;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_user;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_supplier_worker;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_supplier_worker;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_migrator;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_migrator;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_admin_user') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_admin_user;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_admin_user;
|
||||
END IF;
|
||||
END $$;
|
||||
+70
-8
@@ -1,6 +1,8 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
|
||||
-- Версия: v8.38 (26.05.2026 — projects.paused_at TIMESTAMPTZ + projects_paused_at_idx: anchor для SupplierSnapshotGuard. Защита от убытка при удалении/смене источника проекта, пока поставщик может прислать лиды по уже сделанному слепку — docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md)
|
||||
-- Базовая версия: v8.37 (25.05.2026 — supplier_*.platform VARCHAR(4)→VARCHAR(8) + chk_supplier_projects_platform / chk_psl_platform / chk_supplier_leads_platform расширены до IN(B1,B2,B3,DIRECT); +seed suppliers.code='direct'. Phase 3 supplier webhook reliability — приём проектов без B-префикса end-to-end)
|
||||
-- Базовая версия: v8.36 (25.05.2026 — supplier_csv_reconcile_log.unparseable_count: учёт мусорных CSV-строк, вычитание из drift-формулы → убирает false-positive drift_alert от телефонов/URL в поле project)
|
||||
-- Базовая версия: v8.35 (24.05.2026 — legacy direct webhook removal: DROP webhook_log (partitioned) + rejected_deals_log + tenants.webhook_token/webhook_token_rotated_at; webhook_dedup_keys сохранена (CSV-канал))
|
||||
-- Базовая версия: v8.34 (23.05.2026 — Billing v2 Spec B: −индекс deals(duplicate_of_id) — телефонный дедуп удалён)
|
||||
-- Базовая версия: v8.31 (23.05.2026 — партиционирование 7 audit-таблиц помесячно (hole #2): auth_log / activity_log / tenant_operations_log / balance_transactions / pd_processing_log / saas_admin_audit_log; PK → (id, created_at|received_at); retention defaults в system_settings)
|
||||
@@ -675,12 +677,17 @@ CREATE TABLE tenants (
|
||||
-- Метаданные
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ -- soft delete (раздел 4.5)
|
||||
deleted_at TIMESTAMPTZ, -- soft delete (раздел 4.5)
|
||||
-- v8.35 (Billing v2 Spec C): флаг заморозки по балансу.
|
||||
-- Не NULL = тенант заморожен (баланс < стоимость дня лидов). PreflightBalanceService.
|
||||
frozen_by_balance_at TIMESTAMPTZ NULL
|
||||
);
|
||||
|
||||
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain) WHERE deleted_at IS NULL;
|
||||
-- idx_tenants_webhook_token удалён в v8.35 (legacy direct webhook removal)
|
||||
CREATE INDEX idx_tenants_inactive ON tenants(last_activity_at) WHERE deleted_at IS NULL;
|
||||
-- v8.35: частичный индекс для заморозки (sparse — большинство тенантов не заморожены)
|
||||
CREATE INDEX tenants_frozen_by_balance_idx ON tenants (frozen_by_balance_at) WHERE frozen_by_balance_at IS NOT NULL;
|
||||
|
||||
-- Forward FK на tenants для SaaS-админских таблиц, объявленных выше
|
||||
-- (saas_admin_sessions.impersonating_tenant_id — Ю-1; impersonation_tokens.tenant_id).
|
||||
@@ -800,6 +807,11 @@ CREATE TABLE projects (
|
||||
sms_senders JSONB, -- массив sender-имён (для signal_type='sms')
|
||||
sms_keyword TEXT, -- ключевое слово (опционально, signal_type='sms')
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
-- РАСШИРЕНИЕ v8.38: anchor для SupplierSnapshotGuard.
|
||||
-- is_active=false → paused_at := NOW(); is_active=true → paused_at := NULL.
|
||||
-- Используется для расчёта grace-периода до разблокировки удаления/смены источника
|
||||
-- (docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md).
|
||||
paused_at TIMESTAMPTZ,
|
||||
-- РАСШИРЕНИЕ v8.2: динамические лимиты (партия 10.6 аудита)
|
||||
daily_limit_target INT NOT NULL DEFAULT 10, -- что хочет клиент (default 10 = паритет с оригиналом)
|
||||
effective_daily_limit_today INT, -- что реально на сегодня (NULL = ещё не считалось)
|
||||
@@ -846,6 +858,9 @@ CREATE TABLE projects (
|
||||
CHECK (ttfr_target_minutes BETWEEN 1 AND 1440),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ,
|
||||
-- v8.35 (Billing v2 Spec C): флаг точечной блокировки проекта по preflight.
|
||||
-- Не NULL = проект заблокирован (не хватает баланса на ближайшие N лидов).
|
||||
preflight_blocked_at TIMESTAMPTZ NULL,
|
||||
UNIQUE (tenant_id, name),
|
||||
CONSTRAINT chk_projects_daily_limit_positive
|
||||
CHECK (daily_limit_target > 0),
|
||||
@@ -877,6 +892,10 @@ CREATE INDEX idx_projects_tenant_signal
|
||||
ON projects(tenant_id, signal_type, signal_identifier);
|
||||
-- v8.20 (Plan 6): GIN-индекс для outbound regions queries.
|
||||
CREATE INDEX idx_projects_regions ON projects USING GIN (regions);
|
||||
-- v8.38: индекс для SupplierSnapshotGuard grace-проверки.
|
||||
CREATE INDEX projects_paused_at_idx ON projects(paused_at);
|
||||
-- v8.35: частичный индекс для preflight-блокировки (sparse — большинство проектов не заблокированы)
|
||||
CREATE INDEX projects_preflight_blocked_idx ON projects (preflight_blocked_at) WHERE preflight_blocked_at IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN projects.daily_limit_target IS
|
||||
'Целевой дневной лимит лидов, заданный клиентом. Фактический лимит на '
|
||||
@@ -907,7 +926,7 @@ COMMENT ON COLUMN projects.regions IS
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE supplier_projects (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
platform VARCHAR(4) NOT NULL, -- B1 / B2 / B3
|
||||
platform VARCHAR(8) NOT NULL, -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
|
||||
signal_type VARCHAR(16) NOT NULL, -- site / call / sms
|
||||
unique_key TEXT NOT NULL, -- domain / phone / sender+keyword / sender
|
||||
supplier_external_id VARCHAR(64), -- внутренний id у поставщика
|
||||
@@ -923,7 +942,7 @@ CREATE TABLE supplier_projects (
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT chk_supplier_projects_platform
|
||||
CHECK (platform IN ('B1','B2','B3')),
|
||||
CHECK (platform IN ('B1','B2','B3','DIRECT')),
|
||||
CONSTRAINT chk_supplier_projects_signal_type
|
||||
CHECK (signal_type IN ('site','call','sms')),
|
||||
CONSTRAINT chk_supplier_projects_sync_status
|
||||
@@ -964,10 +983,10 @@ CREATE TABLE project_supplier_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
platform VARCHAR(8) NOT NULL, -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
|
||||
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ»
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3')),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3','DIRECT')),
|
||||
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
|
||||
);
|
||||
CREATE INDEX idx_psl_supplier_project ON project_supplier_links(supplier_project_id);
|
||||
@@ -1979,7 +1998,7 @@ CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id);
|
||||
CREATE TABLE supplier_leads (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
supplier_project_id BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
platform VARCHAR(8) NOT NULL, -- B1 / B2 / B3 / DIRECT (Phase 3, 2026-05-25)
|
||||
raw_payload JSONB NOT NULL,
|
||||
vid BIGINT, -- nullable: NULL у CSV-recovered лидов (Путь 2)
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
@@ -1993,7 +2012,7 @@ CREATE TABLE supplier_leads (
|
||||
error TEXT,
|
||||
|
||||
CONSTRAINT chk_supplier_leads_platform
|
||||
CHECK (platform IN ('B1','B2','B3')),
|
||||
CHECK (platform IN ('B1','B2','B3','DIRECT')),
|
||||
CONSTRAINT chk_supplier_leads_source
|
||||
CHECK (source IN ('webhook','csv_recovery')),
|
||||
CONSTRAINT chk_supplier_leads_deals_count_nonneg
|
||||
@@ -3316,6 +3335,49 @@ ALTER TABLE lead_charges
|
||||
DEFERRABLE INITIALLY DEFERRED;
|
||||
|
||||
|
||||
-- =============================================================================
|
||||
-- balance_freeze_log — журнал заморозок/разморозок тенантов по балансу
|
||||
-- (Billing v2 Spec C, v8.35). INSERT-only: записи не обновляются.
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS balance_freeze_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tenant_id BIGINT NOT NULL REFERENCES tenants(id),
|
||||
event_type VARCHAR(30) NOT NULL,
|
||||
triggered_by VARCHAR(30) NOT NULL,
|
||||
balance_rub_at_event DECIMAL(12,2) NOT NULL,
|
||||
required_leads INTEGER NOT NULL,
|
||||
capacity_leads INTEGER NOT NULL,
|
||||
total_daily_limit INTEGER NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE balance_freeze_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tenant_isolation ON balance_freeze_log
|
||||
USING (tenant_id = current_setting('app.current_tenant_id', true)::bigint);
|
||||
|
||||
CREATE INDEX balance_freeze_log_tenant_idx ON balance_freeze_log (tenant_id, created_at DESC);
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_user') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_user;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_user;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_supplier_worker;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_supplier_worker;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_migrator') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_migrator;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_migrator;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_app_admin') THEN
|
||||
GRANT SELECT, INSERT ON balance_freeze_log TO crm_app_admin;
|
||||
GRANT USAGE, SELECT ON SEQUENCE balance_freeze_log_id_seq TO crm_app_admin;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- =============================================================================
|
||||
-- КОНЕЦ schema.sql
|
||||
-- =============================================================================
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# deploy/
|
||||
|
||||
Скрипты применения обновлений на боевом сервере liderra.ru.
|
||||
|
||||
## redeploy.sh
|
||||
|
||||
Server-side половина деплоя. На боевом лежит в `/var/www/liderra/redeploy.sh`
|
||||
(вне репозитория Laravel). Здесь — каноническая копия для версионирования
|
||||
и аудита.
|
||||
|
||||
**Workflow деплоя:**
|
||||
|
||||
1. **Локально** — собрать архив кода + Vite-сборку:
|
||||
```bash
|
||||
git archive HEAD app/ db/ | gzip > /tmp/deploy-code.tgz
|
||||
tar czf /tmp/deploy-build.tgz -C app/public build/
|
||||
```
|
||||
2. **scp** обоих архивов на сервер.
|
||||
3. **На сервере** — распаковать в `/var/www/liderra/app/`, выставить владельца
|
||||
`www-data:www-data`, запустить `bash /var/www/liderra/redeploy.sh`.
|
||||
|
||||
**NB:** `redeploy.sh` НЕ делает `git pull` — он рассчитан на то, что код
|
||||
уже залит scp. Если запустить без предварительного scp — будет no-op
|
||||
(composer install / migrate / optimize / restart на той же кодовой базе).
|
||||
|
||||
**Квирк 107 (фикс встроен):** строка `sudo -u www-data php artisan optimize`
|
||||
обязательна. Без неё `optimize` запускался от `ubuntu` → `bootstrap/cache/config.php`
|
||||
с владельцем `ubuntu` → php-fpm (под `www-data`) не мог прочитать → 503 на всём
|
||||
портале. Инцидент 24.05.2026 03:46 UTC, портал лежал 18 минут.
|
||||
|
||||
**Расхождение с боевым:** если правится этот файл — синкать на боевой
|
||||
(scp + проверка хеша). Боевой = source of truth для исполнения, репо =
|
||||
source of truth для рецепта.
|
||||
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# Лидерра тест-сервер — применить обновление (server-side половина).
|
||||
# ПЕРЕД запуском: с dev-машины залить новый код (git archive app db) + сборку
|
||||
# (app/public/build) через scp. Затем на сервере: bash /var/www/liderra/redeploy.sh
|
||||
set -euo pipefail
|
||||
cd /var/www/liderra/app
|
||||
composer install --optimize-autoloader --no-interaction --no-scripts --ignore-platform-req=ext-redis
|
||||
php artisan migrate --force
|
||||
sudo -u www-data php artisan optimize
|
||||
chmod -R a+rX public/build
|
||||
sudo chown -R ubuntu:www-data storage bootstrap/cache
|
||||
sudo chmod -R 775 storage bootstrap/cache
|
||||
sudo systemctl restart php8.3-fpm liderra-queue
|
||||
echo "Redeploy done at $(date -u +%FT%TZ)"
|
||||
@@ -3,7 +3,7 @@
|
||||
**Дата:** 22.05.2026
|
||||
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3). **17 правил R0–R16** (R15 off-phase routing введён в v3.14 на освободившийся после v2.0 R15-motion слот; R16 brain evidence loop введён в v3.16).
|
||||
|
||||
**v3.22** — C1 marketing-tooling: R10.1 Блок 1 +2 строки (**marketing** #74, Anthropic `knowledge-work-plugins/marketing`; **brand-voice** #76, Anthropic partner-built/Tribe AI) + Блок 1 note (v3.22 — **marketingskills** #75 вендорен MIT, материал/резерв-библиотека; **marketing-ru** #77 self-authored project-скил, eval 20/20) + Блок 3 +6 строк (**Метрика MCP** #78 `atomkraft/yandex-metrika-mcp` READ-ONLY; **Директ+Wordstat MCP** #79 `SvechaPVL/yandex-mcp` Wordstat-only, Direct-mutations disabled IS9; **Telegram MCP** #80 `chigwell/telegram-mcp` Apache-2.0; **Postiz MCP** #81 self-host AGPL-3.0 internal; **DataForSEO MCP** #82 DEFERRED — платный post-Б-1; **Unisender Go MCP** #83 DEFERRED — своя обёртка). Новая 18-я off-phase подкатегория **marketing-tooling** (раздел C1 карты). Не UI → вне R6.0/R6.1/R14. R15.6 +marketing-tooling. Провенанс-вет IS9 выполнен (`docs/security/marketing-vet.md`, 5 инструментов PASS/PASS-with-conditions). Содержательных изменений R0–R14, R16: 0. Связано: Tooling v2.23+, Pravila v1.39+, CLAUDE.md v2.27+; план `docs/superpowers/plans/2026-05-22-c1-marketing-tooling.md`; spec `docs/superpowers/specs/2026-05-22-c1-marketing-tooling-design.md`.
|
||||
**v3.22** — C1 marketing-tooling: R10.1 Блок 1 +2 строки (**marketing** #74, Anthropic `knowledge-work-plugins/marketing`; **brand-voice** #76, Anthropic partner-built/Tribe AI) + Блок 1 note (v3.22 — **marketingskills** #75 вендорен MIT, материал/резерв-библиотека; **marketing-ru** #77 self-authored project-скил, eval 20/20) + Блок 3 +6 строк (**Метрика MCP** #78 `atomkraft/yandex-metrika-mcp` READ-ONLY; **Директ+Wordstat MCP** #79 `SvechaPVL/yandex-mcp` Wordstat-only, Direct-mutations disabled IS9; **Telegram MCP** #80 `chigwell/telegram-mcp` Apache-2.0; **Postiz MCP** #81 self-host AGPL-3.0 internal; **DataForSEO MCP** #82 DEFERRED — платный post-Б-1; **Unisender Go MCP** #83 DEFERRED — своя обёртка). Новая 18-я off-phase подкатегория **marketing-tooling** (раздел C1 карты). Не UI → вне R6.0/R6.1/R14. R15.6 +marketing-tooling. Провенанс-вет IS9 выполнен (`docs/security/marketing-vet.md`, 5 инструментов PASS/PASS-with-conditions). Содержательных изменений R0–R14, R16: 0. Связано: Tooling v2.23+, Pravila v1.42+, CLAUDE.md v2.27+; план `docs/superpowers/plans/2026-05-22-c1-marketing-tooling.md`; spec `docs/superpowers/specs/2026-05-22-c1-marketing-tooling-design.md`.
|
||||
|
||||
**v3.21** — A8 infosec-tooling install-sync: ZAP #68 + Ward #70 установлены портативно 21.05.2026 (без choco) → в R10.1 Блок 1 note (Ward) + Блок 3 (ZAP MCP-row) снят статус PENDING INSTALL. Содержательных изменений R0–R16: 0; счётчики/состав без изменений. Связано: Tooling v2.21, Pravila v1.38, CLAUDE.md v2.25; setup-доки `docs/security/{zap,ward}-setup.md`; план `docs/superpowers/plans/2026-05-21-a8-infosec-tooling.md`.
|
||||
|
||||
@@ -914,6 +914,8 @@ R16 — evidence-сбор, не правило выбора. R0–R15 продо
|
||||
|
||||
## История версий
|
||||
|
||||
- **v3.22 (2026-05-25, cross-ref update)** — §0 cross-ref string Pravila v1.39+→**v1.42+** (Pravila §17.7 «Coverage announcement» добавлена — правило аннотировать каждую non-conversation задачу `coverage: <channel>:<id>`). Содержательных изменений R0–R16: 0. Связано: Pravila v1.42, Tooling v2.23, CLAUDE.md v2.28.
|
||||
|
||||
- **v3.17 (2026-05-19)** — observer schema v2 sync (ADR-011 amend): R16.1 +предложение про `schema_version` / `decision_provenance` / `environment` / `task_size` / `prompt_signal` + расширенные события (`hook_fired` / `interrupt` / `retry` / `time_burn` / `parse_gap`) + `observer_error` маркер; R16.4 +cross-ref на factor-analysis spec и plan. R0–R15 без изменений. Routing-gate / C5 controller / `/brain-retro` analyzer — нормативно в Pravila §16.7/§16.8 + ADR-011 §5; PSR_v1 фиксирует evidence-сбор (R16), не enforcement. Связано: ADR-011, Pravila v1.32 (§16 amend), CLAUDE.md v2.19, spec `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`. Per spec v1.0 §7.
|
||||
|
||||
- **v3.16 (2026-05-19)** — Brain evidence loop: новое R16 «Brain evidence loop» (R16.1 observer scope — Stop-хук `tools/observer-stop-hook.mjs` пишет `docs/observer/episodes-YYYY-MM.jsonl`, 5 обязательных полей: `task_id` / `timestamps` / `path_type` / `outcome` / `primary_rationale` + optional `events[]` per spec v1.1 §5.2.1; R16.2 plugin stack-conscious events — при использовании R6/R6.1 или R15 off-phase observer пишет `routing_decision` / `skill_invoked` с `node_id`, факторная матрица 5 осей для `/brain-retro`: triggers_matched / candidates_dropped_because / boundaries_applied / hard_floor.rules / task_classification; R16.3 не override — R0–R15 определяют выбор узлов, R16 только фиксирует историю; R16.4 cross-refs). R0–R15 без изменений. Связано: ADR-011 `docs/adr/ADR-011-brain-governance.md`, Pravila §16, spec `docs/superpowers/specs/2026-05-19-brain-governance-design.md`, plan `docs/superpowers/plans/2026-05-19-brain-governance.md`, procedure `docs/router-procedure.md`. Per spec v1.1 §5.2.1 amendment.
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
# Правила работы Claude в проекте «Лидерра»
|
||||
|
||||
**Версия:** v1.40 (24.05.2026)
|
||||
**Дата:** 24.05.2026
|
||||
**Версия:** v1.42 (25.05.2026)
|
||||
**Дата:** 25.05.2026
|
||||
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
|
||||
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
|
||||
|
||||
**Что изменилось в v1.42 относительно v1.41:** LLM-first router overhaul Phase 3 deferred follow-up #1 — **§17.7 «Coverage announcement» добавлен**. Правило: в каждом ответе на non-conversation задачу Claude обязан показать coverage-пометку в формате `coverage: <channel>:<id>` рядом с первым tool-вызовом или в начале текста. 6 каналов: `skill:` / `node:` / `chain:` / `hook:` / `agent:` / `direct:<exempt-класс>`. Observability layer (не enforcement) — фиксирует **намерение** выбора канала, дополняет машинный гейт `tools/router-tool-gate.mjs` который ловит **факт**. Отсутствие пометки на non-conversation эпизоде — сигнал для C5 контролёра в STATUS.md, не блокирует коммит. Граница с routing-тегом §16.7: routing-тег только для `user_directed_method`, coverage-пометка — всегда для non-conversation. Cross-ref: реестр узлов `docs/registry/nodes.yaml`, цепочки `docs/routing-off-phase.md`, парсер `tools/observer-transcript-parser.mjs` (schema v4.4+ — реализация следующим коммитом). Архитектурных изменений §§1–16: 0. Связано: §17.1–17.6 (база §17 из v1.41), §16.4 (missed-activation = симметричный отчёт о пропусках §17), spec `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md`, memory `project_brain_overhaul.md`.
|
||||
|
||||
**Что изменилось в v1.41 относительно v1.40:** LLM-first router overhaul Phase 1 Tasks 4+5. **§12 «Superpowers hard rule» снят** (Task 4, commit `bca63fc6`) — полный текст в `docs/archive/llm-bootstrap-2026-05/pravila-12/Pravila_section_12.md`; §0 priority chain пересобран без §12 + добавлен «NB про §12» pointer на архив. **§17 «Universal skill-coverage rule» добавлен** (Task 5, this commit) — classifier-driven default-deny на non-conversation задачах, 5 exempt-классов §17.2, continuation НЕ exempt (D1, §17.3), enforcement через `tools/router-tool-gate.mjs` mode-flag `off/warn-only/enforce`. **§16.4 cross-refs мигрированы** (Task 4): tools/observer-classification-map.json + tools/.node-dormancy.json → docs/registry/nodes.yaml + buildClassificationMap / buildDormancyMap. **§16.5 hard-rule list:** §12 → §17. Архитектурное обоснование — **ADR-016** (new). Связано: spec `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md` v2.3, plan `docs/superpowers/plans/2026-05-25-llm-first-router-overhaul.md` v1.2.
|
||||
|
||||
**Что изменилось в v1.40 относительно v1.39:** Делегирование проектным AI-агентам — §2.4 (новая подсекция) описывает обязанность контроллера передавать класс задач 4 узко-специализированным агентам в `.claude/agents/`: `normative-sync` (#84, синк 4 нормативных файлов после крупной задачи), `prod-deploy-validator` (#85, 8 SSH pre-flight перед выкатом на liderra.ru), плюс прежние `pest-parallel-debugger` и `rls-reviewer`. Project-агенты регистрируются в `docs/registry/nodes.yaml` (subcategory `project-agent`) для missed-activation детектора, но **не входят в Tooling канон счётчиков** #1-#83 (footer-числа не двигаются). Архитектурных изменений §§1, §3–§16: 0. Связано: CLAUDE.md v2.28+ (§3.9), spec `docs/superpowers/specs/2026-05-24-controller-offload-agents-design.md`, agent files `.claude/agents/{normative-sync,prod-deploy-validator}.md`.
|
||||
|
||||
**Что изменилось в v1.39 относительно v1.38:** C1 marketing-tooling — §13.2 +абзац «Off-phase marketing-tooling»: #74 marketing (Anthropic, первичный решатель C1), #75 marketingskills (вендорен MIT, материал/резерв), #76 brand-voice (Anthropic, вербальный бренд), #77 marketing-ru (self-authored project-скил, РФ-специфика + 152-ФЗ маркетинг), #78 Яндекс.Метрика MCP (READ-ONLY), #79 Яндекс.Директ+Wordstat MCP (**Wordstat-only**, Direct-мутации отключены per IS9), #80 Telegram MCP, #81 Postiz (self-host, AGPL-3.0 internal), #82 DataForSEO (**DEFERRED**, pending Б-1/бюджет), #83 Unisender Go (**DEFERRED**, pending согласования + 152-ФЗ). 18-я off-phase подкатегория, раздел C1. Не UI → вне R6.0/R6.1/R14. Границы — ADR-015. Счётчики — канон Tooling §0. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.23+, PSR_v1 v3.22+, CLAUDE.md v2.27+; план `docs/superpowers/plans/2026-05-22-c1-marketing-tooling.md`.
|
||||
@@ -175,8 +179,10 @@
|
||||
|
||||
Это **внутренние правила Claude**, не процессные правила команды. Документ написан для одного читателя — Claude. Заказчик согласовывает содержание; команды/действия не требуются.
|
||||
|
||||
Приоритет правил при конфликте: **§12 (Superpowers — explicit hard-rule, инвокация skills первой)** → **§14 (Ruflo Queen routing — explicit hard-rule, триггер queen/королева)** → §1 (роль) → §2 (что Claude делает сам / спрашивает / не делает) → §3 (формат ответов) → §4 (документация и версии) → §5 (безопасность и ПДн) → §6 (Claude в Chrome) → §7 (открытые вопросы) → §8 (рутины сессии) → §9 (отступления) → **§11 (Superpowers override §2.2/§4.5/§8.4 при явном вызове)** → **§13 (Frontend Design plugin — paired stack, координация через Plugin_stack_rules_v1 v3.2+)**.
|
||||
Приоритет правил при конфликте: **§14 (Ruflo Queen routing — explicit hard-rule, триггер queen/королева)** → §1 (роль) → §2 (что Claude делает сам / спрашивает / не делает) → §3 (формат ответов) → §4 (документация и версии) → §5 (безопасность и ПДн) → §6 (Claude в Chrome) → §7 (открытые вопросы) → §8 (рутины сессии) → §9 (отступления) → **§11 (Superpowers override §2.2/§4.5/§8.4 при явном вызове)** → **§13 (Frontend Design plugin — paired stack, координация через Plugin_stack_rules_v1 v3.2+)** → **§17 (universal skill-coverage — добавляется в Task 5)**.
|
||||
|
||||
> **NB про §12 (2026-05-25):** §12 «Superpowers hard rule» снят в Phase 1 Task 4 LLM-first router overhaul и заменён §17 universal skill-coverage (Task 5). Полный архивный текст — `docs/archive/llm-bootstrap-2026-05/pravila-12/Pravila_section_12.md`. См. ADR-016 (Task 5) для архитектурного обоснования замены.
|
||||
>
|
||||
> **§11 локальное override-исключение из цепочки (v1.10+):** §11 формально стоит ПОСЛЕ §9 в основной цепочке выше, но при **явном вызове skill'а Superpowers** §11 **локально поднимается выше §2.2/§4.5/§8.4** в этих узлах (см. §11.1 — «приоритет skill'а над §2.2 явное согласование, §4.5 паттерн 3 варианта, §8.4 защита от компакции»). То есть основная цепочка определяет приоритет в общем случае; §11 — точечное override 3 параграфов при триггере skill-инвокации. Это НЕ меняет позицию §11 относительно §1, §3, §5, §6, §7, §10, §12 — там §11 остаётся ниже. Аналогично §13 — расширение через PSR_v1 (paired stack + UI-пул), не override Pravila.
|
||||
>
|
||||
> **Scope этой цепочки (v1.9+):** **внутрипараграфный** приоритет внутри Pravila (порядок применения параграфов §1–§13 при конфликтах). Не дублирует:
|
||||
@@ -187,7 +193,7 @@
|
||||
>
|
||||
> При вопросе «приоритет какого правила?» — сначала смотреть **CLAUDE.md §1** (какой файл/слой главный), затем при равенстве — внутрипараграфные приоритеты документа-победителя.
|
||||
|
||||
**Особый статус §12 и §14:** §12 — **explicit hard-rule** (единственное в v1.4–v1.13; с v1.15 — два explicit hard-rule: §12 + §14). §9 «Когда Claude отступает» к §12 **не применяется** (§12.4). Дополнительно §13.9 и §13.10 — **transitive hard-rule** через hard-link на нарушения PSR_v1 R10/R14 (см. §13.6 tier-таблицу). **§14 (с v1.15)** — второе explicit hard-rule документа: триггер queen/королева → безусловный route через ruflo Queen; §9 к §14 не применяется (§14.5). §12 и §14 не конфликтуют — они на разных слоях (§14.6: §12 — слой дисциплины исполнения, §14 — слой маршрутизации); порядок «§12 → §14» в priority chain выше отражает текстовую нумерацию, не иерархию приоритета.
|
||||
**Особый статус §14 и §17:** **§14** (с v1.15) — explicit hard-rule: триггер queen/королева → безусловный route через ruflo Queen; §9 к §14 не применяется (§14.5). **§17** (добавляется в Task 5 LLM-first router overhaul, см. ADR-016) — universal skill-coverage: classifier-driven default-deny на non-conversation задачах. §17 заменяет ранее существовавшее §12 «Superpowers hard rule» (архив — `docs/archive/llm-bootstrap-2026-05/pravila-12/Pravila_section_12.md`). Дополнительно §13.9 и §13.10 — **transitive hard-rule** через hard-link на нарушения PSR_v1 R10/R14 (см. §13.6 tier-таблицу). §14 и §17 не конфликтуют — на разных слоях (§14 — маршрутизация, §17 — дисциплина исполнения).
|
||||
|
||||
---
|
||||
|
||||
@@ -639,6 +645,7 @@ P0 = блокер старта спринта или регуляторного
|
||||
| **v1.31** | **19.05.2026** | Brain governance: +§16 «Регламент «мозга»» (router-only архитектура §16.1 + observer Stop-event §16.2 + 4 контролёра C1-C4 §16.3 + поведенческое правило «не использован ≠ проблема» §16.4 + не override-floor §9 §16.5 + cross-refs §16.6). Уровень рекомендации §13 — НЕ explicit hard-rule вне §9. Тремя hard-rules вне §9 остаются §12 / §14 (dormant) / §15. ADR-011 enforcement через `adr-judge` lefthook job (секция `## Enforcement` обязательна). Связано: ADR-011 `docs/adr/ADR-011-brain-governance.md`, spec `docs/superpowers/specs/2026-05-19-brain-governance-design.md`, plan `docs/superpowers/plans/2026-05-19-brain-governance.md`, procedure `docs/router-procedure.md`, memory `feedback_brain_unused_tools_not_problem.md` + `project_brain_governance_design.md`. Архитектурных изменений в §§1–15: 0. |
|
||||
| **v1.32** | **19.05.2026** | Observer factor-analysis extension (ADR-011 amend): §16.2 +абзац «Схема эпизода v2» (`schema_version: 2`, `decision_provenance`, `environment`, `task_size`, `task_ref`, `prompt_signal`; `outcome` `unknown` при записи; виды событий +`hook_fired`/`interrupt`/`retry`/`time_burn`/`parse_gap`); §16.3 4→5 контролёров (+C5 observer-coverage-checker, warn-only); §16.7 (новое) routing-тег-дисциплина — Stop-хук `decision: block` при навязанном методе без тега, `stop_hook_active` guard; §16.8 (новое) самодисциплина наблюдателя (`observer_error` маркер, `parse_gap` событие, C5). Spec `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`. Связано: PSR_v1 v3.17, CLAUDE.md v2.19. Архитектурных изменений в §§1–15: 0. |
|
||||
| **v1.33** | **19.05.2026** | Observer factor-analysis phase 1.1 (ADR-011 amend): §16.2 — `decision_provenance.kind` расширен до 3 значений (`autonomous` \| `user_directed_method` \| `user_chose_from_options`); 3-й kind — collaborative-choice case (заказчик выбирает один из вариантов, предложенных Claude в предыдущем ходе). §16.7 +абзац «Граница `user_chose_from_options`»: routing-gate НЕ блокирует этот kind — выбор из choice-space, сформулированного самим Claude, не навязанный извне метод; routing-тег не обязателен (детектор `tools/observer-choice-detector.mjs` детерминированный). Spec §11 `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md` v1.1, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis-phase-1-1.md`. Связано: CLAUDE.md v2.20. Архитектурных изменений в §§1–15: 0. |
|
||||
| **v1.42** | **25.05.2026** | LLM-first router overhaul Phase 3 deferred follow-up #1: **+§17.7 «Coverage announcement»** — правило аннотировать каждую non-conversation задачу coverage-пометкой `coverage: <channel>:<id>` (6 каналов: skill/node/chain/hook/agent/direct). Observability layer (не enforcement) — фиксирует **намерение** выбора канала, дополняет машинный гейт §17.4 который ловит **факт**. Граница с routing-тегом §16.7: routing-тег только для `user_directed_method`, coverage-пометка — всегда для non-conversation. C5 controller фиксирует отсутствие пометки в STATUS.md, не блокирует коммит. Cross-ref: реестр `docs/registry/nodes.yaml`, цепочки `docs/routing-off-phase.md`, парсер `tools/observer-transcript-parser.mjs` (schema v4.4+ — реализация следующим коммитом deferred #2). Связано: spec `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md`, memory `project_brain_overhaul.md`. NB: записи таблицы v1.34–v1.41 не дотянуты предыдущими сессиями (известный дрейф); шапка `«Что изменилось в v1.NN»` авторитетна для этого периода. Архитектурных изменений §§1–16: 0. |
|
||||
|
||||
---
|
||||
|
||||
@@ -675,74 +682,9 @@ P0 = блокер старта спринта или регуляторного
|
||||
|
||||
---
|
||||
|
||||
## 12. Superpowers — hard rule (инвокация skills первой)
|
||||
## 12. (archived — superseded by §17 universal skill-coverage)
|
||||
|
||||
Введено 09.05.2026 на явное требование заказчика: **«Создай правило, что ты всегда в первую очередь пользуешься superpowers. При этом ты не можешь игнорировать и обходить это правило.»**
|
||||
|
||||
§12 — **explicit hard-rule**: перед содержательной задачей соответствующий Superpowers-skill (карта §12.2) инвокируется первым. §9 «Отступления» к §12 не применяется (§12.4). Карта §12.2, exclusions §12.3 и детали §12.4 — в силе.
|
||||
|
||||
### 12.1. Принцип
|
||||
|
||||
Перед началом любой содержательной задачи Claude **сначала** проверяет соответствующий skill в плагине Superpowers v5.1.0 и **инвокирует его**. Skill приносит свой workflow, Claude следует ему. Только если skill для задачи отсутствует (см. §12.3) — работа идёт обычным flow.
|
||||
|
||||
### 12.2. Карта задач → skills
|
||||
|
||||
| Задача | Skill для инвокации |
|
||||
|---|---|
|
||||
| Тесты с TDD-циклом (новый функционал биллинга, RLS, deals API) | `superpowers:test-driven-development` |
|
||||
| Разбор бага / системный debug / расследование инцидента | `superpowers:systematic-debugging` |
|
||||
| Планирование эпика / большой задачи (≥3 этапа) | `superpowers:writing-plans` |
|
||||
| Исполнение существующего плана | `superpowers:executing-plans` |
|
||||
| Мозговой штурм / генерация идей по требованию заказчика | `superpowers:brainstorming` |
|
||||
| Подготовка PR / запрос code review | `superpowers:requesting-code-review` |
|
||||
| Получение и применение review-комментариев | `superpowers:receiving-code-review` |
|
||||
| Финализация feature-ветки (merge-ready) | `superpowers:finishing-a-development-branch` |
|
||||
| Параллельная работа независимых задач | `superpowers:dispatching-parallel-agents` |
|
||||
| Делегирование подагентам с инструкциями | `superpowers:subagent-driven-development` |
|
||||
| Финальная проверка перед сдачей задачи | `superpowers:verification-before-completion` |
|
||||
| Создание / правка пользовательских skills | `superpowers:writing-skills` |
|
||||
| Git worktrees (с учётом §11.3 — Windows + кириллица) | `superpowers:using-git-worktrees` |
|
||||
| Понимание возможностей самого плагина | `superpowers:using-superpowers` |
|
||||
|
||||
### 12.3. Когда правило НЕ применяется
|
||||
|
||||
> **Single Source of Truth для exclusions §12 (v1.9+).** При расширении списка — править только этот раздел; в CLAUDE.md §5 п.11 и PSR_v1 R0.4.A — только cross-ref сюда. При расхождении между документами побеждает Pravila §12.3.
|
||||
|
||||
§12 не активируется, только если у задачи **отсутствует** соответствующий skill:
|
||||
|
||||
- Чтение / поиск файла (Glob, Grep, Read).
|
||||
- Тривиальные правки (опечатки, синхронизация ссылок, обновление версионных меток в шапках).
|
||||
- Ответы на справочные вопросы заказчика без действий над кодом.
|
||||
- Работа с открытыми вопросами реестра (`Биз-*`, `CTO-*`, `Ю-*`, `Диз-*`, `DO-*`, `OPEN-*`) — её регулирует §7.
|
||||
- Конкретные команды tooling'а (composer/npm/git/Boost MCP), которые не являются «debug» или «TDD».
|
||||
- Документационные правки уровня §4 (Pravila/Tooling/CLAUDE.md/narrative). Для CLAUDE.md дополнительное требование — через `claude-md-management:claude-md-improver` (CLAUDE.md §5 п.10), но это инфраструктурный канал правок, не §12-skill.
|
||||
|
||||
В **любом другом** случае skill инвокируется **до** прочих действий.
|
||||
|
||||
### 12.4. Hard-rule статус
|
||||
|
||||
- §9 «Отступления» к §12 **не применяется** — §12 explicit hard-rule. Единственная отмена — явный запрос заказчика «не используй superpowers сейчас», только на текущее действие.
|
||||
- §12 имеет приоритет над §1–§11. Это значит, что даже когда §1 (роль) или §11 (override) предписывают определённое поведение, §12 срабатывает раньше — skill инвокируется первым.
|
||||
- Запрос заказчика «не используй superpowers сейчас» — единственная разрешённая отмена правила, и **только** для текущего действия. В следующем действии §12 действует автоматически.
|
||||
- Игнорирование §12 (выбор обычного подхода когда skill доступен) — нарушение того же уровня, что игнорирование §5 (ПДн).
|
||||
- Любая попытка обойти §12 через переформулировку задачи («это просто debug» вместо `systematic-debugging`) — нарушение.
|
||||
- Claude **не имеет права** рационализировать пропуск §12 («сейчас быстрее без skill'а»; «эта задача проще, чем требует skill»). Если skill применим — он инвокируется.
|
||||
|
||||
### 12.5. Override-приоритет относительно §11
|
||||
|
||||
§12 имеет **приоритет над §11**. §11 разрешил Superpowers override §2.2/§4.5/§8.4. §12 теперь говорит: даже без явного вызова заказчиком, skill инвокируется по умолчанию. Override §2.2/§4.5/§8.4 при этом происходит автоматически (§11.1).
|
||||
|
||||
### 12.6. Что остаётся неизменным
|
||||
|
||||
§5 (ПДн), §7 (финальное закрытие открытых вопросов), §3.6 (язык) — **не override-ятся** даже Superpowers skill'ом, и §12 этого не меняет. См. §11.2.
|
||||
|
||||
### 12.7. Нарушения
|
||||
|
||||
Если Claude забыл инвокировать skill в подходящей задаче — заказчик может указать на нарушение. Claude обязан зафиксировать ошибку в feedback memory (`feedback_*.md`) для коррекции в будущих сессиях.
|
||||
|
||||
### 12.8. Ревизия §12
|
||||
|
||||
В отличие от §11, который ревизуется по факту проблем, §12 — стабильное правило. Откат возможен только тем же путём, что и введение: явным запросом заказчика «откати §12, верни §9 как override-возможность».
|
||||
> §12 «Superpowers hard rule» removed 2026-05-25 в Phase 1 Task 4 LLM-first router overhaul. Заменён **§17 universal skill-coverage** (Task 5) — classifier-driven default-deny на non-conversation задачах. Полный текст §12 — `docs/archive/llm-bootstrap-2026-05/pravila-12/Pravila_section_12.md`. См. **ADR-016** (Task 5). Откат: `git checkout brain-pre-llm-bootstrap -- docs/Pravila_raboty_Claude_v1_1.md`.
|
||||
|
||||
---
|
||||
|
||||
@@ -1021,7 +963,7 @@ git fetch origin && git log HEAD..origin/main --oneline
|
||||
|
||||
Узел «мозга», не задействованный в реальной работе, **не** считается проблемой и **не** подлежит автоматической пометке **при условии, что профильной задачи для него в эпизодах не было**. Это — capability-readiness, осознанная стратегия заказчика.
|
||||
|
||||
**Симметричное правило (missed activation):** если в эпизодах присутствует **хотя бы один** эпизод с `primary_rationale.task_classification`, соответствующим набору рекомендуемых узлов из `tools/observer-classification-map.json`, при этом `primary_rationale.node_chosen === 'direct'` и среди рекомендуемых узлов есть хотя бы один non-dormant (по `tools/.node-dormancy.json`, экстракт из [Tooling Прил.Н §3.5/§4.X](Tooling_v8_3.md) с двойным сигналом: `dormant: true` ИЛИ ключевое слово `DEFERRED` в колонке boundaries) — это **сигнал**, кандидат на разбор. Surface в STATUS.md (C5: `missed_activations: N`, ⚠️ при N>0) и в выводе `/brain-retro`. Не блок коммита, не auto-edit.
|
||||
**Симметричное правило (missed activation):** если в эпизодах присутствует **хотя бы один** эпизод с `primary_rationale.task_classification`, соответствующим набору рекомендуемых узлов из реестра `docs/registry/nodes.yaml` (поле `triggers[].classification` per node; адаптер `tools/registry-to-classification-map.mjs::buildClassificationMap`), при этом `primary_rationale.node_chosen === 'direct'` и среди рекомендуемых узлов есть хотя бы один с `status: active` (поле `status` в nodes.yaml; non-active = `dormant`/`deferred`/`historic` через адаптер `buildDormancyMap`) — это **сигнал**, кандидат на разбор. Surface в STATUS.md (C5: `missed_activations: N`, ⚠️ при N>0) и в выводе `/brain-retro`. Не блок коммита, не auto-edit. Прежние source-файлы `tools/observer-classification-map.json` и `tools/.node-dormancy.json` архивированы 2026-05-25 (LLM-first router overhaul Task 4) — см. `docs/archive/llm-bootstrap-2026-05/routing-docs/`.
|
||||
|
||||
**Исключения:** DEFERRED-узлы (на момент v1.36 — #17 pg_partman, #44 Figma MCP, #50 Jupyter MCP, #54 n8n-mcp, #67 NightOwl) — для них «не активирован» = ожидаемое состояние, в missed activations не учитываются.
|
||||
|
||||
@@ -1029,7 +971,7 @@ git fetch origin && git log HEAD..origin/main --oneline
|
||||
|
||||
### 16.5. Не override-floor §9
|
||||
|
||||
§16 — рекомендация tier-уровня §13, НЕ explicit hard-rule вне §9. Тремя hard-rules вне §9 остаются §12 (Superpowers), §14 (Ruflo Queen — dormant), §15 (параллельные сессии).
|
||||
§16 — рекомендация tier-уровня §13, НЕ explicit hard-rule вне §9. Тремя hard-rules вне §9 остаются §14 (Ruflo Queen — dormant), §15 (параллельные сессии), §17 (universal skill-coverage — добавляется в Task 5 LLM-first router overhaul, заменяет архивированное §12).
|
||||
|
||||
ADR-011 enforcement через `adr-judge` lefthook job гарантирует существование секции `## Enforcement` в самом ADR.
|
||||
|
||||
@@ -1066,6 +1008,71 @@ Enforcement — механический, не поведенческая про
|
||||
|
||||
---
|
||||
|
||||
## 17. Universal skill-coverage rule
|
||||
|
||||
Введено 2026-05-25 как часть LLM-first router overhaul (Phase 1 Task 5). Замещает архивированное §12 «Superpowers hard rule» (см. `docs/archive/llm-bootstrap-2026-05/pravila-12/Pravila_section_12.md`). Архитектурное обоснование — [ADR-016](adr/ADR-016-section17-universal-skill-coverage.md).
|
||||
|
||||
### 17.1. Принцип
|
||||
|
||||
Все задачи, кроме явных `conversation`, `micro` или `manual_override`, должны быть покрыты skill или цепочкой из реестра `docs/registry/nodes.yaml`. Direct-исполнение допустимо только в 5 exempt-классах §17.2.
|
||||
|
||||
### 17.2. Exempt-классы (когда direct OK)
|
||||
|
||||
1. **Conversation** — короткие prompt'ы (length < 15 OR в `CONVERSATION_PATTERNS`) без anchor.
|
||||
2. **Micro** — тривиальные правки (опечатка / переименование / format / bump).
|
||||
3. **Manual override** — явное указание заказчика «делай через X».
|
||||
4. **Acknowledgment / Cancellation** — короткие follow-up'ы без продолжения работы (обрабатываются prefilter'ом как conversation → direct OK).
|
||||
5. **Escape-hatch** — `<!-- routing: direct_justified=true reason="..." -->` в начале хода.
|
||||
|
||||
### 17.3. Continuation НЕ exempt (D1)
|
||||
|
||||
«Да», «делай», «дальше» и аналогичные коротыши **наследуют** классификацию предыдущего хода. Если та была non-conversation (feature / bugfix / refactor / planning / analysis / security / marketing / ...), §17 enforcement применяется как обычно — direct запрещён. `NON_BLOCKING_TASK_TYPES` в `tools/router-tool-gate.mjs` содержит только `conversation` / `micro` / `manual_override`; continuation там нет, и это **намеренно** (закрывает D1, см. ADR-016 §boundaries).
|
||||
|
||||
### 17.4. Enforcement
|
||||
|
||||
Через `tools/router-tool-gate.mjs` + классификатор `tools/router-classifier.mjs`. Mode читается из `~/.claude/runtime/router-gate-mode.json`:
|
||||
|
||||
- `off` — гейт выключен (для отладки или отката).
|
||||
- `warn-only` — нарушение инжектируется в context как warning, не блокирует tool-вызов.
|
||||
- `enforce` — нарушение блокирует tool-вызов с reason.
|
||||
|
||||
Default на момент Phase 2 bootstrap — `warn-only`; переход на `enforce` — отдельным решением заказчика после анализа baseline (см. ADR-016 §rollout).
|
||||
|
||||
### 17.5. Статус
|
||||
|
||||
§17 — **не hard-rule под §9 «Отступления»**, но его enforcement — механический хук, не tier-§13-правило. §9 формально применяется (заказчик может временно поднять mode → off через runtime-flag), но рационализация типа «эта задача проще, чем требует skill» не работает: гейт оперирует на классификаторе и цепочке, не на оценке Claude. Замещает §12 полностью — историческая ссылка `docs/archive/llm-bootstrap-2026-05/pravila-12/Pravila_section_12.md`.
|
||||
|
||||
### 17.6. Связь с §16.4
|
||||
|
||||
Missed-activation в §16.4 — это симметричный отчёт о пропусках §17: эпизоды, где non-conversation задача исполнена `direct` без exempt-маркера. Surface в STATUS.md C5 + `/brain-retro`, не блокирует коммит — это сигнал для разбора, не enforcement.
|
||||
|
||||
### 17.7. Coverage announcement (пометка в ответе)
|
||||
|
||||
В каждом ответе на non-conversation задачу Claude **обязан** показать coverage-пометку — одну строку рядом с первым tool-вызовом или в начале текстового блока, формат:
|
||||
|
||||
```text
|
||||
coverage: <channel>:<id> [reason="..." если direct]
|
||||
```
|
||||
|
||||
где `<channel>:<id>` — один из:
|
||||
|
||||
- `skill:<имя>` — задача покрывается скилом (`skill:superpowers:test-driven-development`).
|
||||
- `node:<NN>` — задача покрывается одиночным узлом реестра `docs/registry/nodes.yaml` (`node:62 billing-audit`).
|
||||
- `chain:<L#>` — задача покрывается канонической цепочкой `docs/routing-off-phase.md` (`chain:L15 security-go-live`).
|
||||
- `hook:<имя>` — задача автоматизирована хуком и не требует ручной работы Claude (`hook:lefthook job 10 deptrac`).
|
||||
- `agent:<имя>` — задача делегирована project-агенту из `.claude/agents/` (`agent:normative-sync`).
|
||||
- `direct:<exempt-класс>` — exempt-исполнение из §17.2 (`direct:micro`, `direct:manual_override`, `direct:escape_hatch reason="..."`).
|
||||
|
||||
**Назначение.** Делает выбор канала явным и proverable. Без пометки ревизор в `/brain-retro` не отличает осознанный выбор от молчаливого среза угла, а контролёр C5 в `STATUS.md` не может посчитать coverage-rate. Дополняет §17.1-17.6: enforcement (`router-tool-gate.mjs`) ловит факт нарушения, coverage-пометка фиксирует **намерение**.
|
||||
|
||||
**Граница с routing-тегом §16.7.** Routing-тег (`<!-- routing: provenance=user_directed_method node=... counterfactual=... -->`) обязателен **только** когда метод навязан заказчиком (`user_directed_method`). Coverage-пометка — **всегда** для non-conversation, независимо от provenance. Если оба применимы — оба и пишутся (`coverage:` строка + `<!-- routing: ... -->` HTML-комментарий — параллельно, не дублируют друг друга).
|
||||
|
||||
**Статус.** Observability layer, не enforcement. Отсутствие пометки на non-conversation эпизоде — сигнал для C5 controller, surface в STATUS.md sectionом «missing coverage announcements», **не блокирует** коммит и не препятствует ходу. Hard-rule статус не получает (как §17 в целом — §17.5 не override-floor под §9).
|
||||
|
||||
**Cross-refs.** Реестр узлов `docs/registry/nodes.yaml` (источник `node:NN` идентификаторов). Каноническая таблица цепочек `docs/routing-off-phase.md` (источник `chain:L#`). Парсер `tools/observer-transcript-parser.mjs` извлекает coverage-строку в эпизод (schema v4.4+) — реализация по этому параграфу включает обновление парсера.
|
||||
|
||||
---
|
||||
|
||||
## Что сделано после утверждения
|
||||
|
||||
Заказчик согласовал v1.1-DRAFT (короткий ответ «а» = вариант A: поправить §4.8 и шапку, выпустить v1.1) в сессии 05.05.2026. Claude выполнил:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -162,7 +162,7 @@ VK-постинг покрывает Postiz #81).
|
||||
pdn-152fz-audit #71 (A8). Технический режим аудита ПДн — за #71, не #77.
|
||||
|
||||
- **MKT10** — линт вендоренного: marketingskills #75 исключается из lefthook markdownlint
|
||||
+ cspell (`.claude/skills/marketingskills/**` в ignorePaths) — прецедент MK1 mermaid #37 / CC1 ccpm #41.
|
||||
- cspell (`.claude/skills/marketingskills/**` в ignorePaths) — прецедент MK1 mermaid #37 / CC1 ccpm #41.
|
||||
Self-authored marketing-ru #77 линтуется в обычном режиме.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# ADR-016: §17 Universal skill-coverage — заменяет §12
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-25
|
||||
**Контекст:** LLM-first router overhaul (Phase 1), spec `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md` v2.3, plan `docs/superpowers/plans/2026-05-25-llm-first-router-overhaul.md` v1.2 Task 5.
|
||||
|
||||
## Context
|
||||
|
||||
§12 «Superpowers — hard rule (инвокация skills первой)» (введено 09.05.2026 на явный запрос заказчика) исходило из ограниченного списка из 14 пар «задача → skill» (§12.2 map). За 16 дней эксплуатации обнаружилось:
|
||||
|
||||
1. **Карта §12.2 не покрывает всё.** Новые классы задач (security, marketing, multi-step planning без явного «эпик», analysis-only без коды) не имели чётких маппингов. Заказчик регулярно правил карту вручную.
|
||||
2. **Рационализация пропуска.** Несмотря на §12.4 «hard-rule статус — рационализация нарушение», в episodes-2026-05 (brain-retro #2 + #3) накопились случаи «direct без skill» с post-hoc обоснованием «эта задача проще» — поведение, которое §12 формально запрещал, но не enforce'ил механически.
|
||||
3. **Skill-discipline хуки** (`skill-marker.py` + `skill-check.py`) работали как «speed-bump», а не как блокирующая защита — bypass через Bash был тривиален (см. memory `feedback_superpowers_hard_rule`).
|
||||
4. **Continuation case (D1).** «Делай», «дальше», «продолжай» — короткие коротыши без анкера, формально fail на §12 (нет skill в карте) → классифицировались как `direct` → накапливали missed-activations. brain-retro #3 (23.05.2026) показал 7/15 missed-activations были такого рода после очистки шума маппинга (memory `feedback_feature_via_writing_plans`).
|
||||
|
||||
Brain governance (ADR-011) уже ввёл наблюдателя + 5 контролёров C1-C5 + registry `docs/registry/nodes.yaml` как single source of truth. Inside Phase A/B/C наблюдатель пишет episodes с classifier output (`task_classification`, `node_chosen`, `triggers_matched`, etc) — у нас есть **данные** о реальных пропусках.
|
||||
|
||||
LLM-first router overhaul (spec v2.3, plan v1.2) предлагает **universal skill-coverage** как замену §12: вместо закрытого списка задача→skill, classifier (Sonnet 4.6 + памятка) на каждом ходе решает class задачи (`conversation` / `micro` / `manual_override` / `feature` / `bugfix` / ...) и enforcement-гейт блокирует direct на non-exempt классах. Closed list (§12.2) → open universe via classifier.
|
||||
|
||||
## Decision
|
||||
|
||||
**§12 «Superpowers hard rule» архивируется.** Текст переносится в `docs/archive/llm-bootstrap-2026-05/pravila-12/Pravila_section_12.md` (выполнено Phase 1 Task 4, commit `bca63fc6`).
|
||||
|
||||
**§17 «Universal skill-coverage rule» добавляется** (Phase 1 Task 5, this commit). Полная формулировка — Pravila §17. Ключевые тезисы:
|
||||
|
||||
1. **Default-deny на non-conversation задачах.** Все задачи, кроме явных `conversation` / `micro` / `manual_override`, должны быть покрыты skill или цепочкой из `docs/registry/nodes.yaml`. Direct-исполнение допустимо только в 5 exempt-классах §17.2.
|
||||
2. **Classifier как источник exempt-decisions.** Не закрытый список пар, а классификатор (Sonnet 4.6 + памятка, активируется Phase 2 Task 10) определяет class задачи; exempt = `conversation` ∪ `micro` ∪ `manual_override` ∪ acknowledgment/cancellation prefilter ∪ escape-hatch.
|
||||
3. **Continuation НЕ exempt (D1).** «Да», «делай», «дальше» наследуют classification предыдущего хода; если та была non-conversation — §17 применяется как обычно. `NON_BLOCKING_TASK_TYPES` в router-tool-gate содержит только `conversation` / `micro` / `manual_override`; continuation там нет, и это намеренно.
|
||||
4. **Enforcement через `tools/router-tool-gate.mjs`.** Mode = `~/.claude/runtime/router-gate-mode.json` ∈ `{off, warn-only, enforce}`. Default Phase 2 bootstrap — `warn-only`.
|
||||
5. **§17 — не hard-rule под §9.** Заказчик может временно перевести mode → `off` (runtime-flag). Но рационализация типа «эта задача проще» не работает: гейт оперирует на classifier output, не на оценке Claude.
|
||||
6. **Связь с §16.4.** Missed-activation в §16.4 = симметричный отчёт о пропусках §17. Surface в STATUS.md C5 + `/brain-retro`, не блокирует.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Положительные
|
||||
|
||||
- **Universal coverage.** Любая новая категория задач (security, marketing, audit, etc.) автоматически покрывается классификатором без правки списка §12.2.
|
||||
- **Continuation case закрыт.** D1 (наследование classification на коротких коротышах) явно описан и enforce'ится одинаково с явной non-conversation задачей.
|
||||
- **Механический enforcement.** Router-tool-gate работает на classifier output + hard-coded exempt list; рационализация Claude через переформулировку не работает — гейт не читает текст хода.
|
||||
- **Откатываемость.** 9-флаговая система (см. plan §10) позволяет выключить любой компонент независимо (`router-gate-mode → off`, `router-classifier-mode → regex-fallback`, etc.). Полный откат через `tools/test-rollback.mjs --execute` + `git reset --hard brain-pre-llm-bootstrap` (commit `9d4a30c3`).
|
||||
- **Evidence loop.** Каждый ход пишет `classifier_output` в episode JSONL; brain-retro раз в 1-2 недели разбирает paterns, опционально дистиллирует regex-правила (Phase 4 через ~6 месяцев).
|
||||
|
||||
### Отрицательные / риски
|
||||
|
||||
- **Стоимость классификатора.** Sonnet 4.6 на каждом ходе — оценка $320-1370/мес на bootstrap (spec §10). Без daily cap, только monitoring через STATUS.md. Принято осознанно как «плата за качество данных» (заказчик 2026-05-25).
|
||||
- **Период несогласованности.** Phase 2 bootstrap — `warn-only`; реальный enforce только после явного решения заказчика. До этого §17 действует только как обещание, поведенчески ничего не меняется.
|
||||
- **Classifier-cost vs. человеческая оценка.** Возможны ложные классификации (например, рутинный bugfix → classifier label feature). Это нарушения, которые brain-retro подсветит в sanity-check, но они засоряют warn-only метрики.
|
||||
- **Регрессия зависит от nodes.yaml gaps.** Если узел реестра не имеет `triggers[].classification` для данного class задачи — classifier выдаст `task_type=feature` но `recommended_node=null`. Router-tool-gate сегодня блокирует на `no_skill_found_block` (см. spec §4.4). При неполном реестре это false-block. Phase 2 Task 8 добавляет `capabilities:` на ~85 узлов, что снижает риск.
|
||||
|
||||
### Не влияет на
|
||||
|
||||
- §1-§11 Pravila — без изменений (§11 «Superpowers override §2.2/§4.5/§8.4» остаётся; экономия 0%/5%/25%/50%/75%/100% сохраняется).
|
||||
- §13 (Frontend Design plugin paired stack) — без изменений.
|
||||
- §14 (Ruflo Queen routing — dormant) — без изменений.
|
||||
- §15 (Параллельные сессии) — без изменений.
|
||||
- §16 (Brain governance — наблюдатель + контролёры C1-C6) — §16.4 minor update (cross-ref на nodes.yaml вместо JSON-карты, сделано Task 4); §16.5 hard-rule list update (§12 → §17, сделано Task 4).
|
||||
- Schema БД, открытые вопросы, ADR-001…ADR-015 — не трогаются.
|
||||
- Production code портала liderra.ru — overhaul затрагивает только Claude-meta-слой (router, observer), не application code.
|
||||
|
||||
## Boundaries
|
||||
|
||||
| Сценарий | §17 применяется? | Почему |
|
||||
|---|---|---|
|
||||
| `feature` task type + skill recommended | Да, требует skill | Default-deny на non-conversation |
|
||||
| `feature` task + классификатор не нашёл подходящий skill | Да, блокирует на `no_skill_found_block` | Сигнал, что реестр неполон |
|
||||
| `bugfix` task + явное «делай через TDD» в prompt | Нет, `manual_override` exempt | П.3 §17.2 |
|
||||
| Continuation «делай» после `feature` predecessor | Да, наследует non-conversation classification | П.3 §17.3 (D1) |
|
||||
| Continuation «спасибо» / «отлично» | Нет, `conversation` через prefilter | П.4 §17.2 |
|
||||
| `<!-- routing: direct_justified=true reason="..." -->` в начале хода | Нет, escape-hatch | П.5 §17.2 |
|
||||
| Q&A заказчика без действий над кодом | Нет, `conversation` | П.1 §17.2 |
|
||||
| Опечатка в комментарии / переименование переменной | Нет, `micro` | П.2 §17.2 |
|
||||
| `<!-- routing: skill="brainstorming" -->` без него | Да (но prefilter уже даёт `manual_override` → exempt) | П.3 §17.2 |
|
||||
| ПДн handling, gitleaks pre-commit | НЕ override-ится — §5 + technical compensators выше §17 | §17.5 «замещает §12», но не §5 |
|
||||
|
||||
## Enforcement
|
||||
|
||||
1. **Hook chain.** `tools/router-tool-gate.mjs` подписан на `PreToolUse:Edit|Write|MultiEdit|Bash`. На каждый tool-вызов читает `~/.claude/runtime/router-state-<session>.json` (записан router-prehook на UserPromptSubmit), извлекает `classifier_output.task_type` + `recommended_node` + `skillInvokedThisTurn`. Применяет логику §17.4 (`shouldBlock`).
|
||||
2. **Mode hot-reload.** Каждый tool-вызов перечитывает `~/.claude/runtime/router-gate-mode.json`. Заказчик может перевести `off` ↔ `warn-only` ↔ `enforce` без рестарта сессии.
|
||||
3. **adr-judge.** При попытке Edit на нормативке (`Pravila_raboty_Claude_v1_1.md`, `docs/Plugin_stack_rules_v1.md`, `Tooling_v8_3.md`, `CLAUDE.md`) — adr-judge lefthook job pre-commit (job 9, см. `lefthook.yml`) проверяет, что новые правки не нарушают принятые ADR. ADR-016 декларирует «§17 заменяет §12»: попытка вернуть §12 в Pravila требует sup среды-ADR (опровергнуть/superseded).
|
||||
4. **brain-retro discipline.** Раз в 1-2 недели `/brain-retro` skill читает episodes за период, считает sanity-check coverage (`disciplinePercentByClassification`, `routerStepReached`, `boundariesAppliedRate` из `tools/discipline-metrics.mjs`), сравнивает с предыдущим периодом. Расхождение > порога → сигнал в notes.
|
||||
5. **STATUS.md C5.** `tools/observer-coverage-checker.mjs` (lefthook job 15, warn-only) считает missed-activations + observer registration; surface в `docs/observer/STATUS.md`.
|
||||
|
||||
## Rollback
|
||||
|
||||
Полный откат §17 → §12:
|
||||
|
||||
```bash
|
||||
# 1. Restore user-level (settings.json with skill-marker/skill-check; runtime flags)
|
||||
node tools/test-rollback.mjs --execute
|
||||
|
||||
# 2. Restore git-tracked (Pravila §12 + ADR-016 absent + router-tool-gate revert + lefthook + ...)
|
||||
git reset --hard brain-pre-llm-bootstrap # tag at 9d4a30c3
|
||||
|
||||
# 3. Reinstall deps
|
||||
npm install
|
||||
```
|
||||
|
||||
ROLLBACK runbook: `docs/archive/llm-bootstrap-2026-05/ROLLBACK.md` (verified end-to-end in Phase 1 Task 1 smoke proof, commit `dc7fd579`).
|
||||
|
||||
## Cross-refs
|
||||
|
||||
- **Pravila §17** — текст правила (introduced this commit).
|
||||
- **Pravila §16.4** — обновлено в Task 4 (commit `bca63fc6`) с cross-ref на nodes.yaml.
|
||||
- **Pravila §12** — архивировано в Task 4 (`docs/archive/llm-bootstrap-2026-05/pravila-12/Pravila_section_12.md`).
|
||||
- **ADR-011** brain-governance — §16 enforcement через 5 контролёров; ADR-016 опирается на observer evidence из ADR-011.
|
||||
- **spec** `docs/superpowers/specs/2026-05-24-llm-first-router-overhaul-design.md` §6, §4.4.
|
||||
- **plan** `docs/superpowers/plans/2026-05-25-llm-first-router-overhaul.md` Task 5.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Rollback Runbook — LLM-first router overhaul
|
||||
|
||||
**Anchor commit/tag:** `brain-pre-llm-bootstrap` → `9d4a30c3` (origin/main on 2026-05-25, before any Phase 1 destruction).
|
||||
|
||||
**When to use this:** any time the LLM-first overhaul (Phase 1/2/3) needs to be reverted in full. Partial rollback is via runtime flags (`~/.claude/runtime/*-mode.json`), not this runbook.
|
||||
|
||||
**Time to revert:** ~5 min (mechanical) + dependency reinstall.
|
||||
|
||||
## What this rollback restores
|
||||
|
||||
| Layer | Source of truth | Restore mechanism |
|
||||
|---|---|---|
|
||||
| Git-tracked files | tag `brain-pre-llm-bootstrap` | `git checkout brain-pre-llm-bootstrap -- .` |
|
||||
| User settings (`~/.claude/settings.json`) | `settings-snapshot/user-settings.json.pre-overhaul` | `tools/test-rollback.mjs --execute` |
|
||||
| User hooks (`~/.claude/hooks/*`) | `user-hooks/` (14 files snapshot) | `tools/test-rollback.mjs --execute` (full directory restore: wipes new hooks, restores snapshot) |
|
||||
| Runtime flags (`~/.claude/runtime/*-mode.json`) | `runtime-flags-snapshot/` (only `router-gate-mode.json` at snapshot time) | `tools/test-rollback.mjs --execute` (strategy `restore-snapshot-delete-new`: deletes flags absent in snapshot, copies snapshot files back) |
|
||||
| Node deps | `package-lock.json` from tag | `npm install` |
|
||||
|
||||
## What this rollback does NOT touch (intentional)
|
||||
|
||||
- `docs/observer/episodes-*.jsonl` — preserved (G6). Evidence accumulated during the experiment stays. Schema v4 episodes remain readable after rollback because the parser is forward-compatible (graceful skip of unknown schema versions — Task 15 / G5).
|
||||
- `docs/observer/notes/*` — preserved.
|
||||
- Database / production state — out of scope. This overhaul does not touch the portal's runtime.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Verify rollback is ready (dry-run)
|
||||
|
||||
```bash
|
||||
cd <repo root>
|
||||
node tools/test-rollback.mjs --dry-run
|
||||
```
|
||||
|
||||
Expected: `[dry-run] OK — rollback ready` and exit 0. If `MISSING ...` lines appear — **STOP**, fix the missing artefact first.
|
||||
|
||||
### Step 2 — Restore user-level state + runtime flags
|
||||
|
||||
```bash
|
||||
node tools/test-rollback.mjs --execute
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
- `[execute] restored ~/.claude/settings.json`
|
||||
- `[execute] restored ~/.claude/hooks/ (14 files)`
|
||||
- `[execute] runtime flags: deleted N new, restored 1 from snapshot`
|
||||
- `[execute] user-level + flags restored. Now run: git checkout brain-pre-llm-bootstrap -- . && npm install`
|
||||
|
||||
### Step 3 — Restore git-tracked state
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git reset --hard brain-pre-llm-bootstrap
|
||||
git status
|
||||
```
|
||||
|
||||
`git reset --hard <tag>` does both jobs in one shot: tracked files that EXISTED in the tag are restored to their tag content, and tracked files that were ADDED during the overhaul (e.g. `tools/test-rollback.mjs`, `tools/router-config.mjs`, `docs/archive/llm-bootstrap-2026-05/*`) are removed from the working tree.
|
||||
|
||||
**Why not `git checkout brain-pre-llm-bootstrap -- .`** (the naive command): `checkout -- <pathspec>` only restores files present in the target ref. Files committed during the overhaul but absent in the tag are left on disk and remain staged — the end-to-end smoke during Task 1 caught this. Use `reset --hard` instead.
|
||||
|
||||
Untracked files (never committed) survive `reset --hard`:
|
||||
|
||||
- `docs/observer/episodes-*.jsonl` — preserved by design (G6).
|
||||
- `docs/observer/notes/*` — preserved.
|
||||
- Any local scratch files — preserved.
|
||||
|
||||
If you want a fully hermetic revert that also wipes untracked files, follow with (use with care — also kills .gitignore'd local-only artefacts):
|
||||
|
||||
```bash
|
||||
git clean -fd --exclude=docs/observer/episodes-*.jsonl --exclude='docs/observer/notes/*' --exclude=.env --exclude=node_modules
|
||||
```
|
||||
|
||||
### Step 4 — Reinstall dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Reverts `node_modules/` to the pre-overhaul tree (`@xenova/transformers` etc. removed; `package-lock.json` already restored by Step 3).
|
||||
|
||||
### Step 5 — Smoke verification
|
||||
|
||||
```bash
|
||||
npx vitest run tools/ # all GREEN, no test-rollback or new modules
|
||||
ls ~/.claude/hooks/ | sort # contains skill-marker.py + skill-check.py
|
||||
cat ~/.claude/runtime/router-gate-mode.json # warn-only
|
||||
git log --oneline -1 # brain-pre-llm-bootstrap (9d4a30c3)
|
||||
```
|
||||
|
||||
Re-start Claude Code session to pick up restored user hooks.
|
||||
|
||||
## Snapshot manifest (from → to during execute)
|
||||
|
||||
| From (in archive) | To (live) |
|
||||
|---|---|
|
||||
| `settings-snapshot/user-settings.json.pre-overhaul` | `~/.claude/settings.json` |
|
||||
| `user-hooks/*` | `~/.claude/hooks/*` (full replace) |
|
||||
| `runtime-flags-snapshot/*.json` | `~/.claude/runtime/*.json` (new flags deleted) |
|
||||
| `nodes-yaml-archive/nodes.yaml.pre-overhaul` | `docs/registry/nodes.yaml` (via `git checkout` in Step 3) |
|
||||
| `settings-snapshot/project-settings.json.pre-overhaul` | `.claude/settings.json` (via `git checkout` in Step 3) |
|
||||
|
||||
## Failure modes
|
||||
|
||||
- **Tag missing**: `MISSING git tag: brain-pre-llm-bootstrap`. Recreate from the commit it pointed to (`git tag brain-pre-llm-bootstrap 9d4a30c3`).
|
||||
- **Snapshot file missing**: same `--dry-run` will name it. Snapshots are also reachable via `git show brain-pre-llm-bootstrap:docs/archive/llm-bootstrap-2026-05/...` after Task 1 commit — never lose them.
|
||||
- **User hooks partial restore**: `--execute` wipes the live hooks dir before restoring. If the snapshot is corrupted, Claude Code will start without hooks (graceful degrade) — restore from `git show`.
|
||||
|
||||
## Verification log
|
||||
|
||||
End-to-end smoke proof of this rollback was executed BEFORE any destructive Phase 1/2/3 work — see Task 1 Step 9 in `docs/superpowers/plans/2026-05-25-llm-first-router-overhaul.md` and the test-rollback commit message.
|
||||
@@ -0,0 +1,339 @@
|
||||
# Task log — LLM-first router overhaul (phase 1)
|
||||
|
||||
This file tracks the per-task progression of Phase 1, recording user-level
|
||||
state changes (not in git) so the audit trail survives the overhaul.
|
||||
|
||||
## Task 1 — Rollback infra ⭐ (commit `dc7fd579`, 2026-05-25)
|
||||
|
||||
Established and proved a full rollback mechanism BEFORE any destructive step.
|
||||
|
||||
- Git tag `brain-pre-llm-bootstrap` → `9d4a30c3` (origin/main pre-overhaul).
|
||||
- Archive structure `docs/archive/llm-bootstrap-2026-05/` with 8 subdirs.
|
||||
- Snapshots: `~/.claude/settings.json`, all 14 hooks in `~/.claude/hooks/`,
|
||||
`~/.claude/runtime/router-gate-mode.json`, `docs/registry/nodes.yaml`,
|
||||
project `.claude/settings.json`.
|
||||
- `tools/test-rollback.mjs` + 3 TDD tests (GREEN).
|
||||
- `ROLLBACK.md` runbook with from→to manifest.
|
||||
- E2E smoke proof (Task 1 Step 9) verified user-level + git-tracked rollback,
|
||||
Task 1 untracked files survived. Smoke caught a bug in the plan's procedure
|
||||
(`git checkout tag -- .` + `--soft` does NOT delete files committed after
|
||||
the tag — `git reset --hard tag` is correct). ROLLBACK.md uses `--hard`.
|
||||
|
||||
## Task 2 — Remove §12 skill-discipline, keep economy (2026-05-25)
|
||||
|
||||
Removed §12 enforcement hooks from the live user environment; the economy
|
||||
system (0% / 5% / 75% / 100%, etc.) remains fully active.
|
||||
|
||||
**Changes to `~/.claude/settings.json`** (live user file, not in git):
|
||||
|
||||
- Removed PreToolUse block `matcher: "Skill"` → `skill-marker.py` (§12 trigger).
|
||||
- Removed PreToolUse block `matcher: "Edit|Write|MultiEdit"` →
|
||||
`skill-check.py` (§12 enforcement on Edit/Write).
|
||||
- Remaining PreToolUse: 1 block — `matcher: "Edit|Write|MultiEdit|Bash|Agent"`
|
||||
→ `economy-state-guard.py` (pure economy concern, kept).
|
||||
- All UserPromptSubmit / PostCompact / SessionStart / Stop hooks unchanged.
|
||||
|
||||
**Changes to `~/.claude/hooks/economy-mode.py`** (live user file):
|
||||
|
||||
- Line ~337: replaced trailing reminder
|
||||
«§12 hard rule из Pravila НЕ override-ится этим режимом — на всех уровнях.»
|
||||
→ «§17 universal skill-coverage НЕ override-ится этим режимом — на всех уровнях.»
|
||||
- All economy logic (LEVELS dict, parse_level, closest_level, state file
|
||||
write) unchanged.
|
||||
- The references to `§12.2` inside `LEVELS[5]["rules"]` and `LEVELS[100]["rules"]`
|
||||
remain — those describe process gates and are migrated to `§17` cross-refs
|
||||
in Task 6.
|
||||
|
||||
**Changes to `~/.claude/hooks/economy-state-guard.py`** (live user file):
|
||||
|
||||
- NO-OP. Inspected for §12 skill-discipline logic; the file is pure
|
||||
economy (BASH_FILE_MOD_PATTERNS is the test-cadence reminder, not §12
|
||||
enforcement). Plan Step 3 allows no-op for pure-economy guards.
|
||||
|
||||
**Files NOT removed** (only their PreToolUse triggers were unwired):
|
||||
|
||||
- `~/.claude/hooks/skill-marker.py` — still on disk, no longer invoked.
|
||||
- `~/.claude/hooks/skill-check.py` — still on disk, no longer invoked.
|
||||
|
||||
These two files move into `docs/archive/.../user-hooks/` archive in Task 4
|
||||
(snapshot is already in archive from Task 1).
|
||||
|
||||
**Permissions.ask still references** `skill-marker.py` / `skill-check.py` —
|
||||
4 entries (Edit/Write on each). Left as-is; they only require permission
|
||||
for direct file edits, no enforcement. Cleaned up alongside Task 4.
|
||||
|
||||
**Verification:**
|
||||
|
||||
- `~/.claude/settings.json` parses as valid JSON; `hooks.PreToolUse` length = 1.
|
||||
- All 4 economy hooks still run with exit 0 on `< /dev/null`.
|
||||
- Live `economy-mode.py` run with prompt «тест экономия 5%» returns valid
|
||||
JSON with FIRST LINE `=== ECONOMY MODE: 5%` and trailer mentioning `§17`,
|
||||
no `§12 hard rule` substring.
|
||||
|
||||
**Rollback path**: `node tools/test-rollback.mjs --execute` restores
|
||||
`~/.claude/settings.json` (with skill-marker/skill-check PreToolUse blocks)
|
||||
and overwrites `economy-mode.py` from snapshot. Verified end-to-end in Task 1.
|
||||
|
||||
## Task 3 — Inventory `tools/discipline-metrics.mjs` (2026-05-25)
|
||||
|
||||
**Decision: KEEP** (no code change).
|
||||
|
||||
Read `tools/discipline-metrics.mjs` (115 lines, 3 exports, 19 passing tests).
|
||||
|
||||
The module is NOT only-§12. Three functions, all surviving the §12→§17 migration:
|
||||
|
||||
1. `disciplinePercentByClassification(episodes, classificationMap)` —
|
||||
counts skill-coverage % per task classification. Currently sourced from
|
||||
`tools/observer-classification-map.json`; Task 11 re-sources it from
|
||||
`docs/registry/nodes.yaml` (capabilities + triggers per node). The metric
|
||||
shape stays — §17 universal skill-coverage is the same intent expressed
|
||||
differently (was-skill-used vs default-deny-non-conversation).
|
||||
|
||||
2. `deriveRouterStep(pr)` — infers reached router-procedure stage (1..5)
|
||||
from observable `primary_rationale` features (classification, triggers,
|
||||
chain_ref, node_chosen). General router-procedure metric, untouched.
|
||||
|
||||
3. `boundariesAppliedRate(episodes)` — fraction of episodes with non-empty
|
||||
boundaries_applied, grouped by `path_type`. General metric, untouched.
|
||||
|
||||
Consumers (re-verified before decision):
|
||||
|
||||
- `tools/brain-retro-analyzer.mjs` — calls all three for the brain-retro
|
||||
factor matrix (already shipped in router-overhaul stage 2, commit
|
||||
`b8adeeb9` on feature branch).
|
||||
- `tools/status-md-generator.mjs` — surfaces «Метрики дисциплины» block
|
||||
in `docs/observer/STATUS.md`.
|
||||
|
||||
Tests: `tools/discipline-metrics.test.mjs` 19 tests, all GREEN in baseline
|
||||
and after Task 1-2 work (verified in Task 2 post-commit STATUS.md regen).
|
||||
|
||||
Plan Task 3 step «only-§12 → archive, общий path_type → keep» applies: KEEP.
|
||||
|
||||
## Task 4 — Archive §12 + routing-docs + memory files (2026-05-25)
|
||||
|
||||
Phase 1 Task 4 of LLM-first router overhaul. Heaviest task of Phase 1.
|
||||
|
||||
User chose «aggressively per plan» (AskUserQuestion 2026-05-25) after the
|
||||
session surfaced 4 plan deviations vs reality. Adapted execution below.
|
||||
|
||||
### What was archived (literal)
|
||||
|
||||
1. **Pravila §12** (lines 678-748 of `docs/Pravila_raboty_Claude_v1_1.md`):
|
||||
extracted to `pravila-12/Pravila_section_12.md`, replaced in Pravila by a
|
||||
1-paragraph placeholder pointing to §17 (Task 5) + the archive file +
|
||||
ADR-016 (Task 5). Cross-refs §0 priority chain, §0 «Особый статус» note,
|
||||
§16.4, §16.5 — all updated to drop §12 and reference §17 forward.
|
||||
|
||||
2. **`tools/observer-classification-map.json`** — JSON mapping
|
||||
classification → recommended_node_ids. After Task 4 refactor (below) had
|
||||
no code consumers. Archived via `git mv`.
|
||||
|
||||
3. **`tools/.node-dormancy.json`** — auto-generated dormancy map (Tooling
|
||||
§3.5/§4.X scrape, two signals: `dormant: true` OR `DEFERRED` in boundaries).
|
||||
Single consumer was missed-activations.mjs via the JSON; after Task 4
|
||||
refactor consumers read `status` from `docs/registry/nodes.yaml` directly
|
||||
via `buildDormancyMap` adapter. Archived via `git mv`.
|
||||
|
||||
4. **`tools/extract-node-dormancy.mjs`** + **`tools/extract-node-dormancy.test.mjs`**
|
||||
— generator + 7 tests for `.node-dormancy.json`. Archived via `git mv`.
|
||||
`lefthook.yml` job 12b «extract-node-dormancy» removed (replaced by a
|
||||
removal note pointing to `nodes.yaml status:` as the new source).
|
||||
|
||||
5. **`memory/feedback_superpowers_hard_rule.md`** + **`memory/feedback_feature_via_writing_plans.md`**
|
||||
(user-level, NOT git-tracked at
|
||||
`~/.claude/projects/c---------------------crm-------------/memory/`):
|
||||
copied to `docs/archive/.../memory/` via filesystem cp (plan said `git mv`
|
||||
— wrong, memory files live outside the repo on this machine). Originals
|
||||
left in place on disk; MEMORY.md (also user-level) updated to remove the
|
||||
two index lines and replace them with an «ARCHIVED 2026-05-25» pointer.
|
||||
|
||||
### Code refactor (consequence of the JSON archive)
|
||||
|
||||
The aggressive-per-plan choice required switching the two remaining
|
||||
JSON-direct consumers to the registry adapter pattern (other consumers —
|
||||
`brain-retro-analyzer.mjs`, `status-md-generator.mjs`, `missed-activations.mjs`
|
||||
— already used the adapter):
|
||||
|
||||
1. **`tools/observer-coverage-checker.mjs`**: `loadClassificationMap(root)`
|
||||
and `loadDormancy(root)` switched from `readFileSync(...json)` to
|
||||
`loadRegistry({ registryPath: <root>/docs/registry/nodes.yaml, useCache: false })`
|
||||
plus `buildClassificationMap` / `buildDormancyMap`. 9/9 tests GREEN.
|
||||
|
||||
2. **`tools/observer-transcript-parser.mjs`**: `getClassificationMap()` and
|
||||
`getDormancy()` switched similarly, using the cached default-path
|
||||
`loadRegistry()` (parser is always invoked from `tools/`). 154/154 tests
|
||||
GREEN — clean drop-in replacement, no classification-shape drift.
|
||||
|
||||
### Plan deviations (documented)
|
||||
|
||||
The plan's literal Task 4 said «archive everything including
|
||||
`tools/registry-to-classification-map.mjs` and `docs/routing-off-phase.md` /
|
||||
`docs/router-procedure.md`». Inspection revealed:
|
||||
|
||||
- **`tools/registry-to-classification-map.mjs`** has 4+ active consumers
|
||||
(brain-retro-analyzer, status-md-generator, missed-activations callers,
|
||||
plus the 2 newly-migrated above). It IS the canonical
|
||||
yaml→classification-map / yaml→dormancy-map adapter — keeping it is
|
||||
correct engineering. Plan's framing «adapter is deprecated» was wrong.
|
||||
**Status: KEEP, not archived.** A future task can inline its logic into
|
||||
consumers if «direct yaml read» is strictly required, but that is a
|
||||
separate refactor.
|
||||
|
||||
- **`docs/routing-off-phase.md`** is **auto-generated by
|
||||
`tools/registry-render.mjs`** from `nodes.yaml`, not a hand-edited doc.
|
||||
Archiving it would break the render pipeline + the C6 brain-governance
|
||||
controller (`tools/observer-chain-map-checker.mjs`) which reads it.
|
||||
**Status: NOT ARCHIVED.** This is a derivative, not a source.
|
||||
|
||||
- **`docs/router-procedure.md`** is similarly suspected of being either a
|
||||
derivative or referenced by active controllers; archival deferred to
|
||||
a separate audit.
|
||||
|
||||
### Verification
|
||||
|
||||
- Full `npx vitest run tools/`: **539 passed** (delta: −7 from archived
|
||||
`extract-node-dormancy.test.mjs`, +3 from `test-rollback.test.mjs`
|
||||
added in Task 1; baseline 543 → 539 expected ✓). The 4 pre-existing
|
||||
«No test suite found» failures on `tools/ruflo-*.test.mjs` and
|
||||
`tools/subagent-prompt-prefix.test.mjs` are out of scope and unchanged.
|
||||
- Pre-commit (gitleaks + markdownlint + cspell) — verified at commit time.
|
||||
|
||||
### Rollback
|
||||
|
||||
`node tools/test-rollback.mjs --execute` restores user-level state.
|
||||
`git reset --hard brain-pre-llm-bootstrap` restores Pravila, the 4
|
||||
archived `tools/` files, `lefthook.yml` job 12b, `observer-coverage-checker.mjs`,
|
||||
and `observer-transcript-parser.mjs` to pre-overhaul state.
|
||||
|
||||
## Task 6 — Cross-refs §12 → §17 (minimal scope) (2026-05-25)
|
||||
|
||||
Phase 1 Task 6 of LLM-first router overhaul. Executed in **minimal scope**
|
||||
after reality check; full plan deviations documented below.
|
||||
|
||||
### Reality check (before execution)
|
||||
|
||||
- **C1 l1-watcher**: ran clean (0 drift) on current state. Source is Tooling
|
||||
plugin-name search, not CLAUDE.md §3.3. Plan's «source §3.3 → nodes.yaml»
|
||||
was misdirected — no adaptation needed.
|
||||
- **C2 cross-ref-checker**: FAILED on version drift (CLAUDE.md → Pravila
|
||||
v1.40, Tooling → Pravila v1.39, after Task 5 bump to v1.41). Code logic
|
||||
is purely version-based, not section-based. Plan's «expected cross-refs
|
||||
§12→§17» was misdirected — checker does not track section refs.
|
||||
- §12 occurrences: CLAUDE.md 18, PSR_v1 39, Tooling 18 (total 75).
|
||||
Most are in changelog «v2.X наследие» blocks — historical pointers, not
|
||||
active rules.
|
||||
|
||||
### What was changed (minimal)
|
||||
|
||||
1. `CLAUDE.md` §0 «Источник истины» row for Pravila:
|
||||
`**v1.40 от 24.05.2026**` → `**v1.41 от 25.05.2026**` + narrative bump
|
||||
noting Task 4+5 (§12 archived, §17 added, ADR-016).
|
||||
2. `docs/Tooling_v8_3.md` line 4 cross-ref:
|
||||
`cross-ref Pravila v1.39+ / PSR_v1 v3.22+ / CLAUDE.md v2.27+`
|
||||
→ `cross-ref Pravila v1.41+ / PSR_v1 v3.22+ / CLAUDE.md v2.28+`.
|
||||
|
||||
### What was deferred (plan deviation)
|
||||
|
||||
The plan's literal Task 6 Step 1 («archive §3.3 / R15 / Tooling «когда брать»»)
|
||||
is a large structural restructure of three normative files. Postponed to a
|
||||
separate follow-up task because:
|
||||
|
||||
- `CLAUDE.md §3.3` is the tooling-map index, currently consumed by readers
|
||||
for «which tool for what». Archiving requires replacement with a pin
|
||||
paragraph to `docs/registry/nodes.yaml` — and the §3.3 narrative quality
|
||||
matters for daily use. Out of scope for this minimal cross-ref pass.
|
||||
- `PSR_v1 R15` was already removed in v2.0 (motion-runtime removal,
|
||||
12.05.2026; see `docs/CHANGELOG_claude_md.md` v1.88). The current R15
|
||||
is «Off-phase routing» (v3.14+) — unrelated to §12. No action.
|
||||
- `Tooling §4.X «когда брать»` fields — these are per-tool «when to use it»
|
||||
prose, not §12-specific. Archiving requires structural review out of scope
|
||||
for this commit.
|
||||
|
||||
Active §12 textual cross-refs in `docs/Plugin_stack_rules_v1.md` (39
|
||||
occurrences) and `docs/Tooling_v8_3.md` body (most in historical changelog
|
||||
blocks) — also **deferred**. These now point to the archived §12
|
||||
(`docs/archive/llm-bootstrap-2026-05/pravila-12/Pravila_section_12.md`),
|
||||
which is honest historical record. Active rule replacement is via Pravila
|
||||
§17 (Task 5). Future cleanup can do bulk §12→§17 substitution.
|
||||
|
||||
### Verification
|
||||
|
||||
- `tools/l1-watcher.mjs` exits 0 (no drift).
|
||||
- `tools/cross-ref-checker.mjs` exits 0 («OK — 0 drift in 4 files»).
|
||||
- `npx vitest run tools/`: **539 passed** (unchanged from Task 4 baseline).
|
||||
- 4 pre-existing «No test suite found» failures — out of scope, unchanged.
|
||||
|
||||
### Phase 1 status after Task 6
|
||||
|
||||
5 of 7 Tasks complete + this Task 6 minimal = **6 of 7**. Remaining: Task 7
|
||||
(phase-1 flags + rollback re-verify) closes Phase 1.
|
||||
|
||||
## Task 7 — Phase-1 flags + rollback re-verify (2026-05-25)
|
||||
|
||||
Phase 1 Task 7 of LLM-first router overhaul — closes Phase 1.
|
||||
|
||||
### Flag state after Task 7
|
||||
|
||||
Live `~/.claude/runtime/` flags (user-level, NOT git-tracked):
|
||||
|
||||
- `skill-discipline-mode.json` = `{mode: "off"}` — newly set in this task.
|
||||
Documents that the §12 enforcement hooks (unwired in Task 2) are off.
|
||||
- `router-gate-mode.json` = `{mode: "warn-only"}` — unchanged from
|
||||
pre-overhaul state (was already warn-only). Phase 2 Task 13 will keep
|
||||
warn-only as default; Phase 3+ may bump to enforce by explicit user
|
||||
decision.
|
||||
|
||||
### Rollback re-verify (after all Phase 1 destruction)
|
||||
|
||||
`node tools/test-rollback.mjs --dry-run` → `[dry-run] OK — rollback ready`.
|
||||
|
||||
This is the second proof of rollback readiness (first was Task 1 step 9
|
||||
end-to-end smoke). After 6 commits of destructive Phase 1 work
|
||||
(dc7fd579 → 3073e0cb → 03600acc → bca63fc6 → 712b4c63 → 6d72f5b6), the
|
||||
rollback path is still intact: snapshots present, tag `brain-pre-llm-bootstrap`
|
||||
points to origin/main `9d4a30c3` (pre-overhaul).
|
||||
|
||||
### Phase 1 exit criteria (all met)
|
||||
|
||||
- ✅ Rollback infra established + proven (Task 1).
|
||||
- ✅ §12 skill-discipline hooks unwired from `~/.claude/settings.json`,
|
||||
economy hooks preserved (Task 2).
|
||||
- ✅ `discipline-metrics.mjs` decision recorded — KEEP (Task 3).
|
||||
- ✅ Pravila §12 archived; routing-docs deferred (auto-generated, see
|
||||
Task 4 deviations); 4 routing/dormancy artefacts archived;
|
||||
2 user-level memory files archived; 2 consumers refactored to
|
||||
registry adapter; 539/539 vitest GREEN (Task 4).
|
||||
- ✅ Pravila §17 + ADR-016 added (Task 5).
|
||||
- ✅ Cross-refs §12 → §17 minimal scope + C1/C2 controllers run clean
|
||||
(Task 6).
|
||||
- ✅ Phase-1 flag set; rollback re-verified (this Task 7).
|
||||
|
||||
### Phase 1 commits summary
|
||||
|
||||
| Task | Commit | Files | Net diff |
|
||||
|---|---|---|---|
|
||||
| 1 | `dc7fd579` | 17 | +3700 |
|
||||
| 2 | `3073e0cb` | 3 | +90 / −13 |
|
||||
| 3 | `03600acc` | 2 | +36 / −1 |
|
||||
| 4 | `bca63fc6` | 14 | +382 / −87 |
|
||||
| 5 | `712b4c63` | 4 | +155 / −3 |
|
||||
| 6 | `6d72f5b6` | 4 | +66 / −3 |
|
||||
| 7 | (this commit) | 1+ | +N |
|
||||
|
||||
### Phase 1 → Phase 2 handoff
|
||||
|
||||
Ready to start Phase 2 (Classifier + памятка + inheritance + §17 enforcement,
|
||||
~1-1.5 недели per plan). Phase 2 begins with Task 8 (router-config.mjs +
|
||||
capabilities on ~85 nodes in `docs/registry/nodes.yaml`).
|
||||
|
||||
Phase 2 deferred items from Phase 1:
|
||||
|
||||
- §12 textual cross-refs in PSR_v1 (39 occurrences) — bulk substitution
|
||||
whenever convenient.
|
||||
- CLAUDE.md §3.3 archive + nodes.yaml pin — structural restructure when
|
||||
the classifier is live and §17 enforcement is real (Phase 2 Task 13).
|
||||
- `tools/registry-to-classification-map.mjs` archival — only if direct
|
||||
yaml reads in consumers are required (currently KEEP, 4+ consumers).
|
||||
- `docs/routing-off-phase.md` / `docs/router-procedure.md` — auto-generated
|
||||
derivatives; review whether they remain useful as derived views after
|
||||
Phase 2 classifier replaces routing-procedure execution.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: feedback-feature-via-writing-plans
|
||||
description: "Feature/planning-задачи в Лидерре ИДУТ через superpowers:writing-plans (или brainstorming если ещё нет требований), даже если задача «маленькая» и видна напрямую. Brain-retro"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: feedback
|
||||
originSessionId: 8409f21e-2d54-48b6-8cff-c0fa5e32ba1b
|
||||
---
|
||||
|
||||
**Правило:** для задач классификации `feature` или `planning` (любая новая функциональность портала, даже однострочный endpoint или галочка в UI) сначала инвокирую один из:
|
||||
|
||||
- `superpowers:brainstorming` — если требования ещё не зафиксированы
|
||||
- `superpowers:writing-plans` — если spec уже понятен, нужен implementation план
|
||||
- `superpowers:executing-plans` — если план уже есть и я просто исполняю
|
||||
|
||||
Direct-путь (без skill'а) для feature/planning — **нарушение Pravila §12 hard-rule**, не «оптимизация».
|
||||
|
||||
**Why:** brain-retro #3 (2026-05-23, `docs/observer/notes/2026-05-23-brain-retro.md`) насчитал 7 случаев в дельте 19-23.05 где feature(5)/planning(2) шли autonomous direct без skill. Из 15 «реальных» промахов после очистки шума (A1+A2 23.05) эти 7 — самая большая группа. Расширение [[Superpowers — hard rule §12 (Pravila v1.4)]] (feedback_superpowers_hard_rule): hard-rule уже есть, но я рационализировал «маленькая фича → можно direct». Эта рационализация и есть лазейка, которую §12 закрывает.
|
||||
|
||||
**How to apply:**
|
||||
|
||||
1. **Триггер:** заказчик говорит «сделай X», «добавь Y», «нужна фича Z», «давай спланируем», «допилим». Даже если кажется «один Edit».
|
||||
2. **Перед первым Read/Edit/Write** — инвокирую skill:
|
||||
- Требования не ясны / непонятно «как должно быть» → `superpowers:brainstorming`
|
||||
- Требования ясны, нужно «как сделать» → `superpowers:writing-plans`
|
||||
- План уже есть → `superpowers:executing-plans` (или `subagent-driven-development` если задача делится)
|
||||
3. **Не рационализирую:** «эта фича маленькая», «всё ясно, план не нужен», «один Edit это не feature» — это **рационализации уровня §5 ПДн** (по Pravila §12.4).
|
||||
4. **Исключения** — только если заказчик явно сказал «не используй superpowers сейчас» / «делай напрямую без плана» — и **только** на текущее действие (следующий промпт парсится заново). Pravila §12.4.
|
||||
5. **Скил-discipline хук** уже подсказывает при Edit/Write без skill — не игнорировать reminder для feature/planning, даже если содержание тривиально.
|
||||
|
||||
**Граница vs тривиальные правки:**
|
||||
|
||||
- Тривиальная правка опечатки, JSON-конфига, версии в шапке, переименование переменной — **не** feature/planning, hook reminder можно игнорировать.
|
||||
- Изменение поведения системы (новый эндпоинт, новая колонка БД, новый UI-вью, изменение бизнес-логики, новый job) — **feature**, skill обязателен.
|
||||
- Q&A, аудит, чтение кода, навигация — **не** feature/planning.
|
||||
|
||||
**Источник:** brain-retro #3, 2026-05-23. Кандидат D1 применён по явному «делай» от заказчика.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: Superpowers — hard rule §12 (Pravila v1.4)
|
||||
description: 09.05.2026 заказчик ввёл единственное hard-правило в Pravila: skill из obra/superpowers v5.1.0 инвокируется ПЕРВЫМ для подходящих задач. §9 «Отступления» не применяется. Рационализация = нарушение уровня §5 ПДн.
|
||||
type: feedback
|
||||
originSessionId: 8636df02-dd86-4b5b-90f6-d93a3a6fc448
|
||||
---
|
||||
09.05.2026 заказчик ввёл это правило явной формулировкой: **«Создай правило, что ты всегда в первую очередь пользуешься superpowers. При этом ты не можешь игнорировать и обходить это правило.»** Закреплено как §12 в Pravila v1.4.
|
||||
|
||||
**Why:** В предыдущей итерации (Pravila v1.3 / §11) Superpowers был «разрешён», но без обязательности — заказчик увидел риск, что я буду рационализировать пропуск skill'а («сейчас быстрее без него», «эта задача проще»). Hard-rule убирает эту лазейку — §9 «Отступления» к §12 НЕ применяется.
|
||||
|
||||
**How to apply:**
|
||||
|
||||
1. **Перед любой содержательной задачей** — сначала проверить карту §12.2 правил Claude (14 skills → 14 типов задач):
|
||||
- TDD → `superpowers:test-driven-development`
|
||||
- debug/инцидент → `superpowers:systematic-debugging`
|
||||
- план эпика (≥3 этапа) → `superpowers:writing-plans`
|
||||
- исполнение плана → `superpowers:executing-plans`
|
||||
- brainstorm по запросу → `superpowers:brainstorming`
|
||||
- запрос code review → `superpowers:requesting-code-review`
|
||||
- применение review → `superpowers:receiving-code-review`
|
||||
- финализация feature-ветки → `superpowers:finishing-a-development-branch`
|
||||
- параллельные независимые задачи → `superpowers:dispatching-parallel-agents`
|
||||
- подагенты → `superpowers:subagent-driven-development`
|
||||
- финальная проверка перед сдачей → `superpowers:verification-before-completion`
|
||||
- создание новых skills → `superpowers:writing-skills`
|
||||
- git worktrees (с осторожностью на Windows + кириллица) → `superpowers:using-git-worktrees`
|
||||
- понимание плагина → `superpowers:using-superpowers`
|
||||
|
||||
2. **Если skill применим** — инвокировать его через Skill tool **до** прочих действий. Skill приносит свой workflow, я следую ему.
|
||||
|
||||
3. **Когда §12 НЕ срабатывает** (§12.3): чтение/grep/glob; тривиальные правки (опечатки, версии в шапках, синхронизация ссылок); справочные ответы без действий над кодом; документация уровня §4 (Pravila/Tooling/CLAUDE.md/narrative); работа с открытыми вопросами реестра.
|
||||
|
||||
4. **Запрещённые рационализации** — все эти формулировки = нарушение §12:
|
||||
- «эта задача проще, чем требует skill»
|
||||
- «сейчас быстрее без skill'а»
|
||||
- «это просто debug, обычным способом разберусь»
|
||||
- переформулировка задачи под §12.3 («это просто чтение, хотя на деле full-debug»)
|
||||
|
||||
5. **Единственная разрешённая отмена** — явный запрос заказчика «не используй superpowers сейчас», и **только** на текущее действие. В следующем действии §12 действует автоматически.
|
||||
|
||||
6. **Если забыл инвокировать skill** — заказчик укажет: «§12». Тогда обязательно зафиксировать ошибку в feedback memory для будущих сессий.
|
||||
|
||||
7. **Override-приоритет:** §12 имеет приоритет над §11 (override §2.2/§4.5/§8.4 разрешён автоматически при инвокации skill'а). НЕ override-ятся даже §12: §1 (роль), §3.6 (язык), §5 (ПДн), §7 (финальное закрытие открытых вопросов).
|
||||
|
||||
**Источники:** `docs/Pravila_raboty_Claude_v1_1.md` v1.4 §12 (полный текст 8 подсекций); `CLAUDE.md` v1.77 §1 priority уровень 0 + §5 п.11; коммит `4cac61d`.
|
||||
|
||||
**Контрольный сигнал что правило работает:** в начале нового задания я первым делом упоминаю «по §12.2 это попадает под X — инвокирую `superpowers:Y`» **до** прочих действий, или явно «§12.3 — обычный flow» с указанием категории (тривиальная правка / документация §4 / etc.). Если ни того, ни того — это нарушение, заказчик имеет право указать.
|
||||
|
||||
---
|
||||
|
||||
## Runtime-enforcement: «дисциплина» (skill-discipline hook)
|
||||
|
||||
**Установлено 10.05.2026.** Заказчик: «делай хук» → поставлен runtime-gate в `~/.claude/settings.json`:
|
||||
|
||||
- `~/.claude/hooks/skill-marker.py` — `PreToolUse` matcher `Skill` — пишет флаг `$TEMP/claude-skill-<session_id>.flag` (содержимое = имя skill'а)
|
||||
- `~/.claude/hooks/skill-check.py` — `PreToolUse` matcher `Edit|Write|MultiEdit` — если флаг отсутствует, инжектит `additionalContext` reminder (две формулировки: спец-вариант для CLAUDE.md, общий для остальных файлов)
|
||||
|
||||
**В разговоре заказчик называет это просто «дисциплина»** (например: «дисциплина сработала», «выключи дисциплину», «обнови дисциплину»). Распознавать это слово как ссылку на этот хук, не путать с общей дисциплиной §12.
|
||||
|
||||
**Архитектура:**
|
||||
|
||||
- Per-session: флаг ключуется по `session_id` → каждая сессия независима. Соседние Claude Code сессии параллельно проходят свой gate.
|
||||
- Не блокирует: только эмитит `additionalContext`, не `permissionDecision: "deny"`. Я могу проигнорировать reminder если задача попадает под §12.3 (Q&A, чтение, навигация, тривиальная правка).
|
||||
- Encoding: `ensure_ascii=True` в `json.dumps` — обходит проблему cp1251 stdout на Windows (без этого в reminder приходит мoжибейк).
|
||||
- Bash-обход: хук не ловит правки через `sed`/`Out-File`/etc. в `Bash` tool. Это сознательный пробел — расширение matcher'а на `Bash` дало бы много ложных срабатываний.
|
||||
|
||||
**Подтверждение работоспособности (10.05.2026 18:18):** соседняя сессия `a659b20e-f6b4-46ad-ab7d-53f594962995` в реальном времени вызвала `superpowers:test-driven-development` → marker hook записал флаг → последующие Edit/Write в той сессии проходят молча. Independent end-to-end proof.
|
||||
|
||||
**Как выключить:** `/hooks` UI menu в Claude Code, либо удалить блок `hooks` из `~/.claude/settings.json`, либо `disableAllHooks: true` (отключит ВСЕ хуки, не только этот).
|
||||
|
||||
---
|
||||
|
||||
## Economy hook bypass closure architecture (2026-05-10 финал)
|
||||
|
||||
После adversarial self-analysis (14 hypothesized bypass paths) — установлена hardened архитектура из **6 компонентов** в `~/.claude/hooks/`:
|
||||
|
||||
| # | Component | Event | Покрывает |
|
||||
|---|---|---|---|
|
||||
| 0 | permissions block в settings.json | declarative | H1/H2/H6 (tamper protection через deny+ask) |
|
||||
| 1 | economy-mode.py | UserPromptSubmit | parse end-of-prompt + state write |
|
||||
| 2 | economy-self-check.py | SessionStart | runtime guard (silent failure detection) |
|
||||
| 3 | economy-state-guard.py | PreToolUse Edit/Write/MultiEdit/Bash/Agent | in-turn reminder + Bash bypass detection + subagent inheritance (H7) |
|
||||
| 4 | economy-verifier.py + agent-type hook | Stop | Sonnet 4.6 compliance verifier с decision:'block' on non-compliance |
|
||||
| 5 | economy-postcompact.py | PostCompact | re-inject rules после авто-компакции (H8) |
|
||||
|
||||
Закрыты 8 critical/high bypass paths: H1 (settings disable), H2 (hook script edit), H4 (prompt injection), H6 (state file delete), H7 (subagent inheritance), H8 (PostCompact loss), H9 (retry exhaustion), H10 (verifier endpoint failure), H12 (tool output spoofing), H13 (no-claim bypass).
|
||||
|
||||
**Stage 0 ratchet работает буквально** — auto-mode classifier режет даже subagent Write на hook-files и Bash-обходные действия. Подтверждено в реальной эксплуатации: 10.05.2026 субагент Stage 2 получил NEEDS_CONTEXT, и мой Bash+python heredoc на settings.json — denied.
|
||||
|
||||
Стоимость: ~$7-14/мес (Sonnet 4.6 verifier на ~30% turn'ов).
|
||||
|
||||
Spec: `c:/моя/проекты/портал crm/Документация/docs/superpowers/specs/2026-05-10-economy-hook-bypass-closure-design.md`
|
||||
Plan: `c:/моя/проекты/портал crm/Документация/docs/superpowers/plans/2026-05-10-economy-hook-bypass-closure.md`
|
||||
|
||||
**Не закрытые by design:** translit/English keyword variations (E.1), mojibake homoglyphs (E.3), социальная инженерия (Claude просит rephrase), прямой редактирование settings/hooks пользователем вне Claude Code.
|
||||
|
||||
**18.05.2026 — accepted as-is** (превентивный аудит «мозга», Дмитрий: «б»). Причина: за 8 дней эксплуатации 0 промахов; максимальное последствие промаха = работа строже, не слабее (в сторону пользователя, не против); закрытие требует часов работы + регрессионного риска на сложных хуках. Перечитать раз в квартал — не изменилась ли реальная частота промахов. **Не предлагать закрытие повторно** без явного эпизода промаха в логах.
|
||||
|
||||
---
|
||||
|
||||
## Economy level «5%» (2026-05-16)
|
||||
|
||||
Добавлен новый уровень-якорь `экономия 5%` в `economy-mode.py` `LEVELS` (между 25 и 0; `closest_level` полоса 3–14% → 5; `экономия 10%` теперь → 5, а не 0). Принцип: **`5% = 0% − 6 пунктов избыточности + 6 скоростных правил`** — то же качество и строгость, что 0%, без дублирующей работы.
|
||||
|
||||
6 вырезанных избыточностей: re-read CLAUDE.md (уже в контексте), тесты-после-каждой-правки (→ по логическим блокам), gitleaks-full-history per-commit (→ только pre-push), Stop-верификатор (short-circuit на level 5), авто-гейты brainstorming/writing-plans (→ §12.2-floor, не каждая фича).
|
||||
|
||||
6 скоростных правил (блок A/B3, добавлены 2026-05-16 — секция «СКОРОСТЬ БЕЗ ПОТЕРИ КАЧЕСТВА» в `LEVELS[5]['rules']`): параллельные независимые tool-вызовы; без re-read неизменённых файлов; дешёвая модель на механические субагент-задачи; `run_in_background` на долгие команды; не задавать выводимые из кодовой базы вопросы; фокус/компакт сессии.
|
||||
|
||||
Затронуты 3 хук-файла: `economy-mode.py` (`LEVELS[5]`), `economy-state-guard.py` + `economy-postcompact.py` (`LEVEL_TOPLINE[5]`, две синхронные копии). Тесты: `economy-mode-test.py` 62/62, `economy-state-guard-test.py` 7/7. `LEVELS[0]` — байт-в-байт неизменён (жёсткий инвариант).
|
||||
|
||||
B4 (замер latency всех хуков) — одноразовый bench: ~34 мс median на хук (чистый старт интерпретатора, однородно по всем хукам), ~13–23 с суммарно на крупную задачу — горячей точки нет, оптимизировать нечего, пункт закрыт.
|
||||
|
||||
Спека: `docs/superpowers/specs/2026-05-16-economy-5pct-level-design.md` (на origin/main, §11 — блок A/B3). Хук-файлы — в `~/.claude/`, вне git.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
# Pravila §12 (archived) — Superpowers hard rule
|
||||
|
||||
> **ARCHIVED 2026-05-25.** This section was extracted from
|
||||
> `docs/Pravila_raboty_Claude_v1_1.md` v1.40 → v1.41 as part of the
|
||||
> LLM-first router overhaul (Phase 1 Task 4). It is **superseded** by:
|
||||
>
|
||||
> - **Pravila §17 «universal skill-coverage»** (added in Phase 1 Task 5,
|
||||
> default-deny on non-conversation tasks, evidence-loop driven).
|
||||
> - **ADR-016** «§17 universal skill-coverage» (replaces ADR-011's §12
|
||||
> reasoning).
|
||||
>
|
||||
> §12 used a closed list of 14 task→skill mappings (§12.2 map). §17
|
||||
> replaces this with universal skill coverage discipline determined by
|
||||
> the LLM-first classifier + Sonnet 4.6, with `conversation`/`micro`/
|
||||
> `manual_override` task types exempt by classifier output, not by a
|
||||
> hard-coded list. The classifier writes the choice to `classifier_output`
|
||||
> on every episode; the §17 enforcement decides block/warn from there.
|
||||
>
|
||||
> The §12 enforcement hooks (`skill-marker.py` + `skill-check.py`) were
|
||||
> unwired from `~/.claude/settings.json` in Phase 1 Task 2 (commit
|
||||
> `3073e0cb`). Files remain on disk in `~/.claude/hooks/`; snapshots are
|
||||
> in `docs/archive/llm-bootstrap-2026-05/user-hooks/`.
|
||||
>
|
||||
> Rollback restores the §12 text via
|
||||
> `git checkout brain-pre-llm-bootstrap -- docs/Pravila_raboty_Claude_v1_1.md`
|
||||
> (tag points to pre-overhaul state with §12 intact).
|
||||
|
||||
---
|
||||
|
||||
## 12. Superpowers — hard rule (инвокация skills первой)
|
||||
|
||||
Введено 09.05.2026 на явное требование заказчика: **«Создай правило, что ты всегда в первую очередь пользуешься superpowers. При этом ты не можешь игнорировать и обходить это правило.»**
|
||||
|
||||
§12 — **explicit hard-rule**: перед содержательной задачей соответствующий Superpowers-skill (карта §12.2) инвокируется первым. §9 «Отступления» к §12 не применяется (§12.4). Карта §12.2, exclusions §12.3 и детали §12.4 — в силе.
|
||||
|
||||
### 12.1. Принцип
|
||||
|
||||
Перед началом любой содержательной задачи Claude **сначала** проверяет соответствующий skill в плагине Superpowers v5.1.0 и **инвокирует его**. Skill приносит свой workflow, Claude следует ему. Только если skill для задачи отсутствует (см. §12.3) — работа идёт обычным flow.
|
||||
|
||||
### 12.2. Карта задач → skills
|
||||
|
||||
| Задача | Skill для инвокации |
|
||||
|---|---|
|
||||
| Тесты с TDD-циклом (новый функционал биллинга, RLS, deals API) | `superpowers:test-driven-development` |
|
||||
| Разбор бага / системный debug / расследование инцидента | `superpowers:systematic-debugging` |
|
||||
| Планирование эпика / большой задачи (≥3 этапа) | `superpowers:writing-plans` |
|
||||
| Исполнение существующего плана | `superpowers:executing-plans` |
|
||||
| Мозговой штурм / генерация идей по требованию заказчика | `superpowers:brainstorming` |
|
||||
| Подготовка PR / запрос code review | `superpowers:requesting-code-review` |
|
||||
| Получение и применение review-комментариев | `superpowers:receiving-code-review` |
|
||||
| Финализация feature-ветки (merge-ready) | `superpowers:finishing-a-development-branch` |
|
||||
| Параллельная работа независимых задач | `superpowers:dispatching-parallel-agents` |
|
||||
| Делегирование подагентам с инструкциями | `superpowers:subagent-driven-development` |
|
||||
| Финальная проверка перед сдачей задачи | `superpowers:verification-before-completion` |
|
||||
| Создание / правка пользовательских skills | `superpowers:writing-skills` |
|
||||
| Git worktrees (с учётом §11.3 — Windows + кириллица) | `superpowers:using-git-worktrees` |
|
||||
| Понимание возможностей самого плагина | `superpowers:using-superpowers` |
|
||||
|
||||
### 12.3. Когда правило НЕ применяется
|
||||
|
||||
> **Single Source of Truth для exclusions §12 (v1.9+).** При расширении списка — править только этот раздел; в CLAUDE.md §5 п.11 и PSR_v1 R0.4.A — только cross-ref сюда. При расхождении между документами побеждает Pravila §12.3.
|
||||
|
||||
§12 не активируется, только если у задачи **отсутствует** соответствующий skill:
|
||||
|
||||
- Чтение / поиск файла (Glob, Grep, Read).
|
||||
- Тривиальные правки (опечатки, синхронизация ссылок, обновление версионных меток в шапках).
|
||||
- Ответы на справочные вопросы заказчика без действий над кодом.
|
||||
- Работа с открытыми вопросами реестра (`Биз-*`, `CTO-*`, `Ю-*`, `Диз-*`, `DO-*`, `OPEN-*`) — её регулирует §7.
|
||||
- Конкретные команды tooling'а (composer/npm/git/Boost MCP), которые не являются «debug» или «TDD».
|
||||
- Документационные правки уровня §4 (Pravila/Tooling/CLAUDE.md/narrative). Для CLAUDE.md дополнительное требование — через `claude-md-management:claude-md-improver` (CLAUDE.md §5 п.10), но это инфраструктурный канал правок, не §12-skill.
|
||||
|
||||
В **любом другом** случае skill инвокируется **до** прочих действий.
|
||||
|
||||
### 12.4. Hard-rule статус
|
||||
|
||||
- §9 «Отступления» к §12 **не применяется** — §12 explicit hard-rule. Единственная отмена — явный запрос заказчика «не используй superpowers сейчас», только на текущее действие.
|
||||
- §12 имеет приоритет над §1–§11. Это значит, что даже когда §1 (роль) или §11 (override) предписывают определённое поведение, §12 срабатывает раньше — skill инвокируется первым.
|
||||
- Запрос заказчика «не используй superpowers сейчас» — единственная разрешённая отмена правила, и **только** для текущего действия. В следующем действии §12 действует автоматически.
|
||||
- Игнорирование §12 (выбор обычного подхода когда skill доступен) — нарушение того же уровня, что игнорирование §5 (ПДн).
|
||||
- Любая попытка обойти §12 через переформулировку задачи («это просто debug» вместо `systematic-debugging`) — нарушение.
|
||||
- Claude **не имеет права** рационализировать пропуск §12 («сейчас быстрее без skill'а»; «эта задача проще, чем требует skill»). Если skill применим — он инвокируется.
|
||||
|
||||
### 12.5. Override-приоритет относительно §11
|
||||
|
||||
§12 имеет **приоритет над §11**. §11 разрешил Superpowers override §2.2/§4.5/§8.4. §12 теперь говорит: даже без явного вызова заказчиком, skill инвокируется по умолчанию. Override §2.2/§4.5/§8.4 при этом происходит автоматически (§11.1).
|
||||
|
||||
### 12.6. Что остаётся неизменным
|
||||
|
||||
§5 (ПДн), §7 (финальное закрытие открытых вопросов), §3.6 (язык) — **не override-ятся** даже Superpowers skill'ом, и §12 этого не меняет. См. §11.2.
|
||||
|
||||
### 12.7. Нарушения
|
||||
|
||||
Если Claude забыл инвокировать skill в подходящей задаче — заказчик может указать на нарушение. Claude обязан зафиксировать ошибку в feedback memory (`feedback_*.md`) для коррекции в будущих сессиях.
|
||||
|
||||
### 12.8. Ревизия §12
|
||||
|
||||
В отличие от §11, который ревизуется по факту проблем, §12 — стабильное правило. Откат возможен только тем же путём, что и введение: явным запросом заказчика «откати §12, верни §9 как override-возможность».
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user