Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7df4786499 | |||
| 162fe010fe | |||
| 426983ffaa | |||
| 87c5eb6323 | |||
| cb864b18a5 | |||
| 4b4c8d94b9 | |||
| dd0a9ffea6 | |||
| 353b1599b6 | |||
| 97388cf840 | |||
| 8f5a399a25 | |||
| efd3e73aa2 | |||
| 0f1b604554 | |||
| 48d7303963 | |||
| b9e72e6231 | |||
| 80c5f6289a | |||
| 895975482d | |||
| e81cd8ed2c | |||
| bff5faf02b | |||
| 8df5a3fe00 | |||
| 83295a25f3 | |||
| 0fad4305d4 | |||
| 2f60910b09 | |||
| f48d5115ce | |||
| 774763c21c | |||
| c1b690edd3 | |||
| e34b11aca5 | |||
| b4f4f441b5 | |||
| 475e233c2a | |||
| 3e289479f0 | |||
| 0cee520f0d | |||
| c3392bef13 | |||
| 7fed5bc18b | |||
| 43028228c8 | |||
| f1092772fb | |||
| 702c2ff7b5 | |||
| b75f9e3d21 | |||
| 2e26edbb3a | |||
| 643e1a5dcf | |||
| 21f1d7833b | |||
| 9e1a07aad3 | |||
| b2b9a75731 | |||
| 287332eddf | |||
| 8550ba243d | |||
| ad09db606a | |||
| c27539ca29 | |||
| 9b4bff48f0 | |||
| 6c30c248bc | |||
| 9443b5b446 | |||
| 25088e4a33 | |||
| fcd06afcb2 | |||
| 2f55632792 | |||
| 54365015d8 | |||
| 4dd40f609f | |||
| d760036972 | |||
| 0e27844a28 | |||
| d369383c7d | |||
| 54fcc4b094 | |||
| e87b1385cf | |||
| 66ca57f187 | |||
| 430efe624d | |||
| dc6d2dd358 | |||
| 4969363f78 | |||
| 0e3938f845 | |||
| 7f379bd6a2 | |||
| f751ded65b | |||
| 0c8d0fa8d1 | |||
| f7f37fb4e4 | |||
| d484e60c46 | |||
| a6f44e5bb4 | |||
| 363357bff4 | |||
| 843123bbdb | |||
| 1d76d930bd | |||
| cde9478899 | |||
| d080198220 | |||
| 35231d8b96 | |||
| 2e11c452a9 | |||
| 02bff371c1 | |||
| 375c3e2d1f | |||
| 57d6495271 | |||
| 6ca3b0d6fa | |||
| 85a95aa2d0 | |||
| 2501b00079 | |||
| e0a25ff629 | |||
| d2b344ea24 | |||
| 99c7bac99b | |||
| 59d3dd06b6 | |||
| 0f6f38a70e | |||
| 2a2ded7a53 | |||
| cb681dbd68 | |||
| 8ae0ecef25 | |||
| bffdaa9f57 | |||
| 9ef5227f0f | |||
| a250ea605f | |||
| a70d5a4bdb | |||
| ce2333e309 | |||
| 0c9661d694 | |||
| a780959de9 | |||
| 4382de3a79 | |||
| 0a45fcbdfd | |||
| 747caaf3e7 | |||
| 0cf1406314 | |||
| a8257001a7 | |||
| 4616308402 | |||
| 910c2d0e37 | |||
| d4520ff6b0 | |||
| 1b899e024d | |||
| 8170527ee4 | |||
| 3e733969dc | |||
| 39231ef856 | |||
| ca4da6932e | |||
| 16f7f1c340 | |||
| 0718e41cc5 | |||
| 1f77134597 | |||
| 8a2e701ff2 | |||
| 2ef4ac4b9c | |||
| 06a3bd532d | |||
| 544c8f3081 | |||
| ca93cf7652 | |||
| dd5bdedf0a | |||
| 1a553ab287 | |||
| ecfeddb34a | |||
| 1cd47211a5 | |||
| 66320166b8 | |||
| 989ee58481 | |||
| dd1f72bf58 | |||
| 0b6937973c | |||
| 5e804a35f1 | |||
| 3e70f87d88 | |||
| 7e8560ae58 | |||
| ed8ec89bcc | |||
| 868e57ee0c | |||
| 3b59bd499a | |||
| a8e0cc9195 | |||
| 616f1d98a1 | |||
| aab7345590 | |||
| e3ef9d70be | |||
| a03fb99242 | |||
| bca6d55684 | |||
| 5dc95098ea | |||
| e5ec754abc | |||
| ec4069ce38 | |||
| f248e27702 | |||
| 32006a2bda | |||
| 1412d3fefd | |||
| 9fcefa3ab9 | |||
| e6dbbb49a1 | |||
| 789e7dcdb6 | |||
| 3bedf10449 | |||
| 183c719614 | |||
| 36ea9cde04 | |||
| 1e4278ffb2 |
+11
-18
@@ -37,24 +37,6 @@
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-recall-hook.mjs\""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-queen-hook.mjs\""
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Edit|Write",
|
||||
@@ -94,6 +76,17 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node tools/observer-stop-hook.mjs",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# Brain Retro
|
||||
|
||||
Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces candidates for normative updates. User decides what to apply.
|
||||
|
||||
## When to invoke
|
||||
|
||||
- Explicit user request: «брейн-ретро» / «сделай brain-retro» / `/brain-retro`.
|
||||
- Periodic — owner discretion (e.g. end of sprint).
|
||||
- NOT auto-invoked.
|
||||
|
||||
## What it does NOT do
|
||||
|
||||
- Does NOT edit `docs/Tooling_v8_3.md`, `docs/Pravila_raboty_Claude_v1_1.md`, `docs/Plugin_stack_rules_v1.md`, `CLAUDE.md`, or any normative file.
|
||||
- Does NOT write to `docs/observer/episodes-*.jsonl` (read-only).
|
||||
- Does NOT trigger automatic memory updates.
|
||||
|
||||
## Procedure
|
||||
|
||||
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**: bump `docs/observer/.read-counter.json` `last_read_at` to now, increment `read_count_last_period`. (Side-effect — used by C3 observer-of-observer.)
|
||||
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`.
|
||||
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.
|
||||
9. **Report to user**: high-signal summary.
|
||||
|
||||
## Output anatomy
|
||||
|
||||
See `references/aggregation-template.md`.
|
||||
|
||||
## Behavioral rule reminders
|
||||
|
||||
- **«Не использован ≠ проблема»** — when reporting node usage counts, NEVER mark unused nodes as «zombie» / «removal candidate». Cite `memory/feedback_brain_unused_tools_not_problem.md`.
|
||||
- **No auto-edit** — every regulatory suggestion is a candidate, not an action.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Brain-retro aggregation template
|
||||
|
||||
## Period
|
||||
|
||||
YYYY-MM-DD .. YYYY-MM-DD ({N} sessions)
|
||||
|
||||
## Path-type distribution
|
||||
|
||||
| path_type | count | % |
|
||||
|---|---|---|
|
||||
| regulated | A | x% |
|
||||
| improvised | B | y% |
|
||||
| alternative | C | z% |
|
||||
| mixed | D | w% |
|
||||
|
||||
## Outcome distribution
|
||||
|
||||
| outcome | count |
|
||||
|---|---|
|
||||
| success | M |
|
||||
| partial | N |
|
||||
| failure | O |
|
||||
| aborted | P |
|
||||
|
||||
## Top nodes used (from `skill_invoked` events)
|
||||
|
||||
| node | times used | first / last |
|
||||
|---|---|---|
|
||||
|
||||
## Factor analysis matrix (v2 — from `tools/brain-retro-analyzer.mjs`)
|
||||
|
||||
Outcome distribution per factor value. Source: the analyzer’s `factorMatrix`.
|
||||
Outcome is the *inferred* outcome (next-prompt sentiment), not the stored
|
||||
`unknown`. The factor `decision_provenance` directly answers the owner’s
|
||||
question — "is the rework mine or the router’s?"
|
||||
|
||||
For each factor below, render a table: factor value × outcome counts
|
||||
(`success` / `partial` / `rework` / `unknown`).
|
||||
|
||||
### decision_provenance (autonomous vs user_directed_method)
|
||||
|
||||
| provenance | success | partial | rework | unknown |
|
||||
|---|---|---|---|---|
|
||||
|
||||
### economy_level
|
||||
|
||||
| economy_level | success | partial | rework | unknown |
|
||||
|---|---|---|---|---|
|
||||
|
||||
### model · post_compaction · task_size bucket
|
||||
|
||||
(one table each — same columns)
|
||||
|
||||
### node_chosen · task_classification
|
||||
|
||||
(one table each — same columns)
|
||||
|
||||
## Episodes → tasks (from analyzer `tasks`)
|
||||
|
||||
| task_ref | episodes | turns that are rework |
|
||||
|---|---|---|
|
||||
|
||||
## Causal-chain candidates (from analyzer `causalChains`)
|
||||
|
||||
| from (errored episode) | to (later episode) | shared files |
|
||||
|---|---|---|
|
||||
|
||||
## Observer health
|
||||
|
||||
- `observerErrorCount` from the analyzer — observer_error markers in the period.
|
||||
Non-zero = the observer failed silently somewhere; investigate.
|
||||
|
||||
## Canonical chains L1–L12 hit rate
|
||||
|
||||
| chain | times | notes |
|
||||
|---|---|---|
|
||||
|
||||
## Improvised chains (path_type=improvised, repeated ≥2)
|
||||
|
||||
| node-set | times | candidate L13+? |
|
||||
|---|---|---|
|
||||
|
||||
## chain_divergence cases
|
||||
|
||||
| canonical | chosen | reason | recurring? |
|
||||
|---|---|---|---|
|
||||
|
||||
## Top error classes
|
||||
|
||||
| error class | count | recovery pattern |
|
||||
|---|---|---|
|
||||
|
||||
## confusion_marker hot-spots
|
||||
|
||||
| context | count |
|
||||
|---|---|
|
||||
|
||||
## Candidates for owner review
|
||||
|
||||
### Candidate 1: `<title>`
|
||||
|
||||
- **Type**: new canonical chain L13+ / new ADR / boundary clarification / etc.
|
||||
- **Evidence**: refs to JSONL lines (file:line).
|
||||
- **Suggested action**: `<concrete edit>`.
|
||||
- **Cost / risk**: `<brief>`.
|
||||
|
||||
(repeat for each candidate; could be 0)
|
||||
|
||||
## Informational metrics (NOT alerts)
|
||||
|
||||
- Nodes used at least once this period: K / 60+
|
||||
- Nodes never used since beginning of observer logs: L / 60+ — **not a problem** per [feedback_brain_unused_tools_not_problem](../../../memory/feedback_brain_unused_tools_not_problem.md)
|
||||
@@ -0,0 +1,5 @@
|
||||
# Normalize line endings for Node ESM tooling files.
|
||||
# Keep LF in the working tree regardless of core.autocrlf — CRLF .mjs files
|
||||
# break vitest module loading (SyntaxError: Invalid or unexpected token,
|
||||
# no file:line). See memory quirk #100 (2026-05-19).
|
||||
*.mjs text eol=lf
|
||||
@@ -0,0 +1,31 @@
|
||||
name: brain-l1-watcher (weekly)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
drift:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: run l1-watcher
|
||||
id: l1
|
||||
run: node tools/l1-watcher.mjs
|
||||
continue-on-error: true
|
||||
- name: open issue on drift
|
||||
if: steps.l1.outcome == 'failure'
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.create({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
title: `[l1-watcher] drift detected (weekly cron ${new Date().toISOString().slice(0,10)})`,
|
||||
body: `Run failed. Check workflow logs and run /claude-md-management:claude-md-improver.`,
|
||||
labels: ['brain', 'drift']
|
||||
});
|
||||
@@ -39,11 +39,7 @@
|
||||
"args": ["-y", "@modelcontextprotocol/server-redis", "redis://localhost:6379"],
|
||||
"comment": "Off-phase tool — Redis MCP для Memurai (Windows service, Redis 7-совместимый, localhost:6379). Pending формализация в Tooling §3.3 #35 — sync нормативки отдельным планом. Package: @modelcontextprotocol/server-redis@2025.4.25 — DEPRECATED по статусу npm («Package no longer supported»), но Anthropic source, простой протокол, рабочий. Post-MVP migration на community alternative (e.g., @easy-mcps/redis-mcp-server@1.0.8 или @wenit/redis-mcp-server@1.0.3) когда подтвердим trust. READ-ONLY use — отладка очередей, кэша, Pest --parallel race (memory quirk 72). НЕ для prod (нет prod). Если в будущем prod Redis с auth — отдельный entry redis-prod с url через env var."
|
||||
},
|
||||
"ruflo": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "ruflo@latest", "mcp", "start"],
|
||||
"comment": "Off-phase orchestration MCP — exposes ~210 ruflo tools (Core/Intelligence/Agents/Memory/DevTools). Package: ruflo v3.7.0-alpha.38+ MIT (npm `ruflo`, repo ruvnet/claude-flow legacy after rename Jan-2026; plugin namespace @claude-flow/*). Plugin discovery via IPFS (CID QmeXmAdbWVvT84GfDXPD2Vg1HWhiTW2VdZfRLhkS96KkX2) — Pinata+Cloudflare gateways flaky 2026-05-15, only ipfs.io reliable. stdio mode (no port-conflict). Big-bang integration per spec/plan 2026-05-15-ruflo-integration-design.md (commit a68a0a0+). Pending формализация в Tooling §4.10 — Phase 3 Task 3.4."
|
||||
},
|
||||
"_ruflo_isolated_note": "ruflo MCP-сервер отключён 18.05.2026 (заказчик: «изолируй, не удаляй»). Чтобы вернуть — восстановить блок 'ruflo': { command: 'npx', args: ['-y','ruflo@latest','mcp','start'], comment: ... }. См. memory feedback_ruflo_isolated.md, Tooling §4.10, CLAUDE.md §3.5.",
|
||||
"universal-icons": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "mcp-universal-icons"],
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\Supplier\CsvReconcileJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierManualSyncQueue;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* SaaS-admin → Интеграция с поставщиком: здоровье резервного CSV-канала (Путь 2).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.4
|
||||
*/
|
||||
final class AdminSupplierIntegrationController extends Controller
|
||||
{
|
||||
private const HISTORY_LIMIT = 20;
|
||||
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$rows = DB::connection('pgsql_supplier')
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->orderByDesc('id')
|
||||
->limit(self::HISTORY_LIMIT)
|
||||
->get();
|
||||
|
||||
$last = $rows->first();
|
||||
|
||||
$webhookState = ($last !== null && $last->status === 'drift_alert') ? 'down' : 'live';
|
||||
|
||||
return response()->json([
|
||||
'health' => [
|
||||
'last_run_at' => $last !== null ? ($last->finished_at ?? $last->started_at) : null,
|
||||
'last_status' => $last?->status,
|
||||
'drift_ratio' => $last !== null ? (float) $last->drift_ratio : null,
|
||||
'webhook_state' => $webhookState,
|
||||
],
|
||||
'history' => $rows->map(fn ($r): array => [
|
||||
'started_at' => $r->started_at,
|
||||
'finished_at' => $r->finished_at,
|
||||
'window_start' => $r->window_start,
|
||||
'window_end' => $r->window_end,
|
||||
'status' => $r->status,
|
||||
'total_csv_rows' => (int) $r->total_csv_rows,
|
||||
'matched_count' => (int) $r->matched_count,
|
||||
'recovered_count' => (int) $r->recovered_count,
|
||||
'drift_ratio' => (float) $r->drift_ratio,
|
||||
])->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function reconcile(): JsonResponse
|
||||
{
|
||||
CsvReconcileJob::dispatch();
|
||||
|
||||
return response()->json(['dispatched' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Очередь яруса 3 резерва канала миграции проектов — pending-список для
|
||||
* оператора админ-экрана. Spec §4.6.
|
||||
*/
|
||||
public function manualQueueIndex(): JsonResponse
|
||||
{
|
||||
$rows = SupplierManualSyncQueue::where('status', 'pending')
|
||||
->orderByDesc('id')
|
||||
->limit(100)
|
||||
->get(['id', 'project_id', 'platform', 'operation', 'external_id', 'payload_snapshot', 'failure_reason', 'created_at']);
|
||||
|
||||
return response()->json(['queue' => $rows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Оператор вручную создал проект на портале → reconcile: сверяем через
|
||||
* listProjects(), ставим FK supplier_b{1,2,3}_project_id, помечаем resolved.
|
||||
* 409 если проект на портале не найден (оператор не создал / другие параметры).
|
||||
* Spec §4.6.
|
||||
*/
|
||||
public function manualQueueResolve(int $id, Request $request, SupplierProjectChannel $channel): JsonResponse
|
||||
{
|
||||
$row = SupplierManualSyncQueue::findOrFail($id);
|
||||
if ($row->status !== 'pending') {
|
||||
return response()->json(['message' => 'already resolved or cancelled'], 409);
|
||||
}
|
||||
|
||||
$payload = $row->payload_snapshot;
|
||||
$signalType = (string) ($payload['signal_type'] ?? '');
|
||||
$uniqueKey = (string) ($payload['unique_key'] ?? '');
|
||||
|
||||
$found = null;
|
||||
foreach ($channel->listProjects() as $r) {
|
||||
if (
|
||||
($r['platform'] ?? null) === $row->platform
|
||||
&& ($r['signal_type'] ?? null) === $signalType
|
||||
&& ($r['unique_key'] ?? null) === $uniqueKey
|
||||
) {
|
||||
$found = (int) ($r['id'] ?? 0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($found === null) {
|
||||
return response()->json([
|
||||
'message' => 'Проект не найден на портале поставщика. Проверьте, что вы действительно его создали с теми же параметрами.',
|
||||
], 409);
|
||||
}
|
||||
|
||||
// FK projects.supplier_b{1,2,3}_project_id ведёт на local supplier_projects.id,
|
||||
// не на portal external_id. Find-or-create local row с verified external_id.
|
||||
$sp = SupplierProject::firstOrCreate(
|
||||
[
|
||||
'platform' => $row->platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $uniqueKey,
|
||||
],
|
||||
[
|
||||
'supplier_external_id' => (string) $found,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
],
|
||||
);
|
||||
|
||||
Project::where('id', $row->project_id)->update([
|
||||
'supplier_'.strtolower($row->platform).'_project_id' => $sp->id,
|
||||
]);
|
||||
|
||||
$row->update([
|
||||
'status' => 'resolved',
|
||||
'resolved_by_user_id' => $request->user()->id,
|
||||
'resolved_at' => now(),
|
||||
'external_id' => (string) $found,
|
||||
]);
|
||||
|
||||
return response()->json(['resolved' => true, 'external_id' => $found]);
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ class DealController extends Controller
|
||||
->limit(1),
|
||||
])
|
||||
->where('tenant_id', $tenantId)
|
||||
->with(['project:id,name,signal_type', 'manager:id,email,first_name,last_name']);
|
||||
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);
|
||||
|
||||
if ($onlyDeleted) {
|
||||
$query->withTrashed()->whereNotNull('deleted_at');
|
||||
@@ -213,6 +213,9 @@ class DealController extends Controller
|
||||
'comment' => $d->comment,
|
||||
'city' => $d->city,
|
||||
'project_signal_type' => $d->project?->signal_type,
|
||||
'project_signal_identifier' => $d->project?->signal_identifier,
|
||||
'project_sms_keyword' => $d->project?->sms_keyword,
|
||||
'project_sms_senders' => $d->project?->sms_senders,
|
||||
'next_reminder_at' => $d->next_reminder_at
|
||||
? Carbon::parse($d->next_reminder_at)->toIso8601String()
|
||||
: null,
|
||||
@@ -248,7 +251,7 @@ class DealController extends Controller
|
||||
$deal = Deal::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('id', $id)
|
||||
->with(['project:id,name', 'manager:id,email,first_name,last_name'])
|
||||
->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name'])
|
||||
->first();
|
||||
|
||||
if ($deal === null) {
|
||||
@@ -290,6 +293,10 @@ class DealController extends Controller
|
||||
: null,
|
||||
'received_at' => $deal->received_at?->toIso8601String(),
|
||||
'assigned_at' => $deal->assigned_at?->toIso8601String(),
|
||||
'project_signal_type' => $deal->project?->signal_type,
|
||||
'project_signal_identifier' => $deal->project?->signal_identifier,
|
||||
'project_sms_keyword' => $deal->project?->sms_keyword,
|
||||
'project_sms_senders' => $deal->project?->sms_senders,
|
||||
],
|
||||
'events' => $events->map(fn (ActivityLog $e) => [
|
||||
'id' => $e->id,
|
||||
@@ -432,6 +439,10 @@ class DealController extends Controller
|
||||
'manager_id' => $deal->manager_id,
|
||||
'received_at' => $deal->received_at?->toIso8601String(),
|
||||
'assigned_at' => $deal->assigned_at?->toIso8601String(),
|
||||
'project_signal_type' => $deal->project?->signal_type,
|
||||
'project_signal_identifier' => $deal->project?->signal_identifier,
|
||||
'project_sms_keyword' => $deal->project?->sms_keyword,
|
||||
'project_sms_senders' => $deal->project?->sms_senders,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Project;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateProjectRequest extends FormRequest
|
||||
@@ -16,7 +17,7 @@ class UpdateProjectRequest extends FormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
// signal_type immutable: не валидируется в правилах, controller игнорирует поле
|
||||
return [
|
||||
$rules = [
|
||||
'name' => ['sometimes', 'string', 'max:255'],
|
||||
'daily_limit_target' => ['sometimes', 'integer', 'min:1', 'max:10000'],
|
||||
// Plan 6: subject-level regions[] заменил region_mask/region_mode на API-уровне.
|
||||
@@ -28,5 +29,23 @@ class UpdateProjectRequest extends FormRequest
|
||||
'sms_senders.*' => ['string', 'max:11'],
|
||||
'sms_keyword' => ['sometimes', 'nullable', 'string', 'min:1', 'max:50'],
|
||||
];
|
||||
|
||||
// 18.05.2026 UX: редактирование источника (signal_identifier) для site/call.
|
||||
// Регулярки соответствуют StoreProjectRequest (domain + 7\d{10}).
|
||||
// signal_type immutable — берём из текущего проекта по route id.
|
||||
$projectId = $this->route('id');
|
||||
if ($projectId !== null) {
|
||||
$project = Project::find($projectId);
|
||||
if ($project !== null) {
|
||||
if ($project->signal_type === 'site') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^[a-z0-9][a-z0-9\-]*(\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,}$/i'];
|
||||
} elseif ($project->signal_type === 'call') {
|
||||
$rules['signal_identifier'] = ['sometimes', 'string', 'regex:/^7\d{10}$/'];
|
||||
}
|
||||
// sms: signal_identifier меняется через sms_senders/sms_keyword (см. выше)
|
||||
}
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,10 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
/**
|
||||
* Парсит поле raw_payload['project'] (формат `B[123]_<rest>`):
|
||||
* - rest вида `7\d{10}` → call (телефон-номер для звонка-сигнала);
|
||||
* - rest вида `^[a-z0-9-]+(\.[a-z0-9-]+)+$` → site (домен сайта-сигнала);
|
||||
* - rest вида `^[a-z0-9-]+(\.[a-z0-9-]+)+$` → site (rest целиком — домен);
|
||||
* - rest со встроенным доменом в свободном тексте → site (identifier =
|
||||
* извлечённый домен; поставщик иногда шлёт имя вида `заявка carmoney.ru/`
|
||||
* или `Платежи cabinet.caranga.ru/login` — регрессия 18.05.2026, 21 лид);
|
||||
* - иначе → sms (короткое имя отправителя SMS-шлюза).
|
||||
*
|
||||
* @return array{0: string, 1: string, 2: string} [platform, signal_type, identifier]
|
||||
@@ -163,15 +166,26 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
$platform = $m[1];
|
||||
$rest = $m[2];
|
||||
|
||||
// Домен с латинским TLD ≥2 букв (последний сегмент — только буквы), допускается
|
||||
// в любой позиции строки. Соответствует чистому rest и встроенному в текст домену.
|
||||
$domainRe = '/(?<![a-z0-9.\-])([a-z0-9][a-z0-9\-]*(?:\.[a-z0-9][a-z0-9\-]*)*\.[a-z]{2,})/i';
|
||||
|
||||
if (preg_match('/^7\d{10}$/', $rest) === 1) {
|
||||
$signalType = 'call';
|
||||
$identifier = $rest;
|
||||
} elseif (preg_match('/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i', $rest) === 1) {
|
||||
$signalType = 'site';
|
||||
$identifier = $rest;
|
||||
} elseif (preg_match($domainRe, $rest, $dm) === 1) {
|
||||
// Домен извлечён из свободного текста — это сайт-сигнал.
|
||||
$signalType = 'site';
|
||||
$identifier = mb_strtolower($dm[1]);
|
||||
} else {
|
||||
$signalType = 'sms';
|
||||
$identifier = $rest;
|
||||
}
|
||||
|
||||
return [$platform, $signalType, $rest];
|
||||
return [$platform, $signalType, $identifier];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,21 +24,20 @@ use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Hourly CSV reconciliation с порталом поставщика.
|
||||
* Резервный CSV-канал (Путь 2): сверка отчёта поставщика «Запрос номеров»
|
||||
* с принятыми webhook-лидами; recovery пропущенного + drift-детект.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
|
||||
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. Cache::lock на 600s — overlap-защита.
|
||||
* 1. Cache::lock — overlap-защита.
|
||||
* 2. INSERT supplier_csv_reconcile_log (status='running').
|
||||
* 3. Download CSV за окно 25h.
|
||||
* 4. Parse → собираем ['vid' => row].
|
||||
* 5. SELECT existing vid'ы из supplier_leads (BYPASSRLS).
|
||||
* 6. Diff = missing.
|
||||
* 7. Для каждой missing — INSERT supplier_leads (recovered_from_csv_at) + dispatch RouteJob.
|
||||
* 8. UPDATE log с метриками + status.
|
||||
* 9. drift > 5% → CsvDriftAlertMail + alert_email_sent_at.
|
||||
* 10. На exception — status='failed', throw.
|
||||
* 3. Заказать отчёт «Запрос номеров» за окно (2 кал. дня) → дождаться → скачать.
|
||||
* 4. Parse CSV (Name;Tag;Phone).
|
||||
* 5. Дедуп по (phone, project): SELECT existing supplier_leads за окно.
|
||||
* 6. Diff = missing → INSERT supplier_leads (vid=NULL, source='csv_recovery') + RouteJob.
|
||||
* 7. UPDATE log + drift; drift > 5% → CsvDriftAlertMail.
|
||||
* 8. На exception — status='failed', throw (cron повторит через 30 мин).
|
||||
*/
|
||||
final class CsvReconcileJob implements ShouldQueue
|
||||
{
|
||||
@@ -55,7 +54,7 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
|
||||
private const DRIFT_THRESHOLD = 0.05;
|
||||
|
||||
private const WINDOW_HOURS = 25;
|
||||
private const WINDOW_DAYS = 2;
|
||||
|
||||
private const LOCK_NAME = 'supplier:csv_reconcile';
|
||||
|
||||
@@ -75,47 +74,63 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
// Окно: начало (сегодня − (WINDOW_DAYS−1) дней) 00:00 .. сейчас.
|
||||
$windowEnd = Carbon::now();
|
||||
$windowStart = (clone $windowEnd)->subHours(self::WINDOW_HOURS);
|
||||
$windowStart = Carbon::today()->subDays(self::WINDOW_DAYS - 1);
|
||||
|
||||
$logId = DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->insertGetId([
|
||||
'started_at' => now(),
|
||||
'window_start' => $windowStart,
|
||||
'window_end' => $windowEnd,
|
||||
'status' => 'running',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// $logId инициализируется внутри try: если сам insertGetId упадёт (БД недоступна),
|
||||
// catch обязан НЕ обращаться к неинициализированному $logId, а finally — освободить
|
||||
// lock (иначе lock висит LOCK_TTL_SECONDS и пропускает следующие запуски).
|
||||
$logId = null;
|
||||
|
||||
try {
|
||||
$csv = $portal->downloadLeadsCsv($windowStart, $windowEnd);
|
||||
$logId = DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->insertGetId([
|
||||
'started_at' => now(),
|
||||
'window_start' => $windowStart,
|
||||
'window_end' => $windowEnd,
|
||||
'status' => 'running',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
/** @var array<string, array<string, mixed>> $csvByVid */
|
||||
$csvByVid = [];
|
||||
$reportId = $portal->requestNumbersReport($windowStart, $windowEnd);
|
||||
$portal->waitReportReady($reportId);
|
||||
$csv = $portal->downloadReport($reportId);
|
||||
|
||||
// CSV-строки по ключу phone|project (последняя строка с тем же ключом перетирает).
|
||||
/** @var array<string, array{project: string, tag: string, phone: string}> $csvByKey */
|
||||
$csvByKey = [];
|
||||
foreach ($parser->parse($csv) as $row) {
|
||||
$csvByVid[(string) $row['vid']] = $row;
|
||||
$csvByKey[$this->dedupKey((string) $row['phone'], (string) $row['project'])] = $row;
|
||||
}
|
||||
$totalCsvRows = count($csvByVid);
|
||||
$totalCsvRows = count($csvByKey);
|
||||
|
||||
$existing = DB::connection(self::DB_CONNECTION)
|
||||
// Существующие лиды за окно → set ключей phone|project.
|
||||
$existingKeys = [];
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_leads')
|
||||
->where('received_at', '>=', $windowStart)
|
||||
->where('received_at', '<', $windowEnd->copy()->addHour())
|
||||
->pluck('vid')
|
||||
->map(fn ($v) => (string) $v)
|
||||
->all();
|
||||
->select('phone', 'raw_payload')
|
||||
->orderBy('id')
|
||||
->chunk(500, function ($leads) use (&$existingKeys): void {
|
||||
foreach ($leads as $lead) {
|
||||
$payload = is_string($lead->raw_payload)
|
||||
? json_decode($lead->raw_payload, true)
|
||||
: (array) $lead->raw_payload;
|
||||
$project = (string) ($payload['project'] ?? '');
|
||||
$existingKeys[$this->dedupKey((string) $lead->phone, $project)] = true;
|
||||
}
|
||||
});
|
||||
|
||||
$existingMap = array_flip($existing);
|
||||
$missing = array_diff_key($csvByVid, $existingMap);
|
||||
$missing = array_diff_key($csvByKey, $existingKeys);
|
||||
|
||||
$recoveredCount = 0;
|
||||
foreach ($missing as $vid => $row) {
|
||||
$platform = $this->extractPlatform((string) ($row['project'] ?? ''));
|
||||
foreach ($missing as $row) {
|
||||
$platform = $this->extractPlatform((string) $row['project']);
|
||||
if ($platform === null) {
|
||||
Log::warning('csv_reconcile.unparseable_project_skipped', [
|
||||
'vid' => $vid,
|
||||
'project' => $row['project'] ?? null,
|
||||
'project' => $row['project'],
|
||||
]);
|
||||
|
||||
continue;
|
||||
@@ -123,24 +138,23 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
|
||||
try {
|
||||
$lead = SupplierLead::create([
|
||||
'vid' => (int) $vid,
|
||||
'vid' => null,
|
||||
'platform' => $platform,
|
||||
'phone' => (string) $row['phone'],
|
||||
'raw_payload' => $row,
|
||||
'received_at' => Carbon::createFromTimestamp((int) $row['time']),
|
||||
'received_at' => now(),
|
||||
'recovered_from_csv_at' => now(),
|
||||
'source' => 'csv_recovery',
|
||||
'supplier_project_id' => null, // ResolverStub разрезолвит при RouteJob run
|
||||
'supplier_project_id' => null,
|
||||
]);
|
||||
RouteSupplierLeadJob::dispatch($lead->id);
|
||||
$recoveredCount++;
|
||||
} catch (QueryException $e) {
|
||||
if (str_contains($e->getMessage(), 'unique')) {
|
||||
Log::info('csv_reconcile.duplicate_vid_skipped', ['vid' => $vid]);
|
||||
|
||||
continue;
|
||||
}
|
||||
throw $e;
|
||||
Log::warning('csv_reconcile.lead_insert_failed', [
|
||||
'phone' => $row['phone'],
|
||||
'project' => $row['project'],
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,14 +191,17 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
->update($update);
|
||||
|
||||
} catch (Throwable $e) {
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->where('id', $logId)
|
||||
->update([
|
||||
'finished_at' => now(),
|
||||
'status' => 'failed',
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
]);
|
||||
// $logId === null — упал сам insertGetId, log-строки нет, обновлять нечего.
|
||||
if ($logId !== null) {
|
||||
DB::connection(self::DB_CONNECTION)
|
||||
->table('supplier_csv_reconcile_log')
|
||||
->where('id', $logId)
|
||||
->update([
|
||||
'finished_at' => now(),
|
||||
'status' => 'failed',
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
]);
|
||||
}
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
@@ -192,8 +209,15 @@ final class CsvReconcileJob implements ShouldQueue
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает platform (B1/B2/B3) из поля raw_payload['project'] CSV-строки.
|
||||
* Формат project: `B[123]_<rest>` (например `B1_a.com`, `B2_79991234567`).
|
||||
* Ключ дедупа: нормализованный phone + project.
|
||||
*/
|
||||
private function dedupKey(string $phone, string $project): string
|
||||
{
|
||||
return trim($phone).'|'.trim($project);
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает platform (B1/B2/B3) из имени проекта формата `B[123]_<rest>`.
|
||||
* Возвращает null если не парсится — caller пропустит строку с warning.
|
||||
*/
|
||||
private function extractPlatform(string $project): ?string
|
||||
|
||||
@@ -12,8 +12,11 @@ use App\Mail\SupplierCriticalAlertMail;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\SupplierSyncLog;
|
||||
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
|
||||
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
|
||||
use App\Services\Supplier\Channel\FailoverProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierQuotaAllocator;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -63,9 +66,11 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
|
||||
public const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
public function handle(?SupplierPortalClient $client = null): void
|
||||
private SupplierProjectChannel $channel;
|
||||
|
||||
public function handle(?SupplierProjectChannel $channel = null): void
|
||||
{
|
||||
$client ??= app(SupplierPortalClient::class);
|
||||
$this->channel = $channel ?? app(SupplierProjectChannel::class);
|
||||
$consecutiveTransient = 0;
|
||||
|
||||
$projects = SupplierProject::on(self::DB_CONNECTION)
|
||||
@@ -82,8 +87,16 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
$this->syncOne($sp, $client);
|
||||
$this->syncOne($sp);
|
||||
$consecutiveTransient = 0;
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectsJob: sp #{$sp->id} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectsJob: sp #{$sp->id} deferred by portal window");
|
||||
|
||||
continue;
|
||||
} catch (SupplierAuthException $e) {
|
||||
Mail::to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierCriticalAlertMail(
|
||||
@@ -115,7 +128,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function syncOne(SupplierProject $sp, SupplierPortalClient $client): void
|
||||
private function syncOne(SupplierProject $sp): void
|
||||
{
|
||||
$fkColumn = $this->fkColumnForPlatform($sp->platform);
|
||||
|
||||
@@ -155,8 +168,13 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
// (supplier_project update + supplier_sync_log insert) на одной connection
|
||||
// выполняются последовательно; ошибка между ними — recoverable through retry
|
||||
// на следующем cron-tick'е (supplier_external_id уже записан, скип через equals()).
|
||||
// Context-project для project_id в очереди яруса 3 при эскалации.
|
||||
$contextProject = $liderraProjects->first();
|
||||
|
||||
if ($isCreate) {
|
||||
$externalId = $client->saveProject($allocation);
|
||||
$externalId = $this->channel instanceof FailoverProjectChannel
|
||||
? $this->channel->createProjectForLiderra($contextProject, $allocation)
|
||||
: $this->channel->createProject($allocation);
|
||||
$sp->forceFill([
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $allocation->limit,
|
||||
@@ -166,7 +184,11 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
} else {
|
||||
$client->updateProject((int) $sp->supplier_external_id, $allocation);
|
||||
if ($this->channel instanceof FailoverProjectChannel) {
|
||||
$this->channel->updateProjectForLiderra($contextProject, (int) $sp->supplier_external_id, $allocation);
|
||||
} else {
|
||||
$this->channel->updateProject((int) $sp->supplier_external_id, $allocation);
|
||||
}
|
||||
$sp->forceFill([
|
||||
'current_limit' => $allocation->limit,
|
||||
'current_workdays' => $allocation->workdays,
|
||||
|
||||
@@ -5,7 +5,12 @@ declare(strict_types=1);
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
|
||||
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
|
||||
use App\Services\Supplier\Channel\FailoverProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -24,9 +29,14 @@ use Illuminate\Support\Facades\Log;
|
||||
*
|
||||
* Записывает полученные supplier_projects.id в projects.supplier_b{1,2,3}_project_id.
|
||||
*
|
||||
* Канал миграции — SupplierProjectChannel (резолвится в FailoverProjectChannel:
|
||||
* ярус 1 AJAX → ярус 2 browser-form → ярус 3 manual queue). При эскалации на
|
||||
* ярус 3 / переносе по окну портала — platform пропускается (FK остаётся NULL,
|
||||
* ночной SyncSupplierProjectsJob подберёт после ручного вмешательства).
|
||||
*
|
||||
* Retry: 3 попытки с backoff [15s, 60s, 300s].
|
||||
*
|
||||
* Spec: docs/superpowers/plans/2026-05-11-plan5-frontend-projects-ui-plan.md Task 4
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §5
|
||||
*/
|
||||
class SyncSupplierProjectJob implements ShouldQueue
|
||||
{
|
||||
@@ -39,7 +49,7 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
public function __construct(public int $projectId) {}
|
||||
|
||||
public function handle(SupplierPortalClient $client): void
|
||||
public function handle(SupplierProjectChannel $channel): void
|
||||
{
|
||||
$project = Project::find($this->projectId);
|
||||
|
||||
@@ -53,14 +63,72 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$uniqueKey = $this->buildUniqueKey($project, $platform);
|
||||
$supplierProjectId = $client->ensureSupplierProject($platform, $project->signal_type, $uniqueKey);
|
||||
$column = 'supplier_'.strtolower($platform).'_project_id';
|
||||
$project->{$column} = $supplierProjectId;
|
||||
|
||||
// Идемпотентность: local supplier_projects-запись для тройки уже есть?
|
||||
$existing = SupplierProject::query()
|
||||
->where('platform', $platform)
|
||||
->where('signal_type', $project->signal_type)
|
||||
->where('unique_key', $uniqueKey)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
$project->{$column} = $existing->id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$dto = $this->buildDto($project, $platform, $uniqueKey);
|
||||
|
||||
try {
|
||||
$externalId = $channel instanceof FailoverProjectChannel
|
||||
? $channel->createProjectForLiderra($project, $dto)
|
||||
: $channel->createProject($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} escalated to manual queue #{$e->queueRowId}");
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} {$platform} deferred by portal window");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $project->signal_type,
|
||||
'unique_key' => $uniqueKey,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$project->{$column} = $sp->id;
|
||||
}
|
||||
|
||||
$project->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial-create DTO: лимит 0 (квота приедет ночным SyncSupplierProjectsJob),
|
||||
* полная неделя, без регионов.
|
||||
*/
|
||||
private function buildDto(Project $project, string $platform, string $uniqueKey): SupplierProjectDto
|
||||
{
|
||||
return new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $uniqueKey,
|
||||
limit: 0,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список uppercase platform-кодов для данного project.
|
||||
* Коды соответствуют CHECK constraint: 'B1' / 'B2' / 'B3'.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Очередь яруса 3 резерва канала миграции проектов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $project_id
|
||||
* @property string $platform
|
||||
* @property string $operation
|
||||
* @property string|null $external_id
|
||||
* @property array<string, mixed> $payload_snapshot
|
||||
* @property string $failure_reason
|
||||
* @property string $status
|
||||
* @property int|null $resolved_by_user_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $resolved_at
|
||||
*/
|
||||
class SupplierManualSyncQueue extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'supplier_manual_sync_queue';
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'project_id', 'platform', 'operation', 'external_id',
|
||||
'payload_snapshot', 'failure_reason', 'status',
|
||||
'resolved_by_user_id', 'created_at', 'resolved_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'payload_snapshot' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function project(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Project::class);
|
||||
}
|
||||
|
||||
public function resolver(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'resolved_by_user_id');
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,13 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Supplier\Channel\AjaxProjectChannel;
|
||||
use App\Services\Supplier\Channel\FailoverProjectChannel;
|
||||
use App\Services\Supplier\Channel\FormProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\ProcessFactory;
|
||||
use App\Services\Supplier\SymfonyProcessFactory;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -17,6 +22,18 @@ class AppServiceProvider extends ServiceProvider
|
||||
ProcessFactory::class,
|
||||
SymfonyProcessFactory::class,
|
||||
);
|
||||
|
||||
// Резерв канала миграции проектов: SupplierProjectChannel резолвится в
|
||||
// декоратор-оркестратор (ярус 1 AJAX → ярус 2 browser-form → ярус 3 queue).
|
||||
// Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.4
|
||||
$this->app->bind(
|
||||
SupplierProjectChannel::class,
|
||||
fn ($app) => new FailoverProjectChannel(
|
||||
$app->make(AjaxProjectChannel::class),
|
||||
$app->make(FormProjectChannel::class),
|
||||
$app->make(Mailer::class),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,8 +14,9 @@ class ProjectService
|
||||
public function update(Project $project, array $data): Project
|
||||
{
|
||||
// Immutable fields — silently drop (don't 422)
|
||||
// signal_identifier — теперь editable (18.05.2026 ux), валидируется в UpdateProjectRequest.
|
||||
unset(
|
||||
$data['tenant_id'], $data['signal_type'], $data['signal_identifier'],
|
||||
$data['tenant_id'], $data['signal_type'],
|
||||
$data['delivered_today'], $data['delivered_in_month'],
|
||||
$data['supplier_b1_project_id'], $data['supplier_b2_project_id'], $data['supplier_b3_project_id'],
|
||||
$data['archived_at'],
|
||||
@@ -31,7 +32,10 @@ class ProjectService
|
||||
], 422));
|
||||
}
|
||||
|
||||
$needsResync = array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data);
|
||||
// Resync на смену любого источник-несущего поля — поставщику нужно знать актуальный домен/телефон/sms.
|
||||
$needsResync = array_key_exists('sms_senders', $data)
|
||||
|| array_key_exists('sms_keyword', $data)
|
||||
|| array_key_exists('signal_identifier', $data);
|
||||
|
||||
$project->update($data);
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel;
|
||||
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
|
||||
/**
|
||||
* Ярус 1: тонкий адаптер над SupplierPortalClient (rt-project-* AJAX).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.2
|
||||
*/
|
||||
final class AjaxProjectChannel implements SupplierProjectChannel
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierPortalClient $client,
|
||||
) {}
|
||||
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
return $this->client->saveProject($dto);
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void
|
||||
{
|
||||
$this->client->updateProject($externalId, $dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сырые rt-строки портала → контрактная форма SupplierProjectChannel.
|
||||
*
|
||||
* Портал не отдаёт platform/signal_type/unique_key напрямую. Маппинг
|
||||
* (verified live 2026-05-19, см. SupplierPortalClient::listProjects docblock):
|
||||
* - platform ← префикс name "B<n>_..." (B1/B2/B3); иначе null;
|
||||
* - signal_type ← type: hosts→site, calls→call, sms→sms;
|
||||
* - unique_key ← content (домен / телефон / sender).
|
||||
* Сырые поля остаются (id, tag, name, type, content, ...) — для дебага.
|
||||
*/
|
||||
public function listProjects(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($this->client->listProjects() as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) ($row['name'] ?? '');
|
||||
$platform = preg_match('/^(B[123])_/', $name, $m) === 1 ? $m[1] : null;
|
||||
|
||||
$signalType = match ($row['type'] ?? null) {
|
||||
'hosts' => 'site',
|
||||
'calls' => 'call',
|
||||
'sms' => 'sms',
|
||||
default => null,
|
||||
};
|
||||
|
||||
$out[] = $row + [
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => (string) ($row['content'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel\Exceptions;
|
||||
|
||||
/**
|
||||
* Брошен FailoverProjectChannel когда операция эскалирована на ярус 3.
|
||||
*
|
||||
* Job-уровень ловит и помечает текущую попытку как отложенную к ручному вмешательству.
|
||||
*
|
||||
* Spec §4.4 ("manual_required").
|
||||
*/
|
||||
final class TierEscalatedException extends \RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $queueRowId,
|
||||
public readonly string $reason,
|
||||
string $message = '',
|
||||
) {
|
||||
parent::__construct($message ?: "Escalated to manual queue (row #{$queueRowId}, reason: {$reason})");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel\Exceptions;
|
||||
|
||||
/**
|
||||
* Маркер «портал отказал по причине окна редактирования» (22:00-00:00 МСК).
|
||||
*
|
||||
* НЕ сбой канала — операция переносится. FailoverProjectChannel пропускает
|
||||
* эскалацию ярусов и не пишет в supplier_manual_sync_queue. Job-уровень
|
||||
* получает исключение и помечает попытку как deferred.
|
||||
*
|
||||
* Spec §8.
|
||||
*/
|
||||
final class WindowDeferredException extends \RuntimeException {}
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel;
|
||||
|
||||
use App\Exceptions\Supplier\SupplierAuthException;
|
||||
use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Mail\SupplierCriticalAlertMail;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierManualSyncQueue;
|
||||
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
|
||||
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Декоратор-оркестратор: ярус 1 (AJAX) → ярус 2 (form-driving) → ярус 3 (manual queue).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.4
|
||||
*
|
||||
* Bridge-методы createProjectForLiderra/updateProjectForLiderra принимают Project
|
||||
* (нужен для project_id в очереди яруса 3). Прямые createProject/updateProject
|
||||
* сохраняются для интерфейс-совместимости (без эскалации).
|
||||
*/
|
||||
final class FailoverProjectChannel implements SupplierProjectChannel
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SupplierProjectChannel $tier1,
|
||||
private readonly SupplierProjectChannel $tier2,
|
||||
private readonly Mailer $mailer,
|
||||
) {}
|
||||
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
return $this->tier1->createProject($dto);
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void
|
||||
{
|
||||
$this->tier1->updateProject($externalId, $dto);
|
||||
}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return $this->tier1->listProjects();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create с эскалацией: использует Project для project_id в очереди яруса 3.
|
||||
*/
|
||||
public function createProjectForLiderra(Project $project, SupplierProjectDto $dto): int
|
||||
{
|
||||
// Spec §4.4 шаг 2: портальная сверка через listProjects() до любого create.
|
||||
// Защита от дубля при полу-успехе яруса 1 в прошлом запуске.
|
||||
try {
|
||||
$existing = $this->findOnPortal($dto);
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// listProjects недоступен — продолжаем (ярус-эскалация покроет сбой),
|
||||
// но провал дедупа логируем: иначе при полу-успехе яруса 1 в прошлом
|
||||
// прогоне молча создастся дубль rt-проекта.
|
||||
Log::warning('FailoverProjectChannel: dedup-сверка listProjects провалена', [
|
||||
'platform' => $dto->platform,
|
||||
'unique_key' => $dto->uniqueKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->tier1->createProject($dto);
|
||||
} catch (WindowDeferredException $e) {
|
||||
throw $e;
|
||||
} catch (SupplierTransientException $e) {
|
||||
$this->escalateToTier3($project, 'create', null, $dto, 'portal_unreachable', $e);
|
||||
} catch (SupplierClientException|SupplierAuthException $e) {
|
||||
try {
|
||||
$id = $this->tier2->createProject($dto);
|
||||
$this->alertFailoverToForm($project, 'create', $e);
|
||||
|
||||
return $id;
|
||||
} catch (Throwable $tier2Error) {
|
||||
$this->escalateToTier3(
|
||||
$project, 'create', null, $dto,
|
||||
$this->classifyTier2Failure($tier2Error), $tier2Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Все ветки выше терминируют (return / throw / escalateToTier3(): never) —
|
||||
// явный «unreachable»-throw не нужен (deadCode.unreachable).
|
||||
}
|
||||
|
||||
public function updateProjectForLiderra(Project $project, int $externalId, SupplierProjectDto $dto): void
|
||||
{
|
||||
try {
|
||||
$this->tier1->updateProject($externalId, $dto);
|
||||
|
||||
return;
|
||||
} catch (WindowDeferredException $e) {
|
||||
throw $e;
|
||||
} catch (SupplierTransientException $e) {
|
||||
$this->escalateToTier3($project, 'update', $externalId, $dto, 'portal_unreachable', $e);
|
||||
} catch (SupplierClientException|SupplierAuthException $e) {
|
||||
try {
|
||||
$this->tier2->updateProject($externalId, $dto);
|
||||
$this->alertFailoverToForm($project, 'update', $e);
|
||||
|
||||
return;
|
||||
} catch (Throwable $tier2Error) {
|
||||
$this->escalateToTier3(
|
||||
$project, 'update', $externalId, $dto,
|
||||
$this->classifyTier2Failure($tier2Error), $tier2Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function escalateToTier3(
|
||||
Project $project,
|
||||
string $operation,
|
||||
?int $externalId,
|
||||
SupplierProjectDto $dto,
|
||||
string $reason,
|
||||
Throwable $cause,
|
||||
): never {
|
||||
$row = SupplierManualSyncQueue::create([
|
||||
'project_id' => $project->id,
|
||||
'platform' => $dto->platform,
|
||||
'operation' => $operation,
|
||||
'external_id' => $externalId !== null ? (string) $externalId : null,
|
||||
'payload_snapshot' => [
|
||||
'signal_type' => $dto->signalType,
|
||||
'unique_key' => $dto->uniqueKey,
|
||||
'limit' => $dto->limit,
|
||||
'workdays' => $dto->workdays,
|
||||
'regions' => $dto->regions,
|
||||
'regions_reverse' => $dto->regionsReverse,
|
||||
'status' => $dto->status,
|
||||
],
|
||||
'failure_reason' => $reason,
|
||||
'status' => 'pending',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$this->mailer->to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierCriticalAlertMail(
|
||||
alertType: 'manual_required',
|
||||
details: "Project #{$project->id} ({$dto->platform}/{$dto->uniqueKey}) — {$operation} queued #{$row->id}, reason: {$reason}. Cause: ".mb_substr($cause->getMessage(), 0, 300),
|
||||
));
|
||||
|
||||
throw new TierEscalatedException($row->id, $reason);
|
||||
}
|
||||
|
||||
private function alertFailoverToForm(Project $project, string $operation, Throwable $cause): void
|
||||
{
|
||||
$this->mailer->to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierCriticalAlertMail(
|
||||
alertType: 'failover_to_form',
|
||||
details: "Project #{$project->id} {$operation}: Tier 1 (AJAX) failed, Tier 2 (browser) succeeded. Cause: ".mb_substr($cause->getMessage(), 0, 300),
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Портальная сверка: ищет уже существующий проект на портале по тройке
|
||||
* (platform, signal_type, unique_key). Возвращает external_id найденного
|
||||
* или null. Spec §4.4 шаг 2, §7.
|
||||
*/
|
||||
private function findOnPortal(SupplierProjectDto $dto): ?int
|
||||
{
|
||||
foreach ($this->tier1->listProjects() as $row) {
|
||||
if (
|
||||
($row['platform'] ?? null) === $dto->platform
|
||||
&& ($row['signal_type'] ?? null) === $dto->signalType
|
||||
&& ($row['unique_key'] ?? null) === $dto->uniqueKey
|
||||
) {
|
||||
return (int) ($row['id'] ?? 0) ?: null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function classifyTier2Failure(Throwable $e): string
|
||||
{
|
||||
$msg = mb_strtolower($e->getMessage());
|
||||
if (str_contains($msg, 'auth') || str_contains($msg, 'login')) {
|
||||
return 'auth_failure';
|
||||
}
|
||||
if (str_contains($msg, 'selector') || str_contains($msg, 'form')) {
|
||||
return 'form_selector_break';
|
||||
}
|
||||
|
||||
return 'form_save_error';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel;
|
||||
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\PlaywrightBridge;
|
||||
|
||||
/**
|
||||
* Ярус 2: водит форму «Мои проекты» supplier-портала через manage-project.js.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.3
|
||||
*/
|
||||
final class FormProjectChannel implements SupplierProjectChannel
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PlaywrightBridge $bridge,
|
||||
) {}
|
||||
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
$out = $this->callBridge('create', null, $dto);
|
||||
$id = (int) ($out['external_id'] ?? 0);
|
||||
if ($id === 0) {
|
||||
throw new \RuntimeException('FormProjectChannel: create returned empty external_id');
|
||||
}
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void
|
||||
{
|
||||
$out = $this->callBridge('update', $externalId, $dto);
|
||||
if (($out['ok'] ?? false) !== true) {
|
||||
throw new \RuntimeException('FormProjectChannel: update did not return ok=true');
|
||||
}
|
||||
}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
$out = $this->callBridge('list', null, null);
|
||||
|
||||
return (array) ($out['projects'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function callBridge(string $operation, ?int $externalId, ?SupplierProjectDto $dto): array
|
||||
{
|
||||
return $this->bridge->run([
|
||||
'script' => 'manage-project.js',
|
||||
'operation' => $operation,
|
||||
'externalId' => $externalId,
|
||||
'dto' => $dto !== null ? $this->mapDto($dto) : null,
|
||||
'login' => (string) config('services.supplier.login'),
|
||||
'password' => (string) config('services.supplier.password'),
|
||||
'url' => (string) config('services.supplier.portal_url'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function mapDto(SupplierProjectDto $dto): array
|
||||
{
|
||||
return [
|
||||
'tag' => $dto->uniqueKey,
|
||||
'name' => $dto->uniqueKey,
|
||||
'platforms' => [$dto->platform],
|
||||
'signal_type' => $dto->signalType,
|
||||
'limit' => $dto->limit,
|
||||
'workdays' => $dto->workdays,
|
||||
'regions' => $dto->regions,
|
||||
'region_mode' => $dto->regionsReverse ? 'exclude' : 'include',
|
||||
'domains' => $dto->signalType === 'site' ? [$dto->uniqueKey] : [],
|
||||
'active' => $dto->status === 'active',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier\Channel;
|
||||
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
|
||||
/**
|
||||
* Контракт миграции проекта Лидерра → поставщик crm.bp-gr.ru.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.1
|
||||
*
|
||||
* Реализации (ярусы резерва):
|
||||
* - AjaxProjectChannel — rt-project-* HTTP (primary, быстрый).
|
||||
* - FormProjectChannel — Playwright водит форму «Мои проекты» (fallback).
|
||||
* - FailoverProjectChannel — декоратор-оркестратор (ярус 1 → ярус 2 → ярус 3 queue).
|
||||
*/
|
||||
interface SupplierProjectChannel
|
||||
{
|
||||
/**
|
||||
* Создаёт проект на портале, возвращает supplier external_id.
|
||||
*/
|
||||
public function createProject(SupplierProjectDto $dto): int;
|
||||
|
||||
/**
|
||||
* Обновляет существующий проект (квота/дни/регионы).
|
||||
*/
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void;
|
||||
|
||||
/**
|
||||
* Список проектов с портала — для дедуп-сверки и закрытия яруса 3.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listProjects(): array;
|
||||
}
|
||||
@@ -52,4 +52,46 @@ class PlaywrightBridge
|
||||
|
||||
return $output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Node-скрипт runner: запускает playwright/<script> с JSON stdin,
|
||||
* возвращает декодированный JSON stdout. Используется FormProjectChannel
|
||||
* (manage-project.js — ярус 2 резерва канала миграции проектов).
|
||||
*
|
||||
* @param array<string, mixed> $args обязательный ключ 'script'; остальное — payload на stdin.
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function run(array $args): array
|
||||
{
|
||||
$script = $args['script'] ?? null;
|
||||
if (! is_string($script) || $script === '') {
|
||||
throw new \InvalidArgumentException('PlaywrightBridge::run requires non-empty "script" key');
|
||||
}
|
||||
|
||||
$payload = $args;
|
||||
unset($payload['script']);
|
||||
|
||||
$process = $this->processFactory->create(
|
||||
['node', 'playwright/'.$script],
|
||||
base_path(),
|
||||
);
|
||||
$process->setInput(json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
$process->setTimeoutSeconds(self::TIMEOUT_SECONDS);
|
||||
$process->run();
|
||||
|
||||
if (! $process->isSuccessful()) {
|
||||
throw new \RuntimeException(
|
||||
"PlaywrightBridge::run({$script}) exit code {$process->getExitCode()}: {$process->getErrorOutput()}",
|
||||
);
|
||||
}
|
||||
|
||||
$output = json_decode($process->getOutput(), true);
|
||||
if (! is_array($output)) {
|
||||
throw new \RuntimeException(
|
||||
"PlaywrightBridge::run({$script}) returned non-array output: {$process->getOutput()}",
|
||||
);
|
||||
}
|
||||
|
||||
return $output;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,21 +7,19 @@ namespace App\Services\Supplier;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Streaming-парсер CSV-экспорта `/admin/report/index?type=49` поставщика.
|
||||
* Streaming-парсер CSV-отчёта «Запрос номеров» supplier-портала crm.bp-gr.ru.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.2
|
||||
* Ожидаемые столбцы: vid;project;tag;phone;phones;time (placeholder; уточнится
|
||||
* после Plan 3 Tasks 1-2 discovery с credentials поставщика).
|
||||
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.1
|
||||
* Столбцы: Name;Tag;Phone — 3 колонки. vid и время в этом отчёте отсутствуют.
|
||||
*
|
||||
* Возвращает Generator — вызывающий (CsvReconcileJob) сам решает, сколько
|
||||
* копить в памяти. BOM + CRLF поддерживаются. Malformed rows skip + log.
|
||||
* Возвращает Generator. BOM + CRLF поддерживаются. Malformed rows skip + log.
|
||||
*/
|
||||
final class SupplierCsvParser
|
||||
{
|
||||
private const EXPECTED_COLUMNS = 6;
|
||||
private const EXPECTED_COLUMNS = 3;
|
||||
|
||||
/**
|
||||
* @return iterable<int, array{vid: string, project: string, phone: string, time: int}>
|
||||
* @return iterable<int, array{project: string, tag: string, phone: string}>
|
||||
*/
|
||||
public function parse(string $rawCsv): iterable
|
||||
{
|
||||
@@ -29,7 +27,7 @@ final class SupplierCsvParser
|
||||
return;
|
||||
}
|
||||
|
||||
// Убираем BOM (UTF-8 BOM = EF BB BF)
|
||||
// Убираем UTF-8 BOM (EF BB BF)
|
||||
if (str_starts_with($rawCsv, "\xEF\xBB\xBF")) {
|
||||
$rawCsv = substr($rawCsv, 3);
|
||||
}
|
||||
@@ -65,10 +63,9 @@ final class SupplierCsvParser
|
||||
}
|
||||
|
||||
yield [
|
||||
'vid' => (string) $cols[0],
|
||||
'project' => (string) $cols[1],
|
||||
'phone' => (string) $cols[3],
|
||||
'time' => (int) $cols[5],
|
||||
'project' => (string) $cols[0],
|
||||
'tag' => (string) $cols[1],
|
||||
'phone' => (string) $cols[2],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ use App\Exceptions\Supplier\SupplierAuthException;
|
||||
use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
@@ -21,14 +20,25 @@ use Illuminate\Support\Facades\Cache;
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.4
|
||||
*
|
||||
* Endpoints (placeholder, точные имена адаптируются после Task 1 discovery):
|
||||
* - GET /admin/rt-projects-load — список проектов
|
||||
* - POST /admin/rt-project-save — создание
|
||||
* - POST /admin/rt-project-update — обновление
|
||||
* - POST /admin/rt-project-delete — удаление
|
||||
* Endpoints (verified live 2026-05-19 через Playwright MCP recon —
|
||||
* создан LIDPOTOK_TEST_DELETE_ME, записаны сеть-запросы, проект удалён;
|
||||
* см. план Task 1 docs/superpowers/plans/2026-05-19-supplier-project-channel-failover.md):
|
||||
* - GET /admin/visit/rt-projects-load?src=none — массив всех rt-проектов tenant'а.
|
||||
* - POST /admin/visit/rt-project-save — create (id:0) ИЛИ update (id:N).
|
||||
* Body: application/json, большой Vuex-state. Минимально требуемые поля
|
||||
* описаны в toPayload(). Response:
|
||||
* success → HTTP 200 + {"status":"OK","message":"","result":null,"id":"<string>"}
|
||||
* error → HTTP 200 + {"status":"Error","message":"<reason>","result":null}
|
||||
* ID в ответе — строка (например, "12721245"); приводим к int (fits в int64).
|
||||
* Один save c B1+B2+B3 (несколько включённых src*-флагов) создаёт N rt-проектов
|
||||
* (по одному на каждый включённый канал); `id` в response — последний из созданных.
|
||||
* В нашем use case toPayload() отправляет ровно один платформенный флаг.
|
||||
* - POST /admin/visit/rt-project-delete — удаление по id.
|
||||
* Body: application/json {"id":"<string>"}. Response: тот же конверт {status,message,result}.
|
||||
*
|
||||
* Авторизация: PHPSESSID cookie + X-CSRF-Token header (Redis cache 'supplier:session').
|
||||
* На 401/403 — single retry через dispatch_sync(RefreshSupplierSessionJob).
|
||||
* На HTTP 200 + status:"Error" — выбрасываем SupplierClientException с message портала.
|
||||
*/
|
||||
class SupplierPortalClient
|
||||
{
|
||||
@@ -37,106 +47,202 @@ class SupplierPortalClient
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Идемпотентно обеспечивает наличие supplier_project-записи для переданной
|
||||
* тройки (platform, signalType, uniqueKey). Если запись уже существует —
|
||||
* возвращает её id. Иначе — создаёт проект на стороне поставщика через
|
||||
* saveProject() и сохраняет новую запись supplier_projects.
|
||||
* Сырые строки rt-проектов с портала.
|
||||
*
|
||||
* Используется SyncSupplierProjectJob (Plan 5 Task 4).
|
||||
* Verified live 2026-05-19: GET /admin/visit/rt-projects-load?src=none
|
||||
* возвращает объект-конверт {projects:[...], tags, users, tokens, categories}
|
||||
* — НЕ голый массив. Извлекаем `projects`. Строка проекта:
|
||||
* {id:string, tag, src, name:"B<n>_<key>", type:"hosts|calls|sms", lim,
|
||||
* workdays, regions, regions_reverse, content, ...}.
|
||||
* Приведение к контрактной форме SupplierProjectChannel — в AjaxProjectChannel.
|
||||
*
|
||||
* В тестах метод мокируется через $this->mock(SupplierPortalClient::class) —
|
||||
* реальное тело не вызывается.
|
||||
*
|
||||
* @param string $platform B1 / B2 / B3
|
||||
* @param string $signalType site / call / sms
|
||||
* @param string $uniqueKey domain / phone / sender+keyword / sender
|
||||
*/
|
||||
public function ensureSupplierProject(string $platform, string $signalType, string $uniqueKey): int
|
||||
{
|
||||
$existing = SupplierProject::query()
|
||||
->where('platform', $platform)
|
||||
->where('signal_type', $signalType)
|
||||
->where('unique_key', $uniqueKey)
|
||||
->first();
|
||||
|
||||
if ($existing !== null) {
|
||||
return $existing->id;
|
||||
}
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $uniqueKey,
|
||||
limit: 0,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
$externalId = $this->saveProject($dto);
|
||||
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $uniqueKey,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => null,
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
return $sp->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, mixed>
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listProjects(): array
|
||||
{
|
||||
$response = $this->request('GET', '/admin/rt-projects-load');
|
||||
$response = $this->request('GET', '/admin/visit/rt-projects-load', ['src' => 'none']);
|
||||
|
||||
return $response->json() ?? [];
|
||||
$body = $response->json();
|
||||
$projects = is_array($body) ? ($body['projects'] ?? []) : [];
|
||||
|
||||
return is_array($projects) ? array_values($projects) : [];
|
||||
}
|
||||
|
||||
public function saveProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
$response = $this->request('POST', '/admin/rt-project-save', $this->toPayload($dto));
|
||||
$response = $this->request(
|
||||
'POST',
|
||||
'/admin/visit/rt-project-save',
|
||||
$this->toPayload($dto, externalId: 0),
|
||||
asJson: true,
|
||||
);
|
||||
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
|
||||
|
||||
return (int) ($response->json('id') ?? 0);
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void
|
||||
{
|
||||
$this->request('POST', '/admin/rt-project-update', array_merge(
|
||||
['id' => $externalId],
|
||||
$this->toPayload($dto)
|
||||
));
|
||||
$response = $this->request(
|
||||
'POST',
|
||||
'/admin/visit/rt-project-save',
|
||||
$this->toPayload($dto, externalId: $externalId),
|
||||
asJson: true,
|
||||
);
|
||||
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
|
||||
}
|
||||
|
||||
public function deleteProject(int $externalId): void
|
||||
{
|
||||
$this->request('POST', '/admin/rt-project-delete', ['id' => $externalId]);
|
||||
$response = $this->request(
|
||||
'POST',
|
||||
'/admin/visit/rt-project-delete',
|
||||
['id' => (string) $externalId],
|
||||
asJson: true,
|
||||
);
|
||||
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-delete');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /admin/report/index?type=49 — CSV-экспорт лидов за окно [from, to].
|
||||
* Auth/retry семантика наследуется от request() (PHPSESSID + X-CSRF-Token +
|
||||
* 401 → RefreshSession + 5xx → SupplierTransientException + 4xx → SupplierClientException).
|
||||
*
|
||||
* Возвращает raw CSV-body (UTF-8 + BOM, CRLF). Парсинг — снаружи через
|
||||
* SupplierCsvParser (streaming через generator).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.1
|
||||
* Portal-конверт ответа: HTTP 200 + {"status":"OK"|"Error", "message":"...", ...}.
|
||||
* Текстовая бизнес-ошибка приходит с HTTP 200 — HTTP-уровень обрабатывает только
|
||||
* 401/403/4xx/5xx; status=Error превращаем в SupplierClientException здесь.
|
||||
*/
|
||||
public function downloadLeadsCsv(CarbonInterface $from, CarbonInterface $to): string
|
||||
private function assertStatusOk(Response $response, string $path): void
|
||||
{
|
||||
$response = $this->request('GET', '/admin/report/index', [
|
||||
'type' => 49,
|
||||
'from' => $from->format('Y-m-d H:i:s'),
|
||||
'to' => $to->format('Y-m-d H:i:s'),
|
||||
]);
|
||||
$status = $response->json('status');
|
||||
|
||||
if ($status === 'OK') {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status === 'Error') {
|
||||
$message = (string) ($response->json('message') ?? 'unknown');
|
||||
throw new SupplierClientException(
|
||||
"Supplier rejected {$path}: {$message}",
|
||||
httpStatus: $response->status(),
|
||||
responseBody: $response->body(),
|
||||
);
|
||||
}
|
||||
|
||||
// Неконвертный ответ — считаем как client-error (контракт сломан).
|
||||
throw new SupplierClientException(
|
||||
"Supplier returned unexpected envelope on {$path}: status={$status}",
|
||||
httpStatus: $response->status(),
|
||||
responseBody: $response->body(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Заказывает у поставщика отчёт «Запрос номеров» за диапазон [from, to].
|
||||
* Возвращает report_id для последующего waitReportReady / downloadReport.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.3.
|
||||
*
|
||||
* Discovery T3 verified 2026-05-19 (Playwright MCP, см. snapshot
|
||||
* `supplier-api-configured-2026-05-19.png`):
|
||||
* - POST /admin/report/save-report принимает JSON {reportForm:{selectType:49},
|
||||
* reportFilter:{dateFrom, dateTo, ...defaults}} и возвращает строку "OK"
|
||||
* (НЕ JSON с id).
|
||||
* - id извлекается отдельным GET /admin/report/load-reports — это массив
|
||||
* отчётов в DESC-порядке, ищем первый с title
|
||||
* "Запрос номеров с {from} по {to}".
|
||||
*/
|
||||
public function requestNumbersReport(CarbonInterface $from, CarbonInterface $to): int
|
||||
{
|
||||
$this->request('POST', '/admin/report/save-report', [
|
||||
'reportForm' => ['selectType' => 49],
|
||||
'reportFilter' => [
|
||||
'dateFrom' => $from->format('Y-m-d'),
|
||||
'dateTo' => $to->format('Y-m-d'),
|
||||
'slug' => null,
|
||||
'rate' => 'all',
|
||||
'dnss' => '',
|
||||
'phones' => '',
|
||||
'prophones' => 'curr',
|
||||
'users' => [],
|
||||
'domains' => [],
|
||||
'utcs' => [],
|
||||
'types' => ['phones'],
|
||||
'xls' => false,
|
||||
'project_id' => null,
|
||||
'state_id' => 0,
|
||||
'gck_tech' => 'gck',
|
||||
],
|
||||
], asJson: true);
|
||||
|
||||
$expectedTitle = sprintf(
|
||||
'Запрос номеров с %s по %s',
|
||||
$from->format('Y-m-d'),
|
||||
$to->format('Y-m-d'),
|
||||
);
|
||||
|
||||
$list = $this->request('GET', '/admin/report/load-reports')->json();
|
||||
if (! is_array($list)) {
|
||||
throw new SupplierClientException('load-reports returned non-array response');
|
||||
}
|
||||
|
||||
foreach ($list as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if (($row['title'] ?? null) === $expectedTitle) {
|
||||
return (int) ($row['id'] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
throw new SupplierClientException(
|
||||
"Report just queued (title '{$expectedTitle}') not found in load-reports",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Опрашивает статус отчёта до значения «Обработан» (status="1").
|
||||
* На таймаут — SupplierTransientException.
|
||||
*
|
||||
* Discovery T3 verified: status — строка "0" (в обработке) / "1" (готов);
|
||||
* endpoint — общий GET /admin/report/load-reports (не /status?id=N).
|
||||
*/
|
||||
public function waitReportReady(int $reportId): void
|
||||
{
|
||||
$maxAttempts = 20;
|
||||
$delaySeconds = 3;
|
||||
|
||||
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
|
||||
$list = $this->request('GET', '/admin/report/load-reports')->json();
|
||||
if (is_array($list)) {
|
||||
foreach ($list as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
if ((int) ($row['id'] ?? 0) === $reportId && (string) ($row['status'] ?? '') === '1') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($attempt < $maxAttempts) {
|
||||
sleep($delaySeconds);
|
||||
}
|
||||
}
|
||||
|
||||
throw new SupplierTransientException(
|
||||
"Report {$reportId} not ready after {$maxAttempts} polls"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Скачивает готовый отчёт как raw CSV-body (UTF-8 + BOM, CRLF).
|
||||
* Парсинг — снаружи через SupplierCsvParser.
|
||||
*
|
||||
* Discovery T3 verified: endpoint GET /admin/report/getfile?id=N — совпадает с placeholder.
|
||||
*/
|
||||
public function downloadReport(int $reportId): string
|
||||
{
|
||||
$response = $this->request('GET', '/admin/report/getfile', ['id' => $reportId]);
|
||||
|
||||
return $response->body();
|
||||
}
|
||||
@@ -144,7 +250,7 @@ class SupplierPortalClient
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
*/
|
||||
private function request(string $method, string $path, array $body = [], bool $isRetry = false): Response
|
||||
private function request(string $method, string $path, array $body = [], bool $isRetry = false, bool $asJson = false): Response
|
||||
{
|
||||
$session = $this->loadSession();
|
||||
$portalUrl = (string) config('services.supplier.portal_url');
|
||||
@@ -159,11 +265,14 @@ class SupplierPortalClient
|
||||
$request = $this->http
|
||||
->withCookies(['PHPSESSID' => $session['phpsessid']], $host)
|
||||
->withHeaders(['X-CSRF-Token' => $session['csrf']])
|
||||
->timeout(30);
|
||||
->connectTimeout(30)
|
||||
->timeout(60);
|
||||
|
||||
try {
|
||||
if ($method === 'GET') {
|
||||
$response = $request->get($url, $body);
|
||||
} elseif ($asJson) {
|
||||
$response = $request->asJson()->post($url, $body);
|
||||
} else {
|
||||
$response = $request->asForm()->post($url, $body);
|
||||
}
|
||||
@@ -211,9 +320,43 @@ class SupplierPortalClient
|
||||
);
|
||||
}
|
||||
|
||||
// Defense-in-depth: портал отдаёт логин-страницу с HTTP 200 при истекшей
|
||||
// сессии middle-of-use (вместо 401/403). Детектим Yii2-маркер и форсим
|
||||
// refresh+retry. Verified 2026-05-19: refresh-session.js ловит #loginform-username.
|
||||
if ($this->isHtmlLoginPage($response)) {
|
||||
if ($isRetry) {
|
||||
throw new SupplierAuthException(
|
||||
"Portal returned login page after refresh on {$path}",
|
||||
httpStatus: $response->status(),
|
||||
responseBody: $response->body(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
dispatch_sync(app(RefreshSupplierSessionJob::class));
|
||||
} catch (\Throwable $e) {
|
||||
throw new SupplierAuthException(
|
||||
"Session refresh failed during HTML-login retry on {$path}: {$e->getMessage()}",
|
||||
httpStatus: $response->status(),
|
||||
previous: $e,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->request($method, $path, $body, isRetry: true, asJson: $asJson);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function isHtmlLoginPage(Response $response): bool
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
if (! str_starts_with(mb_strtolower($contentType), 'text/html')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('~loginform-(username|password)~i', $response->body()) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{phpsessid: string, csrf: string, refreshed_at?: string}
|
||||
*/
|
||||
@@ -244,23 +387,68 @@ class SupplierPortalClient
|
||||
}
|
||||
|
||||
/**
|
||||
* NOTE: payload-shape — placeholder. Точные поля будут адаптированы
|
||||
* после Task 1 discovery + Task 2 spec §4.4 (отдельный fixup commit
|
||||
* перед Task 6 при расхождении).
|
||||
* Payload-shape для /admin/visit/rt-project-save (create + update).
|
||||
* Verified live 2026-05-19 (Playwright MCP recon — записан реальный JSON body
|
||||
* админ-формы «Добавить проект»; create=id:0, update=id:N).
|
||||
*
|
||||
* Mappings (наш DTO ↔ portal Vuex-state):
|
||||
* - platform: B1 → srcrt=true; B2 → srcbl=true; B3 → srcmt=true (single-true,
|
||||
* остальные false). Только один платформа за save — чтобы получить ровно
|
||||
* один rt-проект (множественные флаги создают N проектов, мы привязываемся
|
||||
* к одному external_id).
|
||||
* - signalType: site → type:"hosts"; call → type:"calls"; sms → type:"sms".
|
||||
* - uniqueKey → одновременно `name` (label проекта на портале — портал
|
||||
* префиксует "B<n>_" автоматически) и `content` (домен/телефон в полях
|
||||
* сбора).
|
||||
* - workdays: int[1..7] → string["1".."7"] (portal принимает строки).
|
||||
* - regions: int[]; regions_reverse: bool.
|
||||
* - status: "active" → true; "paused" → false.
|
||||
*
|
||||
* Дополнительно отправляем `tag:"_lidpotok"` для маркировки автоматизированных
|
||||
* проектов в админке портала + минимальный набор Vuex-defaults (show/depth/
|
||||
* multisignals/multigroup), которые портал ожидает в state-валидаторе.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function toPayload(SupplierProjectDto $dto): array
|
||||
private function toPayload(SupplierProjectDto $dto, int $externalId): array
|
||||
{
|
||||
$type = match ($dto->signalType) {
|
||||
'site' => 'hosts',
|
||||
'call' => 'calls',
|
||||
'sms' => 'sms',
|
||||
default => $dto->signalType,
|
||||
};
|
||||
|
||||
$srcrt = $dto->platform === 'B1';
|
||||
$srcbl = $dto->platform === 'B2';
|
||||
$srcmt = $dto->platform === 'B3';
|
||||
|
||||
// workdays: int → string (portal: ["1","2",...,"7"]).
|
||||
$workdays = array_map(static fn (int $d): string => (string) $d, $dto->workdays);
|
||||
|
||||
return [
|
||||
'platform' => $dto->platform,
|
||||
'signal_type' => $dto->signalType,
|
||||
'unique_key' => $dto->uniqueKey,
|
||||
'id' => $externalId,
|
||||
'tag' => '_lidpotok',
|
||||
'name' => $dto->uniqueKey,
|
||||
'type' => $type,
|
||||
'content' => $dto->uniqueKey,
|
||||
'srcrt' => $srcrt,
|
||||
'srcbl' => $srcbl,
|
||||
'srcmt' => $srcmt,
|
||||
'srcmg' => false,
|
||||
'srclal' => false,
|
||||
'srcdop' => false,
|
||||
'srcwz' => false,
|
||||
'srcseg' => false,
|
||||
'limit' => $dto->limit,
|
||||
'workdays' => $dto->workdays,
|
||||
'workdays' => $workdays,
|
||||
'regions' => $dto->regions,
|
||||
'regions_reverse' => $dto->regionsReverse ? 1 : 0,
|
||||
'status' => $dto->status,
|
||||
'regions_reverse' => $dto->regionsReverse,
|
||||
'status' => $dto->status === 'active',
|
||||
'show' => true,
|
||||
'multisignals' => false,
|
||||
'multigroup' => false,
|
||||
'depth' => 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Guard: после migrate:fresh schema.sql загружается первой (load_initial_schema).
|
||||
// Если schema.sql уже отдаёт vid как nullable — миграция no-op (idempotent).
|
||||
$isNullable = DB::selectOne(
|
||||
"SELECT is_nullable FROM information_schema.columns
|
||||
WHERE table_name = 'supplier_leads' AND column_name = 'vid'"
|
||||
);
|
||||
if ($isNullable !== null && $isNullable->is_nullable === 'YES') {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN vid DROP NOT NULL');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Внимание: down() не симметричен после migrate:fresh со свежей schema.sql.
|
||||
// Не использовать как откат schema-bump — нужна отдельная schema-правка.
|
||||
DB::statement('ALTER TABLE supplier_leads ALTER COLUMN vid SET NOT NULL');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Создаёт SaaS-level очередь яруса 3 резерва канала миграции проектов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
|
||||
*
|
||||
* Без tenant_id / RLS (как supplier_csv_reconcile_log) — доступ только SaaS-admin.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Guard: после migrate:fresh schema.sql даёт таблицу первой. Idempotent.
|
||||
$exists = DB::selectOne(
|
||||
"SELECT to_regclass('public.supplier_manual_sync_queue') AS r"
|
||||
);
|
||||
if ($exists !== null && $exists->r !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// unprepared — multi-statement (PG prepared statements не разрешают `;`).
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE TABLE supplier_manual_sync_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(8) NOT NULL,
|
||||
operation VARCHAR(16) NOT NULL,
|
||||
external_id VARCHAR(64),
|
||||
payload_snapshot JSONB NOT NULL,
|
||||
failure_reason VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
resolved_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
CONSTRAINT chk_smsq_platform CHECK (platform IN ('B1', 'B2', 'B3')),
|
||||
CONSTRAINT chk_smsq_operation CHECK (operation IN ('create', 'update')),
|
||||
CONSTRAINT chk_smsq_status CHECK (status IN ('pending', 'resolved', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_smsq_status_created ON supplier_manual_sync_queue (status, created_at DESC);
|
||||
CREATE INDEX idx_smsq_project ON supplier_manual_sync_queue (project_id);
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP TABLE IF EXISTS supplier_manual_sync_queue');
|
||||
}
|
||||
};
|
||||
@@ -252,6 +252,12 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/Project/ProjectService.php
|
||||
|
||||
-
|
||||
message: '#^Call to function is_array\(\) with array\<string, mixed\> will always evaluate to true\.$#'
|
||||
identifier: function.alreadyNarrowedType
|
||||
count: 1
|
||||
path: app/Services/Supplier/Channel/AjaxProjectChannel.php
|
||||
|
||||
-
|
||||
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
|
||||
identifier: method.childReturnType
|
||||
@@ -318,6 +324,18 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminPricingTiersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -330,6 +348,24 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1059,7 +1095,7 @@ parameters:
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
count: 13
|
||||
count: 20
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
@@ -1077,7 +1113,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 7
|
||||
count: 10
|
||||
path: tests/Feature/DealShowTest.php
|
||||
|
||||
-
|
||||
@@ -1497,7 +1533,7 @@ parameters:
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 8
|
||||
count: 12
|
||||
path: tests/Feature/Plan5/Projects/ProjectsUpdateTest.php
|
||||
|
||||
-
|
||||
@@ -1746,6 +1782,36 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/AutoPauseFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Services\\Supplier\\PlaywrightBridge\:\:\$lastArgs\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
path: tests/Feature/Supplier/Channel/FormProjectChannelTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Illuminate\\Contracts\\Cache\\Repository\:\:lock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$tier1 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
|
||||
identifier: argument.type
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#2 \$tier2 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
|
||||
identifier: argument.type
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>RT Project Form Fixture — Element UI + Vuetify dialog</title>
|
||||
<style>
|
||||
/* Minimal stubs so Playwright class-based locators work */
|
||||
.el-form-item { margin-bottom: 12px; }
|
||||
.el-form-item__label { display: inline-block; min-width: 140px; }
|
||||
.el-form-item__content { display: inline-block; }
|
||||
.el-input__inner { border: 1px solid #cccccc; padding: 4px 8px; }
|
||||
.el-checkbox { cursor: pointer; margin-right: 8px; }
|
||||
.el-checkbox__input.is-checked .el-checkbox__inner { background: #409eff; }
|
||||
.el-checkbox__inner { display: inline-block; width: 14px; height: 14px; border: 1px solid #cccccc; }
|
||||
.el-switch { cursor: pointer; }
|
||||
.el-switch.is-checked .el-switch__core { background: #409eff; }
|
||||
.el-switch__core { display: inline-block; width: 40px; height: 20px; border-radius: 10px; background: #cccccc; }
|
||||
.el-select-dropdown { position: absolute; background: #ffffff; border: 1px solid #cccccc; z-index: 9999; min-width: 120px; }
|
||||
.el-select-dropdown__item { padding: 6px 12px; cursor: pointer; }
|
||||
.el-select-dropdown__item:hover { background: #f5f7fa; }
|
||||
.el-button { padding: 6px 16px; cursor: pointer; border: 1px solid #cccccc; background: #ffffff; }
|
||||
.el-input-number .el-input__inner { width: 80px; }
|
||||
</style>
|
||||
</head><body>
|
||||
|
||||
<!-- Vuetify dialog wrapper — required by manage-project.js locator ".v-dialog--active button:has-text(...)" -->
|
||||
<div class="v-dialog v-dialog--active v-dialog--persistent" style="padding:16px;">
|
||||
|
||||
<form class="el-form el-form--label-left">
|
||||
|
||||
<!-- 1. Tag -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="tag">Тег</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="tag-fixture">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Источник данных (B1/B2/B3 checkboxes) — label for="srcrt" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="srcrt">Источник данных</label>
|
||||
<div class="el-form-item__content" id="srcrt-container">
|
||||
<label class="el-checkbox is-checked" data-platform="B1">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B1</span>
|
||||
</label>
|
||||
<label class="el-checkbox is-checked" data-platform="B2">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B2</span>
|
||||
</label>
|
||||
<label class="el-checkbox is-checked" data-platform="B3">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B3</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Name — label for="name" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="name">Название проекта</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="name-fixture">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Type select — label for="type" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="type">Источники сбора</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-select" id="type-select-container">
|
||||
<!-- readonly input that shows selected value; clicking it opens dropdown popup in body -->
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="type-select-input" readonly
|
||||
value="Сайты" placeholder="Выберите" data-current-value="Сайты">
|
||||
<span class="el-input__suffix"><span class="el-select__caret">▼</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Slider «Период» — no label-for, no DTO field, leave default -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label">Период</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-slider" aria-valuemin="0" aria-valuemax="24" aria-valuetext="10-18">
|
||||
<span style="font-size:12px;color:#999999">10-18 (default)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Switch «Включить» — no label-for; identified by .el-switch in form-item -->
|
||||
<div class="el-form-item" id="switch-form-item">
|
||||
<label class="el-form-item__label">Статус</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-switch" id="active-switch">
|
||||
<input type="checkbox" class="el-switch__input" id="active-switch-input">
|
||||
<span class="el-switch__core"></span>
|
||||
<span>Включить</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. Regions — label for="regions", el-select multiple -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="regions">Регион</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-select el-select--multiple">
|
||||
<input type="text" class="el-input__inner" id="regions-input" placeholder="Выберите регионы">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 8. limit_off — no label-for, no DTO field -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="limit_off">Разделять по проектам</label>
|
||||
<div class="el-form-item__content">
|
||||
<label class="el-checkbox">
|
||||
<span class="el-checkbox__input">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original">
|
||||
</span>
|
||||
<span class="el-checkbox__label">Да</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 9. Content (uniqueKey / domains) — label for="content", el-tabs -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="content">Список сайтов</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-tabs">
|
||||
<div class="el-tabs__header">
|
||||
<div class="el-tabs__item is-active" data-tab="list">Список</div>
|
||||
<div class="el-tabs__item" data-tab="file">Файл</div>
|
||||
</div>
|
||||
<div class="el-tabs__content">
|
||||
<textarea class="el-textarea__inner" id="content-textarea" rows="4" style="width:100%"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. Limit — label for="limit", el-input-number -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="limit">Лимит в день</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input-number">
|
||||
<span class="el-input-number__decrease">-</span>
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="limit-input" value="10">
|
||||
</div>
|
||||
<span class="el-input-number__increase">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form><!-- end .el-form -->
|
||||
|
||||
<!-- Save/Cancel buttons OUTSIDE form, INSIDE .v-dialog--active -->
|
||||
<div style="margin-top:16px;">
|
||||
<button type="button" class="el-button" id="save-btn">Сохранить</button>
|
||||
<button type="button" class="el-button" id="cancel-btn">Отмена</button>
|
||||
</div>
|
||||
|
||||
</div><!-- end .v-dialog--active -->
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ---- Checkbox toggle behaviour ----
|
||||
// Click on .el-checkbox toggles .is-checked on itself and .el-checkbox__input child
|
||||
document.querySelectorAll('#srcrt-container .el-checkbox').forEach(function(cb) {
|
||||
cb.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var isChecked = cb.classList.contains('is-checked');
|
||||
cb.classList.toggle('is-checked', !isChecked);
|
||||
var cbInput = cb.querySelector('.el-checkbox__input');
|
||||
if (cbInput) cbInput.classList.toggle('is-checked', !isChecked);
|
||||
var rawInput = cb.querySelector('input.el-checkbox__original');
|
||||
if (rawInput) rawInput.checked = !isChecked;
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Switch toggle behaviour ----
|
||||
var switchEl = document.getElementById('active-switch');
|
||||
if (switchEl) {
|
||||
switchEl.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var isChecked = switchEl.classList.contains('is-checked');
|
||||
switchEl.classList.toggle('is-checked', !isChecked);
|
||||
var inp = document.getElementById('active-switch-input');
|
||||
if (inp) inp.checked = !isChecked;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Type select popup ----
|
||||
// When input#type-select-input is clicked, create a dropdown in body
|
||||
var typeInput = document.getElementById('type-select-input');
|
||||
var typeOptions = ['Сайты', 'Звонки', 'СМС', 'Ретро сайты', 'Ретро звонки'];
|
||||
|
||||
function removeDropdown() {
|
||||
var existing = document.querySelector('body > .el-select-dropdown');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
if (typeInput) {
|
||||
typeInput.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
removeDropdown();
|
||||
var dropdown = document.createElement('div');
|
||||
dropdown.className = 'el-select-dropdown el-popper';
|
||||
dropdown.style.position = 'absolute';
|
||||
dropdown.style.left = '20px';
|
||||
dropdown.style.top = '200px';
|
||||
var ul = document.createElement('ul');
|
||||
ul.className = 'el-scrollbar__view el-select-dropdown__list';
|
||||
typeOptions.forEach(function(opt) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'el-select-dropdown__item';
|
||||
li.textContent = opt;
|
||||
li.addEventListener('click', function(e2) {
|
||||
e2.stopPropagation();
|
||||
typeInput.value = opt;
|
||||
typeInput.setAttribute('data-current-value', opt);
|
||||
removeDropdown();
|
||||
});
|
||||
ul.appendChild(li);
|
||||
});
|
||||
dropdown.appendChild(ul);
|
||||
document.body.appendChild(dropdown);
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener('click', function() {
|
||||
removeDropdown();
|
||||
});
|
||||
|
||||
// ---- Save button: POST to /admin/visit/rt-project-save on the same origin ----
|
||||
// NOTE: NO fetch mock here — the HTTP server (manage-project.test.js) handles
|
||||
// this route and returns {status:"OK",id:"99001"}. Playwright's waitForResponse
|
||||
// intercepts real network requests, not mocked fetch.
|
||||
document.getElementById('save-btn').addEventListener('click', function() {
|
||||
var payload = {
|
||||
tag: document.getElementById('tag-fixture') ? document.getElementById('tag-fixture').value : '',
|
||||
name: document.getElementById('name-fixture') ? document.getElementById('name-fixture').value : '',
|
||||
type: typeInput ? typeInput.getAttribute('data-current-value') : 'Сайты',
|
||||
limit: document.getElementById('limit-input') ? document.getElementById('limit-input').value : '10',
|
||||
};
|
||||
fetch('/admin/visit/rt-project-save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Headless Playwright водит UI «Мои проекты» supplier-портала crm.bp-gr.ru.
|
||||
*
|
||||
* Input (JSON через stdin):
|
||||
* {operation: "create"|"update"|"list", login, password, url, skipLogin?, dto?, externalId?}
|
||||
*
|
||||
* Output (JSON через stdout):
|
||||
* - create: {external_id: "12345"}
|
||||
* - update: {ok: true}
|
||||
* - list: {projects: [...]}
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 — success
|
||||
* 1 — auth failed
|
||||
* 2 — DOM/селектор не найден (контракт UI сменился — escalation cause)
|
||||
* 3 — timeout
|
||||
* 4 — invalid input или другая ошибка
|
||||
*
|
||||
* Spec §4.3.
|
||||
*
|
||||
* KNOWN GAPS (Tier-2 MVP, зафиксированы по recon 2026-05-19):
|
||||
* - workdays: поле add-project форм НЕ содержит чекбоксы дней недели (только slider «Период»
|
||||
* часы 0-24). DTO.workdays игнорируется; портал применяет дефолт (все 7 дней).
|
||||
* Для точной настройки workdays используйте Tier-1 (AJAX).
|
||||
* - regions: форма требует имена регионов, DTO несёт int[] id. Mapping id→name не реализован.
|
||||
* Tier-2 всегда передаёт пустой массив регионов (нет фильтрации). Регионы должны быть
|
||||
* настроены вручную или через Tier-1.
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const TIMEOUT_MS = 90_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Возвращает локатор form-item по значению атрибута for= у label.
|
||||
* Стратегия: .el-form-item:has(.el-form-item__label[for="<attrFor>"])
|
||||
*/
|
||||
function fieldByFor(page, attrFor) {
|
||||
return page.locator(`.el-form-item:has(.el-form-item__label[for="${attrFor}"])`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function login(page, args) {
|
||||
// skipLogin: args.url — статическая фикстура формы (тестовый режим),
|
||||
// открываем её напрямую и не логинимся.
|
||||
if (args.skipLogin) {
|
||||
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
return;
|
||||
}
|
||||
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
await page.fill('#loginform-username', args.login);
|
||||
await page.fill('#loginform-password', args.password);
|
||||
await Promise.all([
|
||||
page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
|
||||
page.click('button[type=submit]'),
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fillForm — Element UI label-for локаторы (recon 2026-05-19)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fillForm(page, dto) {
|
||||
// NOTE: статус active/paused НЕ выставляется через форму. Единственный
|
||||
// .el-switch на форме — это include/exclude регионов («Включить/Исключить»,
|
||||
// recon 2026-05-19 row 6), НЕ статус проекта. Статус задаётся дефолтом
|
||||
// портала (active). dto.active игнорируется в Tier-2; switch не трогаем
|
||||
// (regions skip — см. ниже). Verified live 2026-05-19.
|
||||
|
||||
// --- 1. Tag ---
|
||||
if (dto.tag !== undefined && dto.tag !== null) {
|
||||
await fieldByFor(page, 'tag').locator('input.el-input__inner').fill(String(dto.tag));
|
||||
}
|
||||
|
||||
// --- 2. Platforms (srcrt) — B1/B2/B3 checkboxes ---
|
||||
// Initial: все три checked. Нужно включить только те, что в dto.platforms, остальные выключить.
|
||||
const platformContainer = fieldByFor(page, 'srcrt');
|
||||
for (const p of ['B1', 'B2', 'B3']) {
|
||||
const wanted = (dto.platforms || []).includes(p);
|
||||
// Identification — по `.el-checkbox__label` textContent (per recon-doc
|
||||
// 2026-05-19-rt-project-form-locators.md row 2: реальный портал НЕ имеет
|
||||
// `data-platform`-атрибута, inputs без `name`). Whitespace-tolerant `^\s*B1\s*$`.
|
||||
const cb = platformContainer.locator('.el-checkbox').filter({
|
||||
has: page.locator('.el-checkbox__label', { hasText: new RegExp(`^\\s*${p}\\s*$`) }),
|
||||
}).first();
|
||||
const cbClass = await cb.getAttribute('class').catch(() => '');
|
||||
const isChecked = (cbClass || '').includes('is-checked');
|
||||
if (!!isChecked !== wanted) {
|
||||
await cb.click();
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. Name (label for="name") ---
|
||||
// В реальном портале dto.name заполняется в поле «Название проекта»,
|
||||
// а dto.uniqueKey (список сайтов/номеров) — в textarea «content».
|
||||
// manage-project.js получает dto.name напрямую.
|
||||
if (dto.name !== undefined) {
|
||||
await fieldByFor(page, 'name').locator('input.el-input__inner').fill(String(dto.name));
|
||||
}
|
||||
|
||||
// --- 4. Type select (label for="type") ---
|
||||
// El-select readonly input. Клик открывает popup в body > .el-select-dropdown.
|
||||
const signalTypeMap = { site: 'Сайты', call: 'Звонки', sms: 'СМС' };
|
||||
const signalLabel = signalTypeMap[dto.signal_type];
|
||||
if (!signalLabel) {
|
||||
throw new Error(
|
||||
`Unsupported signal_type "${dto.signal_type}". Supported: site, call, sms. ` +
|
||||
'"Ретро сайты" / "Ретро звонки" are not supported in Tier-2 form channel.',
|
||||
);
|
||||
}
|
||||
// Тип меняем ТОЛЬКО если текущее значение ≠ нужное. Смена типа ремоунтит
|
||||
// content tab-pane (Сайты/Звонки/СМС — разные поля сбора) → если сразу
|
||||
// после type-select заполнять content, fill попадёт в detached textarea
|
||||
// (Vue ещё не закончил ре-рендер) → rt-project-save уходит с пустым
|
||||
// `content` → портал «Введите домены». Verified live 2026-05-19.
|
||||
const typeInput = fieldByFor(page, 'type').locator('.el-select input.el-input__inner');
|
||||
const currentType = (await typeInput.inputValue().catch(() => '')).trim();
|
||||
if (currentType !== signalLabel) {
|
||||
await typeInput.click();
|
||||
// Dropdown рендерится снаружи формы в body — ждём его появления
|
||||
const dropdownOption = page.locator('.el-select-dropdown__item', {
|
||||
hasText: new RegExp(`^${signalLabel}$`),
|
||||
});
|
||||
await dropdownOption.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
|
||||
await dropdownOption.click();
|
||||
// Ждём, пока Vue завершит ре-рендер content tab-pane после смены типа.
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
// --- 7. Regions (label for="regions") — SKIP, gap зафиксирован в JSDoc ---
|
||||
// DTO несёт int[] id; форма требует имена. Mapping не реализован для MVP.
|
||||
if (dto.regions && dto.regions.length > 0) {
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
warning: 'regions skipped in Tier-2 form channel: DTO carries int[] ids but form requires region names. ' +
|
||||
'Region filtering will not be applied. Configure regions manually or use Tier-1.',
|
||||
regions_received: dto.regions,
|
||||
}) + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
// --- 9. Content — список сайтов/номеров/отправителей (label for="content") ---
|
||||
// Вкладка «Список» (default active). dto.domains — массив строк или dto.uniqueKey — строка.
|
||||
const contentLines = dto.domains && dto.domains.length
|
||||
? dto.domains.join('\n')
|
||||
: dto.uniqueKey
|
||||
? String(dto.uniqueKey)
|
||||
: null;
|
||||
if (contentLines) {
|
||||
const contentField = fieldByFor(page, 'content');
|
||||
// Вкладка «Список» — default active. Кликаем ТОЛЬКО если она НЕ активна:
|
||||
// клик по вкладке Element UI ремоунтит tab-pane → textarea детачится,
|
||||
// и последующий .fill() гонится с ре-рендером (домены теряются →
|
||||
// rt-project-save уходит с пустым `content` → портал «Введите домены»).
|
||||
// Verified live 2026-05-19: re-click активной вкладки ломал save.
|
||||
const listTab = contentField.locator('.el-tabs__item', { hasText: 'Список' }).first();
|
||||
if ((await listTab.count()) > 0) {
|
||||
const tabClass = (await listTab.getAttribute('class')) || '';
|
||||
if (!tabClass.includes('is-active')) {
|
||||
await listTab.click();
|
||||
await contentField.locator('textarea.el-textarea__inner')
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
const contentTa = contentField.locator('textarea.el-textarea__inner');
|
||||
await contentTa.fill(contentLines);
|
||||
// Defensive: убедиться, что значение действительно осело в textarea
|
||||
// (если поле детачнулось ре-рендером — fill уйдёт в пустоту).
|
||||
const filledValue = await contentTa.inputValue();
|
||||
if (filledValue.trim() === '') {
|
||||
throw new Error(
|
||||
'Content textarea empty after fill — likely tab/type re-render race; domains lost',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 10. Limit (label for="limit") ---
|
||||
if (dto.limit !== undefined) {
|
||||
await fieldByFor(page, 'limit').locator('input.el-input__inner').fill(String(dto.limit));
|
||||
}
|
||||
|
||||
// NOTE: workdays — gap зафиксирован в JSDoc. Форма add-project не содержит
|
||||
// чекбоксы дней недели. dto.workdays игнорируется.
|
||||
if (dto.workdays && dto.workdays.length !== 7) {
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
warning: 'workdays ignored in Tier-2 form channel: add-project form has no workdays field. ' +
|
||||
'Portal will apply default (all 7 days). Configure workdays manually or use Tier-1.',
|
||||
workdays_received: dto.workdays,
|
||||
}) + '\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(
|
||||
args.url.replace(/\/$/, '') + '/admin/visit/rt',
|
||||
{ waitUntil: 'load', timeout: TIMEOUT_MS },
|
||||
);
|
||||
// Кнопка «Добавить проект» — recon: label [title="Добавить проект"]
|
||||
await page.locator('button:has-text("Добавить проект")').click();
|
||||
// Ждём появления формы — label for="name" внутри .el-form
|
||||
await page.locator('.el-form-item__label[for="name"]').waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
await fillForm(page, args.dto);
|
||||
|
||||
// Кликаем «Сохранить» + перехватываем ответ rt-project-save
|
||||
const [saveResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
|
||||
{ timeout: TIMEOUT_MS },
|
||||
),
|
||||
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
|
||||
]);
|
||||
|
||||
const body = await saveResponse.json();
|
||||
if (body.status !== 'OK') {
|
||||
// DIAG: дамп фактически отправленного тела — для расследования "Введите домены"
|
||||
const sentBody = saveResponse.request().postData();
|
||||
process.stderr.write(JSON.stringify({ diag_sent_body: sentBody }) + '\n');
|
||||
throw new Error(`Portal rejected save: ${body.message || 'unknown error'}`);
|
||||
}
|
||||
const externalId = String(body.id ?? '');
|
||||
if (!externalId) {
|
||||
throw new Error('Portal returned status=OK but empty id');
|
||||
}
|
||||
|
||||
return { external_id: externalId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function updateOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(
|
||||
args.url.replace(/\/$/, '') + '/admin/visit/rt',
|
||||
{ waitUntil: 'load', timeout: TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
// Найти строку таблицы по externalId и кликнуть кнопку редактирования.
|
||||
// Реальная таблица портала — Vuetify data-table; строки по data-id или текстовому совпадению.
|
||||
// Стратегия 1: строка с атрибутом data-id
|
||||
const rowLocator = page.locator(`tr[data-id="${args.externalId}"], [data-id="${args.externalId}"]`);
|
||||
const rowCount = await rowLocator.count();
|
||||
if (rowCount > 0) {
|
||||
await rowLocator.first().locator('button').first().click();
|
||||
} else {
|
||||
// Стратегия 2: найти строку содержащую текст externalId и кликнуть edit-кнопку
|
||||
await page.locator(`tr:has-text("${args.externalId}")`).first().locator('button').first().click();
|
||||
}
|
||||
|
||||
// Дождаться формы
|
||||
await page.locator('.el-form-item__label[for="name"]').waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await fillForm(page, args.dto);
|
||||
|
||||
// Перехватываем ответ rt-project-save при update (тот же endpoint)
|
||||
const [saveResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
|
||||
{ timeout: TIMEOUT_MS },
|
||||
),
|
||||
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
|
||||
]);
|
||||
|
||||
const body = await saveResponse.json();
|
||||
if (body.status !== 'OK') {
|
||||
throw new Error(`Portal rejected update: ${body.message || 'unknown error'}`);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function listOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(
|
||||
args.url.replace(/\/$/, '') + '/admin/visit/rt',
|
||||
{ waitUntil: 'load', timeout: TIMEOUT_MS },
|
||||
);
|
||||
}
|
||||
|
||||
// Стратегия 1: Vuex state (если доступен)
|
||||
const projects = await page.evaluate(() => {
|
||||
try {
|
||||
if (window.app && window.app.$store && window.app.$store.state) {
|
||||
const st = window.app.$store.state;
|
||||
const list = st.projects || st.rtProjects || st.visitProjects || null;
|
||||
if (Array.isArray(list)) {
|
||||
return list.map((p) => ({
|
||||
id: parseInt(p.id, 10),
|
||||
name: p.name || p.title || null,
|
||||
platform: p.platform || null,
|
||||
signal_type: p.type || p.signal_type || null,
|
||||
unique_key: p.content || p.unique_key || null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (_) { /* Vuex недоступен */ }
|
||||
return null;
|
||||
});
|
||||
|
||||
if (projects !== null) {
|
||||
return { projects };
|
||||
}
|
||||
|
||||
// Стратегия 2: DOM-скрейп таблицы
|
||||
// Реальная таблица портала: строки tr с data-id или стандартные td
|
||||
const rows = await page.locator('table tbody tr[data-id], .v-data-table tbody tr[data-id]').evaluateAll(
|
||||
(nodes) => nodes.map((n) => ({
|
||||
id: parseInt(n.dataset.id || '0', 10),
|
||||
name: n.querySelector('td:nth-child(2)')
|
||||
? n.querySelector('td:nth-child(2)').textContent.trim()
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return { projects: rows };
|
||||
}
|
||||
|
||||
// Стратегия 3: фикстура / пустая страница — возвращаем пустой массив
|
||||
return { projects: [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function run(args) {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
let out;
|
||||
switch (args.operation) {
|
||||
case 'create': out = await createOp(page, args); break;
|
||||
case 'update': out = await updateOp(page, args); break;
|
||||
case 'list': out = await listOp(page, args); break;
|
||||
default: throw new Error('Unknown operation: ' + args.operation);
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
process.stderr.write(JSON.stringify({ error: err.message }));
|
||||
if (err.message.includes('Timeout')) process.exit(3);
|
||||
if (
|
||||
err.message.toLowerCase().includes('selector') ||
|
||||
err.message.toLowerCase().includes('locator')
|
||||
) process.exit(2);
|
||||
if (
|
||||
err.message.toLowerCase().includes('login') ||
|
||||
err.message.toLowerCase().includes('auth')
|
||||
) process.exit(1);
|
||||
process.exit(4);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
let input = '';
|
||||
process.stdin.on('data', (c) => { input += c; });
|
||||
process.stdin.on('end', () => {
|
||||
let args;
|
||||
try { args = JSON.parse(input); } catch (e) {
|
||||
process.stderr.write(JSON.stringify({ error: 'invalid JSON on stdin' }));
|
||||
process.exit(4);
|
||||
}
|
||||
if (!args.operation || !args.url) {
|
||||
process.stderr.write(JSON.stringify({ error: 'missing required: operation, url' }));
|
||||
process.exit(4);
|
||||
}
|
||||
run(args);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Фикстурный тест manage-project.js — против локального HTTP-сервера с Element UI фикстурой.
|
||||
*
|
||||
* Почему HTTP, не file://: manage-project.js перехватывает ответ page.waitForResponse()
|
||||
* с URL endsWith('/admin/visit/rt-project-save'). Браузер не шлёт network-запросы при
|
||||
* file://-origin fetch из-за CORS/same-origin ограничений в Chromium.
|
||||
*
|
||||
* Runner: встроенный node:test (Node 18+). Запуск: `node --test manage-project.test.js`.
|
||||
*/
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { execFile } = require('node:child_process');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, 'manage-project.js');
|
||||
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures', 'rt-form-element-ui.html');
|
||||
|
||||
/** Запустить ephemeral HTTP-сервер, отдающий фикстуру и обрабатывающий mock-эндпоинты. */
|
||||
function startFixtureServer() {
|
||||
return new Promise((resolve) => {
|
||||
const html = fs.readFileSync(FIXTURE_PATH, 'utf8');
|
||||
const server = http.createServer((req, res) => {
|
||||
// Mock rt-project-save — Playwright перехватывает реальный сетевой запрос
|
||||
if (req.url && req.url.includes('rt-project-save') && req.method === 'POST') {
|
||||
// Consume request body (important — don't hang connection)
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
const payload = JSON.stringify({ status: 'OK', message: '', result: null, id: '99001' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(payload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Default: serve fixture HTML
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
/** Спавнить manage-project.js, подать JSON на stdin, вернуть {code, stdout, stderr}. */
|
||||
function runScript(input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile(
|
||||
'node',
|
||||
[SCRIPT],
|
||||
{ timeout: 90_000 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err && err.killed) return reject(new Error('Process killed / timed out'));
|
||||
// err.code — exit code; treat as expected (tests assert on code)
|
||||
resolve({
|
||||
code: err ? err.code : 0,
|
||||
stdout: stdout.toString(),
|
||||
stderr: stderr.toString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
child.stdin.write(JSON.stringify(input));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1 — createProject через Element UI фикстуру → external_id из mock-response
|
||||
// ---------------------------------------------------------------------------
|
||||
test('createProject fills Element UI form and returns external_id from intercept response', async () => {
|
||||
const server = await startFixtureServer();
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
|
||||
const result = await runScript({
|
||||
operation: 'create',
|
||||
url,
|
||||
skipLogin: true,
|
||||
dto: {
|
||||
tag: '_lidpotok',
|
||||
name: 'example.com',
|
||||
platforms: ['B1'],
|
||||
signal_type: 'site',
|
||||
limit: 5,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
domains: ['example.com'],
|
||||
region_mode: 'include',
|
||||
regions: [],
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
|
||||
|
||||
let out;
|
||||
try {
|
||||
out = JSON.parse(result.stdout);
|
||||
} catch (e) {
|
||||
assert.fail(`stdout is not valid JSON: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
}
|
||||
assert.strictEqual(out.external_id, '99001', `expected external_id "99001", got ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2 — listProjects в skipLogin-режиме возвращает массив projects
|
||||
// ---------------------------------------------------------------------------
|
||||
test('listProjects returns array (skipLogin mode, fixture page)', async () => {
|
||||
const server = await startFixtureServer();
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
|
||||
const result = await runScript({
|
||||
operation: 'list',
|
||||
url,
|
||||
skipLogin: true,
|
||||
});
|
||||
|
||||
// listOp в skipLogin-режиме не навигирует на /admin/visit/rt — просто открывает url.
|
||||
// Фикстура не содержит Vuex и таблицы с проектами → возвращает {projects: []}.
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0. stderr: ${result.stderr}`);
|
||||
|
||||
let out;
|
||||
try {
|
||||
out = JSON.parse(result.stdout);
|
||||
} catch (e) {
|
||||
assert.fail(`stdout is not valid JSON: ${result.stdout}`);
|
||||
}
|
||||
assert.ok(Array.isArray(out.projects), `expected projects array, got: ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
@@ -27,17 +27,36 @@ async function refresh(args) {
|
||||
|
||||
await page.goto(args.url, { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
|
||||
// DOM-селекторы — placeholder до Task 1 discovery
|
||||
const loginSelector = 'input[name=login]';
|
||||
const passwordSelector = 'input[name=password]';
|
||||
// DOM-селекторы crm.bp-gr.ru/login (Yii2 LoginForm) — verified live 2026-05-19 через Playwright MCP.
|
||||
const loginSelector = '#loginform-username';
|
||||
const passwordSelector = '#loginform-password';
|
||||
const submitSelector = 'button[type=submit]';
|
||||
|
||||
await page.fill(loginSelector, args.login);
|
||||
await page.fill(passwordSelector, args.password);
|
||||
await Promise.all([
|
||||
page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
|
||||
page.click(submitSelector),
|
||||
]);
|
||||
|
||||
// Сабмит + ОЖИДАНИЕ пост-логин перехода.
|
||||
// Старый Promise.all([waitForLoadState('networkidle'), click]) — гонка:
|
||||
// логин-страница уже в состоянии networkidle, поэтому waitForLoadState
|
||||
// резолвился мгновенно (ДО редиректа), и скрипт хватал PHPSESSID
|
||||
// неаутентифицированной логин-страницы. Ждём, пока логин-форма исчезнет
|
||||
// из DOM — waitForFunction опрашивает и переживает навигацию.
|
||||
await page.click(submitSelector);
|
||||
await page
|
||||
.waitForFunction(
|
||||
(sel) => !document.querySelector(sel),
|
||||
loginSelector,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
)
|
||||
.catch(() => { /* форма осталась — логин отклонён, ловится guard'ом ниже */ });
|
||||
await page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }).catch(() => {});
|
||||
|
||||
// Verify: логин-форма всё ещё на странице → вход НЕ удался. Не возвращаем
|
||||
// мусорную (неаутентифицированную) сессию как «успех» (exit 0).
|
||||
if ((await page.locator(loginSelector).count()) > 0) {
|
||||
process.stderr.write(JSON.stringify({ error: 'login rejected: still on login page after submit' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let csrf = null;
|
||||
try {
|
||||
|
||||
@@ -165,6 +165,9 @@ export interface ApiDeal {
|
||||
comment: string | null;
|
||||
city: string | null;
|
||||
project_signal_type: string | null;
|
||||
project_signal_identifier?: string | null;
|
||||
project_sms_keyword?: string | null;
|
||||
project_sms_senders?: string[] | null;
|
||||
next_reminder_at: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { computed, defineAsyncComponent, ref, watch } from 'vue';
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import { type DealEvent } from '../../composables/mockDealEvents';
|
||||
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
|
||||
import { stripChannelPrefix } from '../../composables/projectName';
|
||||
import * as dealsApi from '../../api/deals';
|
||||
import * as remindersApi from '../../api/reminders';
|
||||
import type { ApiReminder } from '../../api/reminders';
|
||||
@@ -25,7 +26,13 @@ const props = defineProps<{
|
||||
tenantId?: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
// 18.05.2026 ux: статус меняется через inline picker в Hero.
|
||||
// Эмитим slug наверх — parent (DealDetailDrawer → DealsView/KanbanView)
|
||||
// делает optimistic update + API call + rollback.
|
||||
'status-changed': [slug: string];
|
||||
}>();
|
||||
|
||||
const status = computed(() => {
|
||||
if (!props.deal) return null;
|
||||
@@ -36,6 +43,26 @@ function formatCost(cost: number): string {
|
||||
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
|
||||
}
|
||||
|
||||
// Drawer-«легенда» (18.05.2026 ux): Тип + Источник проекта (read-only).
|
||||
// Редактирование — только в карточке проекта на /projects (см. план Task 5).
|
||||
const TYPE_LABELS: Record<string, string> = { site: 'Сайт', call: 'Звонок', sms: 'СМС' };
|
||||
const projectTypeLabel = computed((): string => {
|
||||
const t = props.deal?.projectSignalType;
|
||||
return t ? (TYPE_LABELS[t] ?? '—') : '—';
|
||||
});
|
||||
const projectSourceLabel = computed((): string => {
|
||||
if (!props.deal) return '—';
|
||||
const t = props.deal.projectSignalType;
|
||||
if (t === 'site' || t === 'call') return props.deal.projectSignalIdentifier ?? '—';
|
||||
if (t === 'sms') {
|
||||
const sender = props.deal.projectSmsSenders?.[0] ?? '';
|
||||
const kw = props.deal.projectSmsKeyword;
|
||||
if (sender && kw) return `${sender} (${kw})`;
|
||||
return sender || '—';
|
||||
}
|
||||
return '—';
|
||||
});
|
||||
|
||||
const events = ref<DealEvent[]>([]);
|
||||
const eventsLoading = ref(false);
|
||||
const eventsFetchError = ref(false);
|
||||
@@ -112,6 +139,12 @@ async function loadEvents() {
|
||||
}
|
||||
}
|
||||
|
||||
function onStatusChange(slug: string): void {
|
||||
if (!props.deal) return;
|
||||
if (props.deal.statusSlug === slug) return;
|
||||
emit('status-changed', slug);
|
||||
}
|
||||
|
||||
async function saveComment() {
|
||||
if (!props.deal || !props.tenantId) return;
|
||||
commentSaving.value = true;
|
||||
@@ -153,7 +186,13 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<div v-if="deal" class="drawer-content">
|
||||
<DealDetailHero :deal="deal" :status="status" @close="emit('close')" />
|
||||
<DealDetailHero
|
||||
:deal="deal"
|
||||
:status="status"
|
||||
:all-statuses="leadStatusesStore.statuses"
|
||||
@close="emit('close')"
|
||||
@change-status="onStatusChange"
|
||||
/>
|
||||
|
||||
<v-divider />
|
||||
|
||||
@@ -162,24 +201,19 @@ defineExpose({
|
||||
<dl class="params">
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Проект</dt>
|
||||
<dd class="text-body-2">{{ deal.project }}</dd>
|
||||
<dd class="text-body-2">{{ stripChannelPrefix(deal.project) }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
|
||||
<dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Менеджер</dt>
|
||||
<dd class="text-body-2">
|
||||
<v-avatar size="20" color="secondary" class="mr-1">
|
||||
<span class="text-caption">{{ deal.manager.initials }}</span>
|
||||
</v-avatar>
|
||||
{{ deal.manager.name }}
|
||||
</dd>
|
||||
<dt class="text-caption text-medium-emphasis">Тип</dt>
|
||||
<dd class="text-body-2">{{ projectTypeLabel }}</dd>
|
||||
</div>
|
||||
<div class="param">
|
||||
<dt class="text-caption text-medium-emphasis">Источник</dt>
|
||||
<dd class="text-body-2 link">Я.Директ → landing-1</dd>
|
||||
<dd class="text-body-2">{{ projectSourceLabel }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
@@ -19,7 +19,10 @@ const props = withDefaults(
|
||||
{ inline: false },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
'status-changed': [slug: string];
|
||||
}>();
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => props.open,
|
||||
@@ -33,7 +36,12 @@ function close() {
|
||||
|
||||
<template>
|
||||
<aside v-if="inline" v-show="open" class="deal-detail-inline" data-testid="deal-detail-panel">
|
||||
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
|
||||
<DealDetailBody
|
||||
:deal="deal"
|
||||
:tenant-id="tenantId"
|
||||
@close="close"
|
||||
@status-changed="(s: string) => emit('status-changed', s)"
|
||||
/>
|
||||
</aside>
|
||||
<v-navigation-drawer
|
||||
v-else
|
||||
@@ -43,7 +51,12 @@ function close() {
|
||||
:width="480"
|
||||
class="deal-drawer"
|
||||
>
|
||||
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
|
||||
<DealDetailBody
|
||||
:deal="deal"
|
||||
:tenant-id="tenantId"
|
||||
@close="close"
|
||||
@status-changed="(s: string) => emit('status-changed', s)"
|
||||
/>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -8,13 +8,20 @@
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
|
||||
defineProps<{
|
||||
deal: MockDeal;
|
||||
status: LeadStatus | null;
|
||||
}>();
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
deal: MockDeal;
|
||||
status: LeadStatus | null;
|
||||
// 18.05.2026 ux: inline status picker — кликабельный chip с выпадающим
|
||||
// списком всех статусов. Если allStatuses не передан — chip read-only.
|
||||
allStatuses?: LeadStatus[];
|
||||
}>(),
|
||||
{ allStatuses: () => [] },
|
||||
);
|
||||
|
||||
defineEmits<{
|
||||
close: [];
|
||||
'change-status': [slug: string];
|
||||
}>();
|
||||
|
||||
function formatRelative(minutes: number): string {
|
||||
@@ -41,10 +48,34 @@ function formatRelative(minutes: number): string {
|
||||
</div>
|
||||
|
||||
<div v-if="status" class="status-row mt-3">
|
||||
<v-chip size="small" variant="tonal" :style="{ color: status.colorHex, borderColor: status.colorHex }">
|
||||
<span class="status-dot" :style="{ background: status.colorHex }" />
|
||||
{{ status.nameRu }}
|
||||
</v-chip>
|
||||
<v-menu :disabled="(allStatuses?.length ?? 0) === 0">
|
||||
<template #activator="{ props: a }">
|
||||
<v-chip
|
||||
v-bind="a"
|
||||
data-testid="status-chip-trigger"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
:style="{ color: status.colorHex, borderColor: status.colorHex, cursor: (allStatuses?.length ?? 0) > 0 ? 'pointer' : 'default' }"
|
||||
>
|
||||
<span class="status-dot" :style="{ background: status.colorHex }" />
|
||||
{{ status.nameRu }}
|
||||
<v-icon v-if="(allStatuses?.length ?? 0) > 0" size="14" class="ml-1">mdi-menu-down</v-icon>
|
||||
</v-chip>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="s in allStatuses"
|
||||
:key="s.slug"
|
||||
:data-testid="`status-option-${s.slug}`"
|
||||
@click="$emit('change-status', s.slug)"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="status-dot" :style="{ background: s.colorHex }" />
|
||||
</template>
|
||||
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
import { stripChannelPrefix } from '../../composables/projectName';
|
||||
import StatusPill from '../ui/StatusPill.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -71,7 +72,7 @@ function rowProps(deal: MockDeal): Record<string, unknown> {
|
||||
|
||||
<template #[`item.project`]="{ item }: { item: MockDeal }">
|
||||
<div class="cell-source">
|
||||
<span class="source-project">{{ item.project }}</span>
|
||||
<span class="source-project">{{ stripChannelPrefix(item.project) }}</span>
|
||||
<span v-if="signalLabel(item.signalType)" class="source-signal">{{
|
||||
signalLabel(item.signalType)
|
||||
}}</span>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* Click → emit('open', deal.id) — TODO: правая панель DealDetailDrawer.
|
||||
*/
|
||||
import type { MockDeal } from '../../composables/mockDeals';
|
||||
import { stripChannelPrefix } from '../../composables/projectName';
|
||||
|
||||
defineProps<{ deal: MockDeal }>();
|
||||
const emit = defineEmits<{ open: [id: number] }>();
|
||||
@@ -27,7 +28,7 @@ function formatCost(cost: number): string {
|
||||
<div class="card-name">{{ deal.name }}</div>
|
||||
<div class="card-phone text-caption text-medium-emphasis">{{ deal.phone }}</div>
|
||||
<div class="card-meta mt-2">
|
||||
<span class="card-project text-caption">{{ deal.project }}</span>
|
||||
<span class="card-project text-caption">{{ stripChannelPrefix(deal.project) }}</span>
|
||||
<span class="card-cost num">{{ formatCost(deal.cost) }}</span>
|
||||
</div>
|
||||
<div class="card-foot mt-1">
|
||||
|
||||
@@ -16,6 +16,7 @@ interface FormState {
|
||||
delivery_days_mask: number;
|
||||
sms_senders: string[];
|
||||
sms_keyword: string;
|
||||
signal_identifier: string;
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
@@ -25,6 +26,7 @@ const form = reactive<FormState>({
|
||||
delivery_days_mask: 127,
|
||||
sms_senders: [],
|
||||
sms_keyword: '',
|
||||
signal_identifier: '',
|
||||
});
|
||||
|
||||
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
|
||||
@@ -37,6 +39,7 @@ function reseedFromProject(p: Project | null): void {
|
||||
form.delivery_days_mask = p.delivery_days_mask ?? 127;
|
||||
form.sms_senders = p.sms_senders ?? [];
|
||||
form.sms_keyword = p.sms_keyword ?? '';
|
||||
form.signal_identifier = p.signal_identifier ?? '';
|
||||
}
|
||||
reseedFromProject(props.project);
|
||||
|
||||
@@ -78,6 +81,10 @@ async function onSave(): Promise<void> {
|
||||
regions: form.regions,
|
||||
delivery_days_mask: form.delivery_days_mask,
|
||||
};
|
||||
// 18.05.2026 ux: редактирование источника проекта.
|
||||
if (props.project.signal_type === 'site' || props.project.signal_type === 'call') {
|
||||
payload.signal_identifier = form.signal_identifier;
|
||||
}
|
||||
if (props.project.signal_type === 'sms') {
|
||||
payload.sms_senders = form.sms_senders;
|
||||
payload.sms_keyword = form.sms_keyword;
|
||||
@@ -127,6 +134,54 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||
<div v-if="errors.name" class="pdd-error" data-testid="pdd-error-name">{{ errors.name[0] }}</div>
|
||||
</label>
|
||||
|
||||
<!-- 18.05.2026 ux: редактирование источника проекта (site/call/sms) -->
|
||||
<label v-if="project?.signal_type === 'site'" class="pdd-field">
|
||||
<span class="pdd-label">Источник — домен сайта-донора</span>
|
||||
<input
|
||||
v-model="form.signal_identifier"
|
||||
data-testid="pdd-signal-identifier"
|
||||
class="pdd-input"
|
||||
placeholder="okna-konkurent.ru"
|
||||
/>
|
||||
<div v-if="errors.signal_identifier" class="pdd-error" data-testid="pdd-error-signal">
|
||||
{{ errors.signal_identifier[0] }}
|
||||
</div>
|
||||
</label>
|
||||
<label v-else-if="project?.signal_type === 'call'" class="pdd-field">
|
||||
<span class="pdd-label">Источник — телефонный номер донора</span>
|
||||
<input
|
||||
v-model="form.signal_identifier"
|
||||
data-testid="pdd-signal-identifier"
|
||||
class="pdd-input"
|
||||
placeholder="79161234567"
|
||||
/>
|
||||
<div v-if="errors.signal_identifier" class="pdd-error" data-testid="pdd-error-signal">
|
||||
{{ errors.signal_identifier[0] }}
|
||||
</div>
|
||||
</label>
|
||||
<div v-else-if="project?.signal_type === 'sms'" class="pdd-field">
|
||||
<span class="pdd-label">Источник — отправители SMS</span>
|
||||
<v-combobox
|
||||
v-model="form.sms_senders"
|
||||
data-testid="pdd-sms-senders"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details
|
||||
placeholder="MTS, BEELINE …"
|
||||
/>
|
||||
<div v-if="errors.sms_senders" class="pdd-error">{{ errors.sms_senders[0] }}</div>
|
||||
<span class="pdd-label mt-2">Ключевое слово (опционально)</span>
|
||||
<input
|
||||
v-model="form.sms_keyword"
|
||||
data-testid="pdd-sms-keyword"
|
||||
class="pdd-input"
|
||||
placeholder="КРЕДИТ"
|
||||
/>
|
||||
<div v-if="errors.sms_keyword" class="pdd-error">{{ errors.sms_keyword[0] }}</div>
|
||||
</div>
|
||||
|
||||
<label class="pdd-field">
|
||||
<span class="pdd-label">Лимит лидов в день</span>
|
||||
<input
|
||||
|
||||
@@ -78,5 +78,9 @@ export function mapApiDeal(api: ApiDeal, now: Date = new Date()): MockDeal {
|
||||
comment: api.comment,
|
||||
receivedAt: api.received_at,
|
||||
nextReminderAt: api.next_reminder_at,
|
||||
projectSignalType: (api.project_signal_type as MockDeal['projectSignalType']) ?? null,
|
||||
projectSignalIdentifier: api.project_signal_identifier ?? null,
|
||||
projectSmsKeyword: api.project_sms_keyword ?? null,
|
||||
projectSmsSenders: api.project_sms_senders ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface MockDeal {
|
||||
comment?: string | null;
|
||||
receivedAt?: string | null; // ISO — колонка «Поставлен»
|
||||
nextReminderAt?: string | null; // ISO — колонка «Напоминание»
|
||||
// Drawer-«легенда» сделки (18.05.2026): Тип + Источник проекта (read-only).
|
||||
projectSignalType?: 'site' | 'call' | 'sms' | null;
|
||||
projectSignalIdentifier?: string | null;
|
||||
projectSmsKeyword?: string | null;
|
||||
projectSmsSenders?: string[] | null;
|
||||
}
|
||||
|
||||
export const MOCK_DEALS: MockDeal[] = [
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Утилиты отображения имён проектов crm.bp.
|
||||
*
|
||||
* Поставщик crm.bp префиксует имена проектов признаком канала-провайдера
|
||||
* (B1_/B2_/B3_ — три разных базы лидов). В UI Лидерры префикс — шум:
|
||||
* пользователю интересен сам проект, а не канал.
|
||||
*
|
||||
* Трансформация — **display-only**: данные в БД (`supplier_projects.name`)
|
||||
* не трогаем, фильтрация/поиск/маппинг идёт по сырому имени и `id`.
|
||||
*/
|
||||
|
||||
const CHANNEL_PREFIX_RE = /^B[123]_/i;
|
||||
|
||||
/**
|
||||
* Убирает префикс B1_/B2_/B3_ из начала имени проекта (case-insensitive).
|
||||
* Префикс внутри строки и другие буквы (B0/B4/Bx) не трогает.
|
||||
* null/undefined/'' -> ''.
|
||||
*/
|
||||
export function stripChannelPrefix(name: string | null | undefined): string {
|
||||
if (!name) return '';
|
||||
return name.replace(CHANNEL_PREFIX_RE, '');
|
||||
}
|
||||
@@ -32,6 +32,7 @@ const navItems: NavItem[] = [
|
||||
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents', count: 3 },
|
||||
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
|
||||
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
|
||||
{ title: 'Интеграция с поставщиком', icon: 'mdi-swap-horizontal', to: '/admin/supplier-integration' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -271,6 +271,18 @@ const routes: RouteRecordRaw[] = [
|
||||
devLabel: 'Admin Impersonation',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/admin/supplier-integration',
|
||||
name: 'admin-supplier-integration',
|
||||
component: () => import('../views/admin/AdminSupplierIntegrationView.vue'),
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
title: 'Интеграция с поставщиком',
|
||||
requiresAuth: true,
|
||||
devIndex: 30,
|
||||
devLabel: 'Admin Supplier Integration',
|
||||
},
|
||||
},
|
||||
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
|
||||
{
|
||||
path: '/403',
|
||||
|
||||
@@ -13,6 +13,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { MockDeal } from '../composables/mockDeals';
|
||||
import { mapApiDeal } from '../composables/dealsApiMapper';
|
||||
import { stripChannelPrefix } from '../composables/projectName';
|
||||
import { usePolling } from '../composables/usePolling';
|
||||
import DealsFilters from '../components/deals/DealsFilters.vue';
|
||||
import DealsBulkBar from '../components/deals/DealsBulkBar.vue';
|
||||
@@ -46,6 +47,11 @@ const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const fetchError = ref(false);
|
||||
const availableProjects = ref<dealsApi.ApiProject[]>([]);
|
||||
// Список для фильтра «Проект» — без префикса B1_/B2_/B3_ (display-only;
|
||||
// id сохраняем, фильтрация идёт по id, не по name).
|
||||
const availableProjectsForFilter = computed(() =>
|
||||
availableProjects.value.map((p) => ({ ...p, name: stripChannelPrefix(p.name) })),
|
||||
);
|
||||
|
||||
const leadStatuses = computed(() => leadStatusesStore.statuses);
|
||||
const statusBySlug = computed(() => leadStatusesStore.bySlug);
|
||||
@@ -114,6 +120,21 @@ watch([filterStatus, filterProject, receivedFrom, receivedTo, perPage], () => {
|
||||
});
|
||||
watch(page, () => void loadDeals());
|
||||
|
||||
// Selected-driven drawer visibility (18.05.2026 ux-request):
|
||||
// 0 selected → drawer по row-click; 1 selected → авто-открыт для этой сделки;
|
||||
// ≥2 selected → закрыт (показывается bulk-полоса).
|
||||
watch(selected, (ids) => {
|
||||
if (ids.length === 1) {
|
||||
const deal = dealsState.find((d) => d.id === ids[0]);
|
||||
if (deal) {
|
||||
selectedDeal.value = deal;
|
||||
panelOpen.value = true;
|
||||
}
|
||||
} else if (ids.length >= 2) {
|
||||
panelOpen.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Поиск по телефону — debounce 350 мс.
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
watch(searchPhone, () => {
|
||||
@@ -138,6 +159,28 @@ function clearFilters() {
|
||||
filterCity.value = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 18.05.2026 ux — inline status picker в drawer (DealDetailHero).
|
||||
* Optimistic UI: меняем statusSlug в dealsState ДО API, rollback при ошибке.
|
||||
*/
|
||||
async function onDrawerStatusChanged(slug: string): Promise<void> {
|
||||
if (!auth.user?.tenant_id || !selectedDeal.value) return;
|
||||
const id = selectedDeal.value.id;
|
||||
const target = dealsState.find((d) => d.id === id);
|
||||
if (!target) return;
|
||||
const prev = target.statusSlug;
|
||||
if (prev === slug) return;
|
||||
target.statusSlug = slug as MockDeal['statusSlug'];
|
||||
try {
|
||||
await dealsApi.updateDeal(id, { tenant_id: auth.user.tenant_id, status: slug });
|
||||
statusToastText.value = 'Статус обновлён.';
|
||||
} catch {
|
||||
target.statusSlug = prev;
|
||||
statusToastText.value = 'Не удалось сохранить статус.';
|
||||
}
|
||||
statusToastOpen.value = true;
|
||||
}
|
||||
|
||||
async function applyBulkStatus(slug: MockDeal['statusSlug']) {
|
||||
const ids = [...selected.value];
|
||||
statusMenuOpen.value = false;
|
||||
@@ -297,7 +340,7 @@ defineExpose({
|
||||
v-model:filter-project="filterProject"
|
||||
v-model:filter-city="filterCity"
|
||||
:lead-statuses="leadStatuses"
|
||||
:available-projects="availableProjects"
|
||||
:available-projects="availableProjectsForFilter"
|
||||
:available-cities="availableCities"
|
||||
class="mt-4"
|
||||
@clear-filters="clearFilters"
|
||||
@@ -319,6 +362,7 @@ defineExpose({
|
||||
</div>
|
||||
|
||||
<DealsBulkBar
|
||||
v-if="selected.length >= 2"
|
||||
v-model:status-menu-open="statusMenuOpen"
|
||||
:selected-count="selected.length"
|
||||
:lead-statuses="leadStatuses"
|
||||
@@ -356,6 +400,7 @@ defineExpose({
|
||||
:deal="selectedDeal"
|
||||
:tenant-id="auth.user?.tenant_id"
|
||||
@update:open="(v: boolean) => (panelOpen = v)"
|
||||
@status-changed="onDrawerStatusChanged"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -52,6 +52,44 @@ const dealsByStatus = reactive<Record<string, MockDeal[]>>(
|
||||
}, {}),
|
||||
);
|
||||
|
||||
/**
|
||||
* 18.05.2026 ux — inline status picker в drawer (DealDetailHero).
|
||||
* При смене статуса через drawer — переносим карточку между колонками
|
||||
* Канбана (vuedraggable arrays) + API call + rollback.
|
||||
*/
|
||||
async function onDrawerStatusChanged(slug: string): Promise<void> {
|
||||
if (!selectedDeal.value) return;
|
||||
const deal = selectedDeal.value;
|
||||
const prev = deal.statusSlug;
|
||||
if (prev === slug) return;
|
||||
const next = slug as MockDeal['statusSlug'];
|
||||
|
||||
// Optimistic: переносим карточку между колонками.
|
||||
const fromCol = dealsByStatus[prev];
|
||||
const toCol = dealsByStatus[next];
|
||||
if (fromCol && toCol) {
|
||||
const idx = fromCol.findIndex((d) => d.id === deal.id);
|
||||
if (idx >= 0) fromCol.splice(idx, 1);
|
||||
deal.statusSlug = next;
|
||||
toCol.unshift(deal);
|
||||
} else {
|
||||
deal.statusSlug = next;
|
||||
}
|
||||
|
||||
if (!auth.user?.tenant_id) return;
|
||||
try {
|
||||
await dealsApi.transitionDeals({ tenant_id: auth.user.tenant_id, ids: [deal.id], status: next });
|
||||
} catch {
|
||||
// Rollback: вернуть карточку обратно.
|
||||
deal.statusSlug = prev;
|
||||
if (fromCol && toCol) {
|
||||
const idx = toCol.findIndex((d) => d.id === deal.id);
|
||||
if (idx >= 0) toCol.splice(idx, 1);
|
||||
if (!fromCol.find((d) => d.id === deal.id)) fromCol.push(deal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) {
|
||||
if (!event.added) {
|
||||
// 'removed' и 'moved' — vuedraggable мутирует array; reactive triggers re-render.
|
||||
@@ -219,7 +257,12 @@ defineExpose({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DealDetailDrawer v-model:open="drawerOpen" :deal="selectedDeal" :tenant-id="auth.user?.tenant_id" />
|
||||
<DealDetailDrawer
|
||||
v-model:open="drawerOpen"
|
||||
:deal="selectedDeal"
|
||||
:tenant-id="auth.user?.tenant_id"
|
||||
@status-changed="onDrawerStatusChanged"
|
||||
/>
|
||||
|
||||
<NewDealDialog v-model="newDealOpen" :tenant-id="auth.user?.tenant_id" @created="onDealCreated" />
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
|
||||
interface ReconcileRow {
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
window_start: string;
|
||||
window_end: string;
|
||||
status: string;
|
||||
total_csv_rows: number;
|
||||
matched_count: number;
|
||||
recovered_count: number;
|
||||
drift_ratio: number;
|
||||
}
|
||||
|
||||
interface Health {
|
||||
last_run_at: string | null;
|
||||
last_status: string | null;
|
||||
drift_ratio: number | null;
|
||||
webhook_state: string;
|
||||
}
|
||||
|
||||
const health = ref<Health | null>(null);
|
||||
const history = ref<ReconcileRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const reconciling = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const { data } = await axios.get('/api/admin/supplier-integration');
|
||||
health.value = data.health;
|
||||
history.value = data.history;
|
||||
} catch {
|
||||
error.value = 'Не удалось загрузить состояние канала.';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileNow(): Promise<void> {
|
||||
reconciling.value = true;
|
||||
try {
|
||||
await axios.post('/api/admin/supplier-integration/reconcile');
|
||||
// Сверка асинхронная (queued job) — ждём ~4с и перезагружаем здоровье канала.
|
||||
setTimeout(() => void load(), 4000);
|
||||
} finally {
|
||||
reconciling.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function statusColor(status: string | null): string {
|
||||
if (status === 'ok') return 'success';
|
||||
if (status === 'drift_alert') return 'warning';
|
||||
if (status === 'failed') return 'error';
|
||||
return 'grey';
|
||||
}
|
||||
|
||||
// --- Ручная очередь (ярус 3 резерва канала миграции проектов) ---
|
||||
|
||||
interface ManualQueueRow {
|
||||
id: number;
|
||||
project_id: number;
|
||||
platform: string;
|
||||
operation: string;
|
||||
external_id: string | null;
|
||||
payload_snapshot: Record<string, unknown>;
|
||||
failure_reason: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const manualQueue = ref<ManualQueueRow[]>([]);
|
||||
const manualQueueError = ref<string | null>(null);
|
||||
const resolvingId = ref<number | null>(null);
|
||||
|
||||
async function loadManualQueue(): Promise<void> {
|
||||
try {
|
||||
const { data } = await axios.get('/api/admin/supplier-integration/manual-queue');
|
||||
manualQueue.value = Array.isArray(data?.queue) ? data.queue : [];
|
||||
} catch {
|
||||
manualQueueError.value = 'Не удалось загрузить очередь.';
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveRow(id: number): Promise<void> {
|
||||
if (!confirm('Подтверждаете, что внесли изменения в crm.bp-gr.ru?')) return;
|
||||
resolvingId.value = id;
|
||||
try {
|
||||
await axios.post(`/api/admin/supplier-integration/manual-queue/${id}/resolve`);
|
||||
await loadManualQueue();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
alert(err?.response?.data?.message ?? 'Не удалось закрыть запись.');
|
||||
} finally {
|
||||
resolvingId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(s: string): string {
|
||||
return new Date(s).toLocaleString('ru-RU');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
void loadManualQueue();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pa-6">
|
||||
<h1 class="text-h5 mb-4">Интеграция с поставщиком</h1>
|
||||
|
||||
<v-card class="mb-4">
|
||||
<v-card-title>Здоровье резервного канала</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert v-if="error" type="error" density="compact" class="mb-4">
|
||||
{{ error }}
|
||||
</v-alert>
|
||||
<template v-if="health">
|
||||
<div class="mb-2">
|
||||
Webhook:
|
||||
<v-chip :color="health.webhook_state === 'live' ? 'success' : 'error'" size="small">
|
||||
{{ health.webhook_state }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
Последняя сверка:
|
||||
<v-chip :color="statusColor(health.last_status)" size="small">
|
||||
{{ health.last_status ?? '—' }}
|
||||
</v-chip>
|
||||
<span class="ml-2">{{ health.last_run_at ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
Расхождение (drift):
|
||||
{{ health.drift_ratio !== null ? (health.drift_ratio * 100).toFixed(2) + ' %' : '—' }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else-if="loading" class="mb-4 text-medium-emphasis">Загрузка…</div>
|
||||
<v-btn
|
||||
data-test="reconcile-now"
|
||||
color="primary"
|
||||
:loading="reconciling"
|
||||
@click="reconcileNow"
|
||||
>
|
||||
Сверить сейчас
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card>
|
||||
<v-card-title>История сверок</v-card-title>
|
||||
<v-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Начало</th>
|
||||
<th>Статус</th>
|
||||
<th>Строк CSV</th>
|
||||
<th>Совпало</th>
|
||||
<th>Подобрано</th>
|
||||
<th>Drift</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in history" :key="row.started_at">
|
||||
<td>{{ row.started_at }}</td>
|
||||
<td>
|
||||
<v-chip :color="statusColor(row.status)" size="x-small">{{ row.status }}</v-chip>
|
||||
</td>
|
||||
<td>{{ row.total_csv_rows }}</td>
|
||||
<td>{{ row.matched_count }}</td>
|
||||
<td>{{ row.recovered_count }}</td>
|
||||
<td>{{ (row.drift_ratio * 100).toFixed(2) }} %</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
|
||||
<v-card class="mt-4">
|
||||
<v-card-title>
|
||||
Ручная очередь
|
||||
<v-chip v-if="manualQueue.length" color="warning" class="ml-2" size="small">
|
||||
{{ manualQueue.length }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert v-if="manualQueueError" type="error" density="compact">
|
||||
{{ manualQueueError }}
|
||||
</v-alert>
|
||||
<p v-else-if="!manualQueue.length" class="text-medium-emphasis">
|
||||
Очередь пуста — авто-фейловер не понадобился.
|
||||
</p>
|
||||
<v-table v-else density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Платформа</th>
|
||||
<th>Операция</th>
|
||||
<th>Параметры</th>
|
||||
<th>Причина</th>
|
||||
<th>Создано</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in manualQueue" :key="row.id">
|
||||
<td>#{{ row.project_id }}</td>
|
||||
<td>{{ row.platform }}</td>
|
||||
<td>{{ row.operation }}</td>
|
||||
<td>
|
||||
<code>{{ row.payload_snapshot.unique_key }}</code>
|
||||
· limit {{ row.payload_snapshot.limit ?? '—' }}
|
||||
</td>
|
||||
<td>{{ row.failure_reason }}</td>
|
||||
<td>{{ formatDate(row.created_at) }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
size="small"
|
||||
color="primary"
|
||||
:data-testid="`resolve-${row.id}`"
|
||||
:loading="resolvingId === row.id"
|
||||
@click="resolveRow(row.id)"
|
||||
>
|
||||
Отметить выполнено
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,6 +18,9 @@
|
||||
|
||||
<v-tabs-window v-model="form.signal_type" class="mt-4">
|
||||
<v-tabs-window-item value="site">
|
||||
<div class="source-hint text-caption text-medium-emphasis mb-2">
|
||||
Источник — домен сайта-«донора», с которого приходят лиды
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="form.signal_identifier"
|
||||
label="Домен конкурента"
|
||||
@@ -28,6 +31,9 @@
|
||||
/>
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="call">
|
||||
<div class="source-hint text-caption text-medium-emphasis mb-2">
|
||||
Источник — телефонный номер «донора», на который звонят клиенты
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="form.signal_identifier"
|
||||
label="Номер конкурента"
|
||||
@@ -39,6 +45,9 @@
|
||||
/>
|
||||
</v-tabs-window-item>
|
||||
<v-tabs-window-item value="sms">
|
||||
<div class="source-hint text-caption text-medium-emphasis mb-2">
|
||||
Источник — отправитель SMS и (опционально) ключевое слово в тексте
|
||||
</div>
|
||||
<v-combobox
|
||||
v-model="form.sms_senders"
|
||||
label="Отправители (до 11 символов каждый)"
|
||||
@@ -235,6 +244,10 @@ function close() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.source-hint {
|
||||
line-height: 1.4;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.ld-input-quiet :deep(.v-field) {
|
||||
border-radius: var(--radius-8);
|
||||
}
|
||||
|
||||
@@ -42,17 +42,21 @@ Schedule::command('partitions:create-months')
|
||||
// — Cache::lock guard внутри handle, RetryFailedSupplierJobs — WHERE retried_at
|
||||
// фильтр. На multi-server prod может потребовать cache_locks таблицу.
|
||||
Schedule::job(new RefreshSupplierSessionJob)->hourly();
|
||||
// Spec docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.7:
|
||||
// крон переехал с 20:30 на 18:00 МСК — даёт ~3 часа окно восстановления
|
||||
// (эскалация на медленный ярус 2 / ручной ярус 3) в рабочее время до
|
||||
// портального дедлайна 21:00. Session refresh — на 15 мин раньше sync (17:45).
|
||||
Schedule::job(new RefreshSupplierSessionJob)
|
||||
->dailyAt('20:15')
|
||||
->dailyAt('17:45')
|
||||
->timezone('Europe/Moscow');
|
||||
Schedule::job(new SyncSupplierProjectsJob)
|
||||
->dailyAt('20:30')
|
||||
->dailyAt('18:00')
|
||||
->timezone('Europe/Moscow');
|
||||
Schedule::job(new CleanupInactiveSupplierProjectsJob)
|
||||
->dailyAt('02:00')
|
||||
->timezone('Europe/Moscow');
|
||||
Schedule::command('supplier:retry-failed')->hourly();
|
||||
|
||||
// Plan 4 Task 8: hourly CSV reconciliation (резерв-канал приёма лидов).
|
||||
// Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3
|
||||
Schedule::job(new CsvReconcileJob)->hourly();
|
||||
// Резервный CSV-канал (Путь 2): сверка каждые 30 минут.
|
||||
// Spec: docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md §4.5
|
||||
Schedule::job(new CsvReconcileJob)->everyThirtyMinutes();
|
||||
|
||||
@@ -145,6 +145,15 @@ Route::middleware('saas-admin')->group(function () {
|
||||
Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index');
|
||||
Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update')
|
||||
->where('id', '[0-9]+');
|
||||
|
||||
// Резервный CSV-канал (Путь 2): здоровье канала + ручной запуск сверки.
|
||||
Route::get('/api/admin/supplier-integration', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@index');
|
||||
Route::post('/api/admin/supplier-integration/reconcile', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@reconcile');
|
||||
|
||||
// Резерв канала миграции проектов (ярус 3): ручная очередь оператора.
|
||||
Route::get('/api/admin/supplier-integration/manual-queue', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueIndex');
|
||||
Route::post('/api/admin/supplier-integration/manual-queue/{id}/resolve', 'App\Http\Controllers\Api\AdminSupplierIntegrationController@manualQueueResolve')
|
||||
->where('id', '[0-9]+');
|
||||
});
|
||||
|
||||
// Plan 4 Task 11: tenant charges ledger (read-only + CSV export).
|
||||
@@ -283,6 +292,7 @@ Route::view('/admin/incidents', 'welcome');
|
||||
Route::view('/admin/system', 'welcome');
|
||||
Route::view('/admin/pricing-tiers', 'welcome');
|
||||
Route::view('/admin/supplier-prices', 'welcome');
|
||||
Route::view('/admin/supplier-integration', 'welcome');
|
||||
Route::view('/admin/impersonation', 'welcome');
|
||||
Route::view('/403', 'welcome');
|
||||
Route::view('/500', 'welcome');
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\CsvReconcileJob;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('GET /api/admin/supplier-integration returns channel health + history', function (): void {
|
||||
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->insert([
|
||||
'started_at' => now()->subMinutes(10),
|
||||
'finished_at' => now()->subMinutes(9),
|
||||
'window_start' => now()->subDay(),
|
||||
'window_end' => now(),
|
||||
'total_csv_rows' => 100,
|
||||
'matched_count' => 98,
|
||||
'recovered_count' => 2,
|
||||
'drift_ratio' => 0.02,
|
||||
'status' => 'ok',
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/admin/supplier-integration');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonStructure([
|
||||
'health' => ['last_run_at', 'last_status', 'drift_ratio', 'webhook_state'],
|
||||
'history' => [['started_at', 'status', 'total_csv_rows', 'matched_count', 'recovered_count', 'drift_ratio']],
|
||||
]);
|
||||
expect($response->json('health.last_status'))->toBe('ok');
|
||||
expect($response->json('health.webhook_state'))->toBe('live');
|
||||
});
|
||||
|
||||
it('webhook_state is "down" when last run had drift_alert', function (): void {
|
||||
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->insert([
|
||||
'started_at' => now()->subMinutes(5),
|
||||
'finished_at' => now()->subMinutes(4),
|
||||
'window_start' => now()->subDay(),
|
||||
'window_end' => now(),
|
||||
'total_csv_rows' => 100,
|
||||
'matched_count' => 80,
|
||||
'recovered_count' => 20,
|
||||
'drift_ratio' => 0.20,
|
||||
'status' => 'drift_alert',
|
||||
'created_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/admin/supplier-integration');
|
||||
|
||||
expect($response->json('health.webhook_state'))->toBe('down');
|
||||
});
|
||||
|
||||
it('POST /api/admin/supplier-integration/reconcile dispatches CsvReconcileJob', function (): void {
|
||||
Bus::fake([CsvReconcileJob::class]);
|
||||
|
||||
$response = $this->postJson('/api/admin/supplier-integration/reconcile');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJson(['dispatched' => true]);
|
||||
Bus::assertDispatched(CsvReconcileJob::class, 1);
|
||||
});
|
||||
|
||||
it('returns nulls in health when reconcile log is empty (no run yet)', function (): void {
|
||||
// Пустой supplier_csv_reconcile_log — до первой сверки. Контроллер не должен
|
||||
// падать на $last === null (property access на null).
|
||||
DB::connection('pgsql_supplier')->table('supplier_csv_reconcile_log')->truncate();
|
||||
|
||||
$response = $this->getJson('/api/admin/supplier-integration');
|
||||
|
||||
$response->assertOk();
|
||||
expect($response->json('health.last_run_at'))->toBeNull();
|
||||
expect($response->json('health.last_status'))->toBeNull();
|
||||
expect($response->json('health.drift_ratio'))->toBeNull();
|
||||
expect($response->json('health.webhook_state'))->toBe('live');
|
||||
expect($response->json('history'))->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierManualSyncQueue;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
// EnsureSaasAdmin — стаб (Sprint 3F): в testing пропускает всех без проверки
|
||||
// роли. actingAs нужен только чтобы $request->user() в manualQueueResolve дал
|
||||
// id для resolved_by_user_id.
|
||||
|
||||
it('GET /api/admin/supplier-integration/manual-queue returns pending rows', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
SupplierManualSyncQueue::create([
|
||||
'project_id' => $project->id,
|
||||
'platform' => 'B1',
|
||||
'operation' => 'create',
|
||||
'payload_snapshot' => ['limit' => 10],
|
||||
'failure_reason' => 'contract_break',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$r = $this->getJson('/api/admin/supplier-integration/manual-queue');
|
||||
|
||||
$r->assertOk()
|
||||
->assertJsonStructure(['queue' => [['id', 'project_id', 'platform', 'operation', 'payload_snapshot', 'failure_reason', 'created_at']]])
|
||||
->assertJsonCount(1, 'queue');
|
||||
});
|
||||
|
||||
it('GET excludes resolved rows', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
SupplierManualSyncQueue::create([
|
||||
'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
|
||||
'payload_snapshot' => [], 'failure_reason' => 'contract_break',
|
||||
'status' => 'resolved', 'resolved_at' => now(),
|
||||
]);
|
||||
|
||||
$this->getJson('/api/admin/supplier-integration/manual-queue')
|
||||
->assertOk()->assertJsonCount(0, 'queue');
|
||||
});
|
||||
|
||||
it('POST /resolve marks row resolved when listProjects matches', function (): void {
|
||||
$admin = User::factory()->create();
|
||||
$this->actingAs($admin);
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
$row = SupplierManualSyncQueue::create([
|
||||
'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
|
||||
'payload_snapshot' => ['signal_type' => 'site', 'unique_key' => 'foo.com'],
|
||||
'failure_reason' => 'contract_break', 'status' => 'pending',
|
||||
]);
|
||||
|
||||
$channelMock = new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [['id' => 99999, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'foo.com']];
|
||||
}
|
||||
};
|
||||
app()->instance(SupplierProjectChannel::class, $channelMock);
|
||||
|
||||
$this->postJson("/api/admin/supplier-integration/manual-queue/{$row->id}/resolve")
|
||||
->assertOk();
|
||||
|
||||
expect($row->fresh()->status)->toBe('resolved');
|
||||
expect($row->fresh()->resolved_by_user_id)->toBe($admin->id);
|
||||
// FK ведёт на local supplier_projects.id; portal external_id (99999) хранится
|
||||
// в supplier_external_id созданной строки + в queue-row.external_id.
|
||||
expect($project->fresh()->supplier_b1_project_id)->not->toBeNull();
|
||||
expect(SupplierProject::find($project->fresh()->supplier_b1_project_id)->supplier_external_id)->toBe('99999');
|
||||
expect($row->fresh()->external_id)->toBe('99999');
|
||||
});
|
||||
|
||||
it('POST /resolve returns 409 when listProjects does not match', function (): void {
|
||||
$this->actingAs(User::factory()->create());
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
$row = SupplierManualSyncQueue::create([
|
||||
'project_id' => $project->id, 'platform' => 'B1', 'operation' => 'create',
|
||||
'payload_snapshot' => ['signal_type' => 'site', 'unique_key' => 'foo.com'],
|
||||
'failure_reason' => 'contract_break', 'status' => 'pending',
|
||||
]);
|
||||
|
||||
$channelMock = new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
app()->instance(SupplierProjectChannel::class, $channelMock);
|
||||
|
||||
$this->postJson("/api/admin/supplier-integration/manual-queue/{$row->id}/resolve")
|
||||
->assertStatus(409);
|
||||
|
||||
expect($row->fresh()->status)->toBe('pending');
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierManualSyncQueue;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
it('table supplier_manual_sync_queue exists with required columns', function (): void {
|
||||
$cols = collect(DB::select(
|
||||
"SELECT column_name FROM information_schema.columns WHERE table_name = 'supplier_manual_sync_queue'"
|
||||
))->pluck('column_name')->all();
|
||||
|
||||
expect($cols)->toContain(
|
||||
'id', 'project_id', 'platform', 'operation', 'external_id',
|
||||
'payload_snapshot', 'failure_reason', 'status',
|
||||
'resolved_by_user_id', 'created_at', 'resolved_at',
|
||||
);
|
||||
});
|
||||
|
||||
it('platform CHECK constraint rejects non-B1/B2/B3', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
|
||||
'project_id' => $project->id,
|
||||
'platform' => 'B9',
|
||||
'operation' => 'create',
|
||||
'payload_snapshot' => json_encode([]),
|
||||
'failure_reason' => 'portal_unreachable',
|
||||
'status' => 'pending',
|
||||
'created_at' => now(),
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('operation CHECK constraint rejects non-create/update', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
|
||||
'project_id' => $project->id,
|
||||
'platform' => 'B1',
|
||||
'operation' => 'delete',
|
||||
'payload_snapshot' => json_encode([]),
|
||||
'failure_reason' => 'portal_unreachable',
|
||||
'status' => 'pending',
|
||||
'created_at' => now(),
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('status CHECK constraint rejects non-pending/resolved/cancelled', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
|
||||
'project_id' => $project->id,
|
||||
'platform' => 'B1',
|
||||
'operation' => 'create',
|
||||
'payload_snapshot' => json_encode([]),
|
||||
'failure_reason' => 'portal_unreachable',
|
||||
'status' => 'archived',
|
||||
'created_at' => now(),
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('FK on project_id enforces referential integrity', function (): void {
|
||||
expect(fn () => DB::table('supplier_manual_sync_queue')->insert([
|
||||
'project_id' => 999_999_999,
|
||||
'platform' => 'B1',
|
||||
'operation' => 'create',
|
||||
'payload_snapshot' => json_encode([]),
|
||||
'failure_reason' => 'portal_unreachable',
|
||||
'status' => 'pending',
|
||||
'created_at' => now(),
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('Eloquent model SupplierManualSyncQueue creates row and casts payload_snapshot to array', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$row = SupplierManualSyncQueue::create([
|
||||
'project_id' => $project->id,
|
||||
'platform' => 'B1',
|
||||
'operation' => 'create',
|
||||
'payload_snapshot' => ['limit' => 10, 'workdays' => [1, 2, 3]],
|
||||
'failure_reason' => 'contract_break',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
expect($row->fresh()->payload_snapshot)->toBe(['limit' => 10, 'workdays' => [1, 2, 3]]);
|
||||
});
|
||||
@@ -162,3 +162,59 @@ test('GET /api/deals/{id} лимит 50 событий', function () {
|
||||
|
||||
expect($r->json('events'))->toHaveCount(50);
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
* 18.05.2026 UX-request: drawer сделки показывает «Тип» + «Источник»
|
||||
* проекта. Backend отдаёт project_signal_type/identifier/sms_*.
|
||||
* --------------------------------------------------------------------- */
|
||||
|
||||
test('GET /api/deals/{id} отдаёт project_signal_identifier/sms_keyword/sms_senders для site-проекта', function () {
|
||||
$siteProject = Project::factory()->for($this->tenant)->create([
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'krk-finance.ru',
|
||||
]);
|
||||
$deal = Deal::factory()->for($this->tenant)->for($siteProject)->create();
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$deal->id);
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('deal.project_signal_type'))->toBe('site');
|
||||
expect($r->json('deal.project_signal_identifier'))->toBe('krk-finance.ru');
|
||||
expect($r->json('deal.project_sms_keyword'))->toBeNull();
|
||||
expect($r->json('deal.project_sms_senders'))->toBeNull();
|
||||
});
|
||||
|
||||
test('GET /api/deals/{id} отдаёт sms_senders/sms_keyword для sms-проекта', function () {
|
||||
$smsProject = Project::factory()->for($this->tenant)->create([
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => 'MTS',
|
||||
'sms_senders' => ['MTS', 'BEELINE'],
|
||||
'sms_keyword' => 'КРЕДИТ',
|
||||
]);
|
||||
$deal = Deal::factory()->for($this->tenant)->for($smsProject)->create();
|
||||
|
||||
$r = $this->getJson('/api/deals/'.$deal->id);
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('deal.project_signal_type'))->toBe('sms');
|
||||
expect($r->json('deal.project_sms_senders'))->toBe(['MTS', 'BEELINE']);
|
||||
expect($r->json('deal.project_sms_keyword'))->toBe('КРЕДИТ');
|
||||
});
|
||||
|
||||
test('GET /api/deals отдаёт те же поля в index payload', function () {
|
||||
$smsProject = Project::factory()->for($this->tenant)->create([
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => 'MTS',
|
||||
'sms_senders' => ['MTS'],
|
||||
'sms_keyword' => 'КРЕДИТ',
|
||||
]);
|
||||
Deal::factory()->for($this->tenant)->for($smsProject)->create();
|
||||
|
||||
$r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id);
|
||||
|
||||
$r->assertStatus(200);
|
||||
expect($r->json('deals.0.project_signal_type'))->toBe('sms');
|
||||
expect($r->json('deals.0.project_signal_identifier'))->toBe('MTS');
|
||||
expect($r->json('deals.0.project_sms_senders'))->toBe(['MTS']);
|
||||
expect($r->json('deals.0.project_sms_keyword'))->toBe('КРЕДИТ');
|
||||
});
|
||||
|
||||
@@ -392,6 +392,51 @@ it('handles partial failure: one project throws, others continue routing', funct
|
||||
expect($tenants[2]->fresh()->balance_leads)->toBe(99);
|
||||
});
|
||||
|
||||
it('routes B1 lead whose project name embeds a domain in free text (carmoney/caranga/krk)', function (string $projectField, string $domain): void {
|
||||
// Регрессия 18.05.2026: поставщик crm.bp-gr.ru шлёт B1-проекты, чьё имя — свободный
|
||||
// текст со встроенным URL/доменом ('B1_заявка carmoney.ru/'). Старый parseProjectField
|
||||
// c anchored-regex '^[a-z0-9-]+(\.[a-z0-9-]+)+$' такой rest не матчил → классифицировал
|
||||
// как 'sms' → B1+sms → DomainException → 21 реальный лид застрял с error, 0 сделок.
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => $domain,
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => $domain,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$vid = random_int(100000, 999999);
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => [
|
||||
'vid' => $vid,
|
||||
'project' => $projectField,
|
||||
'phone' => '79991234567',
|
||||
'time' => now()->getTimestamp(),
|
||||
],
|
||||
]);
|
||||
|
||||
runRouteJob($lead->id);
|
||||
|
||||
$lead->refresh();
|
||||
expect($lead->processed_at)->not->toBeNull();
|
||||
expect($lead->supplier_project_id)->toBe($supplier->id);
|
||||
expect($lead->deals_created_count)->toBe(1);
|
||||
})->with([
|
||||
'carmoney embedded in free text' => ['B1_заявка carmoney.ru/', 'carmoney.ru'],
|
||||
'caranga subdomain with path' => ['B1_Платежи cabinet.caranga.ru/login', 'cabinet.caranga.ru'],
|
||||
'krk-finance with auth path' => ['B1_krk-finance.ru/cabinet/auth', 'krk-finance.ru'],
|
||||
]);
|
||||
|
||||
it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2 race recheck)', function (): void {
|
||||
// BLOCKER #2 (CV.11 audit): matchEligibleProjects делает SELECT delivered_today < limit
|
||||
// БЕЗ lockForUpdate. Между snapshot SELECT и createDealCopyForProject (которое
|
||||
|
||||
@@ -59,25 +59,26 @@ it('supplier_csv_reconcile_log table exists with required columns and status CHE
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('schema.sql v8.22 has correct metrics — 63 base tables, 119 indexes, 40 RLS policies', function () {
|
||||
it('schema.sql v8.25 has correct metrics — 64 base tables, 121 indexes, 40 RLS policies', function () {
|
||||
// Замена destructive `migrate:fresh` (cross-test coupling: после DROP CASCADE остальные
|
||||
// Feature-тесты в той же сессии видели пустую БД). Static parse `db/schema.sql` —
|
||||
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.22.
|
||||
// источник истины метрик из spec §2.4 / db/CHANGELOG_schema.md v8.25.
|
||||
// v8.21 (Sprint 4): +1 таблица import_unknown_statuses, +1 индекс, +1 RLS-политика.
|
||||
// v8.22 (Plan 6/C9): +1 GIN-индекс idx_projects_regions.
|
||||
// v8.25 (supplier-failover): +1 таблица supplier_manual_sync_queue, +2 индекса.
|
||||
$schemaPath = dirname(base_path()).DIRECTORY_SEPARATOR.'db'.DIRECTORY_SEPARATOR.'schema.sql';
|
||||
expect(is_file($schemaPath) && is_readable($schemaPath))->toBeTrue();
|
||||
$schema = file_get_contents($schemaPath);
|
||||
expect($schema)->not->toBeFalse();
|
||||
|
||||
// 63 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
|
||||
// 64 base tables = все CREATE TABLE минус 12 партиций (PARTITION OF).
|
||||
$createTables = preg_match_all('/^CREATE TABLE\b/m', $schema);
|
||||
$partitionOf = preg_match_all('/CREATE TABLE\s+\w+\s+PARTITION OF\b/m', $schema);
|
||||
$baseTables = $createTables - $partitionOf;
|
||||
expect($baseTables)->toBe(63);
|
||||
expect($baseTables)->toBe(64);
|
||||
|
||||
$createIndexes = preg_match_all('/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/m', $schema);
|
||||
expect($createIndexes)->toBe(119); // v8.22 (Plan 6/C9): +1 GIN idx_projects_regions
|
||||
expect($createIndexes)->toBe(121); // v8.25: +2 idx_smsq_status_created, idx_smsq_project
|
||||
|
||||
$createPolicies = preg_match_all('/^CREATE\s+POLICY\b/m', $schema);
|
||||
expect($createPolicies)->toBe(40);
|
||||
|
||||
@@ -6,7 +6,7 @@ use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
|
||||
// TestCase auto-bound via tests/Pest.php (->in('Feature')).
|
||||
@@ -14,17 +14,17 @@ use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
/**
|
||||
* Хелпер: разрешает мок SupplierPortalClient из контейнера и вызывает Job.handle().
|
||||
* Нельзя использовать (new Job)->handle() без аргументов — handle() требует DI-инъекцию
|
||||
* SupplierPortalClient; прямой вызов без аргументов обходит контейнер и мок не применяется.
|
||||
* Хелпер: разрешает SupplierProjectChannel из контейнера и вызывает Job.handle().
|
||||
* Mock SupplierProjectChannel НЕ instanceof FailoverProjectChannel → job идёт
|
||||
* по ветке createProject() (без эскалации) — это и тестируем здесь.
|
||||
* Failover-эскалация покрыта FailoverProjectChannelTest.
|
||||
*/
|
||||
function dispatchJobSync(SyncSupplierProjectJob $job): void
|
||||
{
|
||||
$client = app(SupplierPortalClient::class);
|
||||
$job->handle($client);
|
||||
$job->handle(app(SupplierProjectChannel::class));
|
||||
}
|
||||
|
||||
it('site project: links B1+B2+B3 supplier_projects and sets all three IDs', function () {
|
||||
it('site project: creates B1+B2+B3 supplier_projects and sets all three IDs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
@@ -32,15 +32,9 @@ it('site project: links B1+B2+B3 supplier_projects and sets all three IDs', func
|
||||
'signal_identifier' => 'okna.ru',
|
||||
]);
|
||||
|
||||
$this->mock(SupplierPortalClient::class, function ($mock) {
|
||||
$mock->shouldReceive('ensureSupplierProject')->times(3)
|
||||
->andReturnUsing(fn (string $platform, string $signalType, string $key) => SupplierProject::factory()->create([
|
||||
'platform' => $platform, // uppercase: B1, B2, B3
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $key,
|
||||
'sync_status' => 'ok',
|
||||
])->id
|
||||
);
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock) {
|
||||
$mock->shouldReceive('createProject')->times(3)
|
||||
->andReturn(700001, 700002, 700003);
|
||||
});
|
||||
|
||||
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
||||
@@ -49,21 +43,19 @@ it('site project: links B1+B2+B3 supplier_projects and sets all three IDs', func
|
||||
expect($project->supplier_b1_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b2_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b3_project_id)->not->toBeNull();
|
||||
// FK ведёт на local supplier_projects.id, не на portal external_id.
|
||||
expect(SupplierProject::find($project->supplier_b1_project_id)->supplier_external_id)->toBe('700001');
|
||||
});
|
||||
|
||||
it('call project: links B1+B2+B3 with phone signal_identifier', function () {
|
||||
it('call project: creates B1+B2+B3 with phone signal_identifier', function () {
|
||||
$project = Project::factory()->create([
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79161234567',
|
||||
]);
|
||||
|
||||
$this->mock(SupplierPortalClient::class, function ($mock) {
|
||||
$mock->shouldReceive('ensureSupplierProject')->times(3)
|
||||
->andReturn(SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'call',
|
||||
'sync_status' => 'ok',
|
||||
])->id);
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock) {
|
||||
$mock->shouldReceive('createProject')->times(3)
|
||||
->andReturn(800001, 800002, 800003);
|
||||
});
|
||||
|
||||
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
||||
@@ -73,21 +65,16 @@ it('call project: links B1+B2+B3 with phone signal_identifier', function () {
|
||||
expect($project->fresh()->supplier_b3_project_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('sms project with keyword: links B2+B3 only (no B1)', function () {
|
||||
it('sms project with keyword: creates B2+B3 only (no B1)', function () {
|
||||
$project = Project::factory()->create([
|
||||
'signal_type' => 'sms',
|
||||
'sms_senders' => ['TINKOFF'],
|
||||
'sms_keyword' => 'ипотека',
|
||||
]);
|
||||
|
||||
$this->mock(SupplierPortalClient::class, function ($mock) {
|
||||
$mock->shouldReceive('ensureSupplierProject')->times(2)
|
||||
->andReturnUsing(fn (string $platform) => SupplierProject::factory()->create([
|
||||
'platform' => $platform, // B2 or B3 — both pass CHECK constraint
|
||||
'signal_type' => 'sms',
|
||||
'sync_status' => 'ok',
|
||||
])->id
|
||||
);
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock) {
|
||||
$mock->shouldReceive('createProject')->times(2)
|
||||
->andReturn(900001, 900002);
|
||||
});
|
||||
|
||||
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
||||
@@ -98,20 +85,16 @@ it('sms project with keyword: links B2+B3 only (no B1)', function () {
|
||||
expect($project->supplier_b3_project_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('sms project without keyword: links B3 only', function () {
|
||||
it('sms project without keyword: creates B3 only', function () {
|
||||
$project = Project::factory()->create([
|
||||
'signal_type' => 'sms',
|
||||
'sms_senders' => ['TINKOFF'],
|
||||
'sms_keyword' => null,
|
||||
]);
|
||||
|
||||
$this->mock(SupplierPortalClient::class, function ($mock) {
|
||||
$mock->shouldReceive('ensureSupplierProject')->once()
|
||||
->andReturn(SupplierProject::factory()->create([
|
||||
'platform' => 'B3',
|
||||
'signal_type' => 'sms',
|
||||
'sync_status' => 'ok',
|
||||
])->id);
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock) {
|
||||
$mock->shouldReceive('createProject')->once()
|
||||
->andReturn(910001);
|
||||
});
|
||||
|
||||
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
||||
@@ -122,14 +105,14 @@ it('sms project without keyword: links B3 only', function () {
|
||||
expect($project->supplier_b3_project_id)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('portal exception: re-throws for queue retry', function () {
|
||||
it('channel exception: re-throws for queue retry', function () {
|
||||
$project = Project::factory()->create([
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'x.ru',
|
||||
]);
|
||||
|
||||
$this->mock(SupplierPortalClient::class, function ($mock) {
|
||||
$mock->shouldReceive('ensureSupplierProject')
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock) {
|
||||
$mock->shouldReceive('createProject')
|
||||
->andThrow(new RuntimeException('timeout'));
|
||||
});
|
||||
|
||||
@@ -137,16 +120,13 @@ it('portal exception: re-throws for queue retry', function () {
|
||||
->toThrow(RuntimeException::class);
|
||||
});
|
||||
|
||||
it('partial success: B1=ok, B2=failed (pre-created row), B3=ok — all three IDs written', function () {
|
||||
it('idempotency: pre-existing supplier_project row is reused, channel not called for it', function () {
|
||||
$project = Project::factory()->create([
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'x.ru',
|
||||
]);
|
||||
|
||||
// Pre-create a supplier_project row for B2 with sync_status='failed' —
|
||||
// the mock returns its ID to simulate a failed B2 sync.
|
||||
// NOTE: supplier_projects has NO last_error column (schema v8.19);
|
||||
// "failed" status alone is the observable signal.
|
||||
// B2 уже существует локально (например, от прошлого частичного запуска).
|
||||
$spB2 = SupplierProject::factory()->create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
@@ -154,16 +134,16 @@ it('partial success: B1=ok, B2=failed (pre-created row), B3=ok — all three IDs
|
||||
'sync_status' => 'failed',
|
||||
]);
|
||||
|
||||
$this->mock(SupplierPortalClient::class, function ($mock) use ($spB2) {
|
||||
$spB1 = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'sync_status' => 'ok'])->id;
|
||||
$spB3 = SupplierProject::factory()->create(['platform' => 'B3', 'signal_type' => 'site', 'sync_status' => 'ok'])->id;
|
||||
$mock->shouldReceive('ensureSupplierProject')->andReturn($spB1, $spB2->id, $spB3);
|
||||
// Channel дёргается только для B1 и B3 — B2 берётся из существующей строки.
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock) {
|
||||
$mock->shouldReceive('createProject')->times(2)
|
||||
->andReturn(700001, 700003);
|
||||
});
|
||||
|
||||
dispatchJobSync(new SyncSupplierProjectJob($project->id));
|
||||
|
||||
$project->refresh();
|
||||
expect($project->supplier_b2_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b2_project_id)->toBe($spB2->id);
|
||||
expect(SupplierProject::find($project->supplier_b2_project_id)->sync_status)->toBe('failed');
|
||||
expect($project->supplier_b1_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b3_project_id)->not->toBeNull();
|
||||
|
||||
@@ -125,3 +125,61 @@ it('preserves regions when PATCH omits the field (sometimes rule)', function ()
|
||||
$response->assertStatus(200);
|
||||
expect($project->fresh()->regions)->toBe([82, 83]);
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
* 18.05.2026 UX-request (Task 5 плана): редактирование источника
|
||||
* (signal_identifier для site/call) — Sync поставщику обязателен.
|
||||
* --------------------------------------------------------------------- */
|
||||
|
||||
it('updates signal_identifier for site project + triggers resync', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'old.ru',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'signal_identifier' => 'new-source.ru',
|
||||
])->assertOk();
|
||||
|
||||
expect($project->fresh()->signal_identifier)->toBe('new-source.ru');
|
||||
Queue::assertPushed(SyncSupplierProjectJob::class);
|
||||
});
|
||||
|
||||
it('updates signal_identifier for call project (11-digit phone)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'signal_type' => 'call', 'signal_identifier' => '79991111111',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'signal_identifier' => '79992222222',
|
||||
])->assertOk();
|
||||
|
||||
expect($project->fresh()->signal_identifier)->toBe('79992222222');
|
||||
});
|
||||
|
||||
it('rejects invalid signal_identifier for site (not a domain)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'ok.ru',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'signal_identifier' => 'not-a-domain',
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['signal_identifier']);
|
||||
});
|
||||
|
||||
it('rejects invalid signal_identifier for call (not 7\d{10})', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'signal_type' => 'call', 'signal_identifier' => '79991111111',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->patchJson("/api/projects/{$project->id}", [
|
||||
'signal_identifier' => '12345',
|
||||
])->assertStatus(422)->assertJsonValidationErrors(['signal_identifier']);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
/*
|
||||
* Крон supplier-sync переехал 20:30 → 18:00 МСК (Task 9, spec §4.7) —
|
||||
* запас ~3 часа до портального дедлайна 21:00 на эскалацию ярус 2/3.
|
||||
* Session refresh — на 15 мин раньше sync (17:45).
|
||||
*/
|
||||
|
||||
it('SyncSupplierProjectsJob is scheduled at 18:00 MSK', function (): void {
|
||||
$schedule = app(Schedule::class);
|
||||
$events = collect($schedule->events());
|
||||
|
||||
$sync = $events->first(fn ($e) => str_contains((string) $e->description, SyncSupplierProjectsJob::class)
|
||||
|| str_contains((string) $e->command, 'SyncSupplierProjectsJob'));
|
||||
|
||||
expect($sync)->not->toBeNull();
|
||||
expect($sync->expression)->toBe('0 18 * * *');
|
||||
expect($sync->timezone)->toBe('Europe/Moscow');
|
||||
});
|
||||
|
||||
it('Daily RefreshSupplierSessionJob is scheduled at 17:45 MSK', function (): void {
|
||||
$schedule = app(Schedule::class);
|
||||
$events = collect($schedule->events());
|
||||
|
||||
$daily = $events->first(fn ($e) => (str_contains((string) $e->description, RefreshSupplierSessionJob::class)
|
||||
|| str_contains((string) $e->command, 'RefreshSupplierSessionJob'))
|
||||
&& $e->expression === '45 17 * * *');
|
||||
|
||||
expect($daily)->not->toBeNull();
|
||||
expect($daily->timezone)->toBe('Europe/Moscow');
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\Channel\AjaxProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/*
|
||||
* AjaxProjectChannel (Tier 1) — тонкий адаптер над SupplierPortalClient.
|
||||
*
|
||||
* Контракт rt-project-* верифицирован Task 1 (см. SupplierPortalClientRtProjectTest);
|
||||
* здесь проверяем только что адаптер прозрачно делегирует на правильный endpoint.
|
||||
*/
|
||||
|
||||
beforeEach(function (): void {
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'test', 'csrf' => 'test',
|
||||
], now()->addHour());
|
||||
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
|
||||
});
|
||||
|
||||
it('AjaxProjectChannel implements SupplierProjectChannel', function (): void {
|
||||
expect(app(AjaxProjectChannel::class))->toBeInstanceOf(SupplierProjectChannel::class);
|
||||
});
|
||||
|
||||
it('createProject delegates to SupplierPortalClient::saveProject and returns external id', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700777'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1',
|
||||
signalType: 'site',
|
||||
uniqueKey: 'foo.com',
|
||||
limit: 5,
|
||||
workdays: [1, 2, 3],
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
$id = app(AjaxProjectChannel::class)->createProject($dto);
|
||||
|
||||
expect($id)->toBe(700777);
|
||||
});
|
||||
|
||||
it('updateProject delegates to SupplierPortalClient::updateProject with id:N', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700777'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1',
|
||||
signalType: 'site',
|
||||
uniqueKey: 'foo.com',
|
||||
limit: 10,
|
||||
workdays: [1],
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
app(AjaxProjectChannel::class)->updateProject(700777, $dto);
|
||||
|
||||
Http::assertSent(fn ($r) => $r['id'] === 700777);
|
||||
});
|
||||
|
||||
it('listProjects normalizes raw rt-rows to channel contract (platform/signal_type/unique_key)', function (): void {
|
||||
// Сырая форма портала (verified 2026-05-19): конверт {projects:[...]},
|
||||
// строка {id, name:"B<n>_<key>", type, content}. Адаптер маппит в контракт.
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response([
|
||||
'projects' => [
|
||||
['id' => '700001', 'name' => 'B1_okna.ru', 'type' => 'hosts', 'content' => 'okna.ru'],
|
||||
['id' => '700002', 'name' => 'B3_79991112233', 'type' => 'calls', 'content' => '79991112233'],
|
||||
['id' => '700003', 'name' => 'noPrefix', 'type' => 'sms', 'content' => 'KEYWORD'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$list = app(AjaxProjectChannel::class)->listProjects();
|
||||
|
||||
expect($list)->toHaveCount(3);
|
||||
|
||||
expect($list[0]['platform'])->toBe('B1');
|
||||
expect($list[0]['signal_type'])->toBe('site');
|
||||
expect($list[0]['unique_key'])->toBe('okna.ru');
|
||||
expect($list[0]['id'])->toBe('700001'); // сырое поле сохранено
|
||||
|
||||
expect($list[1]['platform'])->toBe('B3');
|
||||
expect($list[1]['signal_type'])->toBe('call');
|
||||
expect($list[1]['unique_key'])->toBe('79991112233');
|
||||
|
||||
// name без B<n>_ префикса → platform null (контракт не ломается)
|
||||
expect($list[2]['platform'])->toBeNull();
|
||||
expect($list[2]['signal_type'])->toBe('sms');
|
||||
expect($list[2]['unique_key'])->toBe('KEYWORD');
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Supplier\SupplierAuthException;
|
||||
use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Mail\SupplierCriticalAlertMail;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierManualSyncQueue;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
|
||||
use App\Services\Supplier\Channel\Exceptions\WindowDeferredException;
|
||||
use App\Services\Supplier\Channel\FailoverProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
|
||||
function makeDto(): SupplierProjectDto
|
||||
{
|
||||
return new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
|
||||
limit: 10, workdays: [1, 2], regions: [], regionsReverse: false, status: 'active',
|
||||
);
|
||||
}
|
||||
|
||||
function makeFailover(SupplierProjectChannel $tier1, ?SupplierProjectChannel $tier2 = null): FailoverProjectChannel
|
||||
{
|
||||
return new FailoverProjectChannel(
|
||||
$tier1,
|
||||
$tier2 ?? new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
throw new RuntimeException('tier2 not configured');
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
},
|
||||
app(Mailer::class),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
it('createProject — Tier 1 success: returns id, no queue, no alert', function (): void {
|
||||
$tier1 = new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
return 700123;
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
$id = makeFailover($tier1)->createProject(makeDto());
|
||||
|
||||
expect($id)->toBe(700123);
|
||||
expect(SupplierManualSyncQueue::count())->toBe(0);
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
it('createProject — Tier 1 transient-exhausted: skips Tier 2, jumps to Tier 3 with portal_unreachable', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create(['signal_type' => 'site', 'signal_identifier' => 'foo.com']);
|
||||
|
||||
$tier1 = new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
throw new SupplierTransientException('5xx exhausted', httpStatus: 503);
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
$tier2Called = false;
|
||||
$tier2 = new class($tier2Called) implements SupplierProjectChannel
|
||||
{
|
||||
public function __construct(public bool &$called) {}
|
||||
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
$this->called = true;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void
|
||||
{
|
||||
$this->called = true;
|
||||
}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
$this->called = true;
|
||||
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
expect(fn () => makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto()))
|
||||
->toThrow(TierEscalatedException::class);
|
||||
|
||||
expect($tier2Called)->toBeFalse();
|
||||
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('failure_reason', 'portal_unreachable')->count())->toBe(1);
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class);
|
||||
});
|
||||
|
||||
it('createProject — Tier 1 client-exc → Tier 2 success: no queue', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$tier1 = new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
throw new SupplierClientException('4xx contract break', httpStatus: 400);
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
$tier2 = new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
return 800001;
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
$id = makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto());
|
||||
|
||||
expect($id)->toBe(800001);
|
||||
expect(SupplierManualSyncQueue::count())->toBe(0);
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class); // failover_to_form alert
|
||||
});
|
||||
|
||||
it('createProject — Tier 1 client-exc + Tier 2 fail: Tier 3 queue, manual_required alert', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$tier1 = new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
throw new SupplierClientException('4xx', httpStatus: 400);
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
$tier2 = new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
throw new RuntimeException('form_selector_break');
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
expect(fn () => makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto()))
|
||||
->toThrow(TierEscalatedException::class);
|
||||
|
||||
expect(SupplierManualSyncQueue::where('project_id', $project->id)->where('status', 'pending')->count())->toBe(1);
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class);
|
||||
});
|
||||
|
||||
it('createProject — Tier 1 auth-exc → Tier 2 success', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$tier1 = new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
throw new SupplierAuthException('sticky 401', httpStatus: 401);
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
$tier2 = new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
return 900042;
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
$id = makeFailover($tier1, $tier2)->createProjectForLiderra($project, makeDto());
|
||||
|
||||
expect($id)->toBe(900042);
|
||||
});
|
||||
|
||||
it('createProject — WindowDeferred: no queue, no escalation, op rescheduled (re-throws WindowDeferred)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$tier1 = new class implements SupplierProjectChannel
|
||||
{
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
throw new WindowDeferredException('portal returned 22:00-00:00 window-block');
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
expect(fn () => makeFailover($tier1)->createProjectForLiderra($project, makeDto()))
|
||||
->toThrow(WindowDeferredException::class);
|
||||
|
||||
expect(SupplierManualSyncQueue::count())->toBe(0);
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
it('createProject — portal already has project (listProjects match): adopts external_id, skips create', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$tier1CreateCalled = false;
|
||||
$tier1 = new class($tier1CreateCalled) implements SupplierProjectChannel
|
||||
{
|
||||
public function __construct(public bool &$createCalled) {}
|
||||
|
||||
public function createProject(SupplierProjectDto $dto): int
|
||||
{
|
||||
$this->createCalled = true;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public function updateProject(int $externalId, SupplierProjectDto $dto): void {}
|
||||
|
||||
public function listProjects(): array
|
||||
{
|
||||
return [
|
||||
['id' => 555555, 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'foo.com'],
|
||||
];
|
||||
}
|
||||
};
|
||||
|
||||
$id = makeFailover($tier1)->createProjectForLiderra($project, makeDto());
|
||||
|
||||
expect($id)->toBe(555555);
|
||||
expect($tier1CreateCalled)->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\Channel\FormProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\PlaywrightBridge;
|
||||
|
||||
/*
|
||||
* FormProjectChannel (Tier 2) — PHP wrapper над manage-project.js.
|
||||
*
|
||||
* PlaywrightBridge подменяется stub-ом (extends PlaywrightBridge с пустым
|
||||
* конструктором — реальный требует ProcessFactory; run() переопределён целиком,
|
||||
* processFactory не трогается).
|
||||
*/
|
||||
|
||||
function bridgeStub(callable $onRun): PlaywrightBridge
|
||||
{
|
||||
return new class($onRun) extends PlaywrightBridge
|
||||
{
|
||||
/** @var array<string, mixed>|null */
|
||||
public ?array $lastArgs = null;
|
||||
|
||||
/** @var callable */
|
||||
private $onRun;
|
||||
|
||||
public function __construct(callable $onRun)
|
||||
{
|
||||
$this->onRun = $onRun;
|
||||
}
|
||||
|
||||
public function run(array $args): array
|
||||
{
|
||||
$this->lastArgs = $args;
|
||||
|
||||
return ($this->onRun)($args);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
it('FormProjectChannel implements SupplierProjectChannel', function (): void {
|
||||
app()->instance(PlaywrightBridge::class, bridgeStub(fn () => []));
|
||||
|
||||
expect(app(FormProjectChannel::class))->toBeInstanceOf(SupplierProjectChannel::class);
|
||||
});
|
||||
|
||||
it('createProject calls PlaywrightBridge with operation=create and returns external_id', function (): void {
|
||||
$stub = bridgeStub(fn () => ['external_id' => '12345']);
|
||||
app()->instance(PlaywrightBridge::class, $stub);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
|
||||
limit: 10, workdays: [1, 2], regions: [], regionsReverse: false, status: 'active',
|
||||
);
|
||||
|
||||
$id = app(FormProjectChannel::class)->createProject($dto);
|
||||
|
||||
expect($id)->toBe(12345);
|
||||
expect($stub->lastArgs['operation'])->toBe('create');
|
||||
expect($stub->lastArgs['script'])->toBe('manage-project.js');
|
||||
expect($stub->lastArgs['dto']['name'])->toBe('foo.com');
|
||||
expect($stub->lastArgs['dto']['platforms'])->toBe(['B1']);
|
||||
});
|
||||
|
||||
it('updateProject calls bridge with operation=update and externalId', function (): void {
|
||||
$stub = bridgeStub(fn () => ['ok' => true]);
|
||||
app()->instance(PlaywrightBridge::class, $stub);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
|
||||
limit: 20, workdays: [1], regions: [], regionsReverse: false, status: 'active',
|
||||
);
|
||||
app(FormProjectChannel::class)->updateProject(700123, $dto);
|
||||
|
||||
expect($stub->lastArgs['operation'])->toBe('update');
|
||||
expect($stub->lastArgs['externalId'])->toBe(700123);
|
||||
});
|
||||
|
||||
it('listProjects calls bridge with operation=list and returns array', function (): void {
|
||||
$stub = bridgeStub(fn () => ['projects' => [['id' => 1, 'name' => 'A']]]);
|
||||
app()->instance(PlaywrightBridge::class, $stub);
|
||||
|
||||
$list = app(FormProjectChannel::class)->listProjects();
|
||||
|
||||
expect($list)->toBe([['id' => 1, 'name' => 'A']]);
|
||||
expect($stub->lastArgs['operation'])->toBe('list');
|
||||
});
|
||||
|
||||
it('createProject throws when bridge returns empty external_id', function (): void {
|
||||
app()->instance(PlaywrightBridge::class, bridgeStub(fn () => ['external_id' => '0']));
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'foo.com',
|
||||
limit: 10, workdays: [1], regions: [], regionsReverse: false, status: 'active',
|
||||
);
|
||||
|
||||
expect(fn () => app(FormProjectChannel::class)->createProject($dto))
|
||||
->toThrow(RuntimeException::class, 'empty external_id');
|
||||
});
|
||||
@@ -76,7 +76,10 @@ test('phase C deletes supplier_project after 180 days inactive and writes audit
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/rt-project-delete' => Http::response('', 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new CleanupInactiveSupplierProjectsJob)->handle();
|
||||
@@ -128,7 +131,7 @@ test('handles 404 from supplier as already-deleted: local delete + audit row wit
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/rt-project-delete' => Http::response('not found', 404),
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response('not found', 404),
|
||||
]);
|
||||
|
||||
(new CleanupInactiveSupplierProjectsJob)->handle();
|
||||
|
||||
@@ -8,12 +8,11 @@ use App\Jobs\Supplier\CsvReconcileJob;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Mail\CsvDriftAlertMail;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\SupplierCsvParser;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -23,20 +22,6 @@ use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
/**
|
||||
* Hard re-puts `supplier:session` immediately before the SUT reads it.
|
||||
*
|
||||
* Parallel-test race fix: other Supplier tests (Sync*, Cleanup*) call
|
||||
* `Cache::store('redis')->forget('supplier:session')` in their afterEach.
|
||||
* In `--parallel` mode all workers share Redis DB+prefix, so a concurrent
|
||||
* afterEach can wipe our session between beforeEach `put` and the SUT call,
|
||||
* triggering PlaywrightBridge auto-refresh (which has no credentials).
|
||||
*
|
||||
* Calling this immediately before the job dispatches the HTTP request
|
||||
* minimizes the race window. The test still tolerates the rare case where
|
||||
* another test's afterEach runs between this put and the SUT's read — but
|
||||
* empirically the window is too small for that to fire.
|
||||
*/
|
||||
function putSupplierSession(): void
|
||||
{
|
||||
Cache::store('redis')->put(
|
||||
@@ -46,13 +31,9 @@ function putSupplierSession(): void
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
beforeEach(function (): void {
|
||||
Mail::fake();
|
||||
// Partial fake: only RouteSupplierLeadJob is intercepted (what we assert on).
|
||||
// RefreshSupplierSessionJob must NOT be faked — it must run our mock below
|
||||
// so that loadSession() can recover if a concurrent afterEach wipes the session.
|
||||
Bus::fake([RouteSupplierLeadJob::class]);
|
||||
// Bind a mock that re-puts the session when dispatch_sync triggers it during a race.
|
||||
app()->bind(RefreshSupplierSessionJob::class, fn () => new class
|
||||
{
|
||||
public function handle(): void
|
||||
@@ -60,63 +41,92 @@ beforeEach(function () {
|
||||
putSupplierSession();
|
||||
}
|
||||
});
|
||||
// NB: NOT Cache::store('redis')->flush() — flush wipes session keys belonging to
|
||||
// OTHER parallel tests (cross-pollution). Just forget our reserved keys + re-put.
|
||||
Cache::store('redis')->forget('supplier:csv_reconcile');
|
||||
putSupplierSession();
|
||||
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
|
||||
config(['services.supplier.alert_email' => 'ops@liderra.ru']);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
afterEach(function (): void {
|
||||
Cache::store('redis')->forget('supplier:csv_reconcile');
|
||||
});
|
||||
|
||||
/**
|
||||
* 3-колоночный CSV «Запрос номеров»: Name;Tag;Phone.
|
||||
*
|
||||
* @param array<int, array{project: string, phone: string}> $rows
|
||||
*/
|
||||
function csvBody(array $rows): string
|
||||
{
|
||||
$out = "vid;project;tag;phone;phones;time\n";
|
||||
$out = "Name;Tag;Phone\n";
|
||||
foreach ($rows as $r) {
|
||||
$out .= "{$r['vid']};{$r['project']};;{$r['phone']};{$r['phone']};{$r['time']}\n";
|
||||
$out .= "{$r['project']};tag;{$r['phone']}\n";
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
function makeSupplierProject(): SupplierProject
|
||||
/**
|
||||
* Мокает весь async-флоу отчёта (реальные endpoint'ы — discovery T3 2026-05-19):
|
||||
* POST /admin/report/save-report → "OK"
|
||||
* GET /admin/report/load-reports → array [{id, title, status:"1", ...}] (id извлекается по title-match)
|
||||
* GET /admin/report/getfile?id=N → raw CSV
|
||||
*
|
||||
* Title включает фактически использованные dateFrom/dateTo — захватываем их из save-report body
|
||||
* и возвращаем тот же диапазон в load-reports, чтобы матч requestNumbersReport состоялся.
|
||||
*/
|
||||
function fakeReportFlow(string $csv): void
|
||||
{
|
||||
return SupplierProject::factory()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'a.com',
|
||||
$captured = ['from' => '', 'to' => ''];
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/report/save-report' => function (Request $r) use (&$captured) {
|
||||
$body = $r->data();
|
||||
$captured['from'] = (string) ($body['reportFilter']['dateFrom'] ?? '');
|
||||
$captured['to'] = (string) ($body['reportFilter']['dateTo'] ?? '');
|
||||
|
||||
return Http::response('OK', 200);
|
||||
},
|
||||
'crm.bp-gr.ru/admin/report/load-reports' => function () use (&$captured) {
|
||||
$title = sprintf('Запрос номеров с %s по %s', $captured['from'], $captured['to']);
|
||||
|
||||
return Http::response([
|
||||
['id' => '700001', 'title' => $title, 'status' => '1', 'is_file' => '1', 'percent' => '100'],
|
||||
], 200);
|
||||
},
|
||||
'crm.bp-gr.ru/admin/report/getfile*' => Http::response($csv, 200),
|
||||
]);
|
||||
}
|
||||
|
||||
it('matches existing leads, no missing — status=ok, no alert', function () {
|
||||
$sp = makeSupplierProject();
|
||||
$now = time();
|
||||
|
||||
$vids = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$vid = (int) ('11100000'.$i); // numeric vid because BIGINT
|
||||
$vids[] = $vid;
|
||||
SupplierLead::factory()->create([
|
||||
'vid' => $vid,
|
||||
'phone' => '79991234567',
|
||||
'supplier_project_id' => $sp->id,
|
||||
'received_at' => now()->subHour(),
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($vids as $vid) {
|
||||
$rows[] = ['vid' => (string) $vid, 'project' => 'B1_a.com', 'phone' => '79991234567', 'time' => $now - 3600];
|
||||
}
|
||||
Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]);
|
||||
|
||||
putSupplierSession();
|
||||
function runCsvReconcile(): void
|
||||
{
|
||||
app(CsvReconcileJob::class)->handle(
|
||||
app(SupplierPortalClient::class),
|
||||
app(SupplierCsvParser::class),
|
||||
app(Mailer::class),
|
||||
);
|
||||
}
|
||||
|
||||
it('no missing leads — status=ok, no recovery, no alert', function (): void {
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => "7999000000{$i}",
|
||||
'vid' => 800000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999000000{$i}"];
|
||||
}
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect($log->status)->toBe('ok');
|
||||
@@ -128,77 +138,61 @@ it('matches existing leads, no missing — status=ok, no alert', function () {
|
||||
Bus::assertNothingDispatched();
|
||||
});
|
||||
|
||||
it('drift 10% (1 missing of 10) → alert email + 1 RouteJob dispatched', function () {
|
||||
$sp = makeSupplierProject();
|
||||
$now = time();
|
||||
|
||||
$vids = [];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$vids[] = (int) ('22200000'.$i);
|
||||
}
|
||||
|
||||
// Existing 9 of 10
|
||||
it('1 missing of 10 (drift 10%) — recovery + drift alert', function (): void {
|
||||
for ($i = 0; $i < 9; $i++) {
|
||||
SupplierLead::factory()->create([
|
||||
'vid' => $vids[$i],
|
||||
'phone' => '79991234567',
|
||||
'supplier_project_id' => $sp->id,
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => "7999111000{$i}",
|
||||
'vid' => 810000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($vids as $vid) {
|
||||
$rows[] = ['vid' => (string) $vid, 'project' => 'B1_a.com', 'phone' => '79991234567', 'time' => $now - 3600];
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => "7999111000{$i}"];
|
||||
}
|
||||
Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]);
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
putSupplierSession();
|
||||
app(CsvReconcileJob::class)->handle(
|
||||
app(SupplierPortalClient::class),
|
||||
app(SupplierCsvParser::class),
|
||||
app(Mailer::class),
|
||||
);
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect($log->status)->toBe('drift_alert');
|
||||
expect((float) $log->drift_ratio)->toBeGreaterThan(0.05);
|
||||
expect((int) $log->recovered_count)->toBe(1);
|
||||
|
||||
$recovered = SupplierLead::where('source', 'csv_recovery')->first();
|
||||
expect($recovered)->not->toBeNull();
|
||||
expect($recovered->vid)->toBeNull();
|
||||
expect($recovered->recovered_from_csv_at)->not->toBeNull();
|
||||
|
||||
Mail::assertSent(CsvDriftAlertMail::class, 1);
|
||||
Bus::assertDispatched(RouteSupplierLeadJob::class, 1);
|
||||
});
|
||||
|
||||
it('drift 1% (1 missing of 100) → status=ok, no alert', function () {
|
||||
$sp = makeSupplierProject();
|
||||
$now = time();
|
||||
|
||||
$vids = [];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$vids[] = (int) ('33300'.str_pad((string) $i, 4, '0', STR_PAD_LEFT));
|
||||
}
|
||||
|
||||
it('1 missing of 100 (drift 1%) — recovery without alert', function (): void {
|
||||
for ($i = 0; $i < 99; $i++) {
|
||||
SupplierLead::factory()->create([
|
||||
'vid' => $vids[$i],
|
||||
'phone' => '79991234567',
|
||||
'supplier_project_id' => $sp->id,
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT),
|
||||
'vid' => 820000 + $i,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($vids as $vid) {
|
||||
$rows[] = ['vid' => (string) $vid, 'project' => 'B1_a.com', 'phone' => '79991234567', 'time' => $now - 3600];
|
||||
for ($i = 0; $i < 100; $i++) {
|
||||
$rows[] = ['project' => 'B1_a.com', 'phone' => '79992'.str_pad((string) $i, 6, '0', STR_PAD_LEFT)];
|
||||
}
|
||||
Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]);
|
||||
fakeReportFlow(csvBody($rows));
|
||||
|
||||
putSupplierSession();
|
||||
app(CsvReconcileJob::class)->handle(
|
||||
app(SupplierPortalClient::class),
|
||||
app(SupplierCsvParser::class),
|
||||
app(Mailer::class),
|
||||
);
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect($log->status)->toBe('ok');
|
||||
@@ -206,49 +200,60 @@ it('drift 1% (1 missing of 100) → status=ok, no alert', function () {
|
||||
Mail::assertNothingSent();
|
||||
});
|
||||
|
||||
it('empty CSV → status=ok, drift=0, no alert', function () {
|
||||
Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response("vid;project;tag;phone;phones;time\n", 200)]);
|
||||
it('dedup is keyed by (phone, project) — same phone on different project is NOT a duplicate', function (): void {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79995550000',
|
||||
'vid' => 830000,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => '79995550000'],
|
||||
'received_at' => now()->subHour(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
|
||||
putSupplierSession();
|
||||
app(CsvReconcileJob::class)->handle(
|
||||
app(SupplierPortalClient::class),
|
||||
app(SupplierCsvParser::class),
|
||||
app(Mailer::class),
|
||||
);
|
||||
fakeReportFlow(csvBody([
|
||||
['project' => 'B1_a.com', 'phone' => '79995550000'],
|
||||
['project' => 'B2_b.com', 'phone' => '79995550000'],
|
||||
]));
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect((int) $log->matched_count)->toBe(1);
|
||||
expect((int) $log->recovered_count)->toBe(1);
|
||||
});
|
||||
|
||||
it('empty CSV — status=ok, drift=0', function (): void {
|
||||
fakeReportFlow("Name;Tag;Phone\n");
|
||||
|
||||
runCsvReconcile();
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect($log->status)->toBe('ok');
|
||||
expect((int) $log->total_csv_rows)->toBe(0);
|
||||
});
|
||||
|
||||
it('SupplierTransientException → status=failed, error_message recorded', function () {
|
||||
it('overlap lock held — job skips, no log row', function (): void {
|
||||
$countBefore = DB::table('supplier_csv_reconcile_log')->count();
|
||||
|
||||
$lock = Cache::store('redis')->lock('supplier:csv_reconcile', 600);
|
||||
$lock->get();
|
||||
|
||||
try {
|
||||
runCsvReconcile();
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
expect(DB::table('supplier_csv_reconcile_log')->count())->toBe($countBefore);
|
||||
});
|
||||
|
||||
it('SupplierTransientException — status=failed, error recorded, rethrown', function (): void {
|
||||
Http::fake(['crm.bp-gr.ru/*' => Http::response('Server Error', 500)]);
|
||||
|
||||
putSupplierSession();
|
||||
expect(fn () => app(CsvReconcileJob::class)->handle(
|
||||
app(SupplierPortalClient::class),
|
||||
app(SupplierCsvParser::class),
|
||||
app(Mailer::class),
|
||||
)
|
||||
)->toThrow(SupplierTransientException::class);
|
||||
expect(fn () => runCsvReconcile())->toThrow(SupplierTransientException::class);
|
||||
|
||||
$log = DB::table('supplier_csv_reconcile_log')->latest('id')->first();
|
||||
expect($log->status)->toBe('failed');
|
||||
expect($log->error_message)->toContain('500');
|
||||
});
|
||||
|
||||
it('Schedule entry: hourly cron registered', function () {
|
||||
/** @var Schedule $schedule */
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$events = $schedule->events();
|
||||
$hasCsv = collect($events)->contains(function ($event) {
|
||||
$repr = (string) ($event->description ?? '');
|
||||
if (property_exists($event, 'job')) {
|
||||
$repr .= ' '.((string) $event->job);
|
||||
}
|
||||
|
||||
return str_contains($repr, 'CsvReconcileJob');
|
||||
});
|
||||
expect($hasCsv)->toBeTrue();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
it('CsvReconcileJob is scheduled every 30 minutes', function (): void {
|
||||
/** @var Schedule $schedule */
|
||||
$schedule = app(Schedule::class);
|
||||
|
||||
$csvEvent = collect($schedule->events())->first(function ($event): bool {
|
||||
$repr = (string) ($event->description ?? '');
|
||||
if (property_exists($event, 'job')) {
|
||||
$repr .= ' '.((string) $event->job);
|
||||
}
|
||||
|
||||
return str_contains($repr, 'CsvReconcileJob');
|
||||
});
|
||||
|
||||
expect($csvEvent)->not->toBeNull();
|
||||
// Laravel everyThirtyMinutes() → cron-выражение '*/30 * * * *'.
|
||||
expect($csvEvent->expression)->toBe('*/30 * * * *');
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Mail\SupplierCriticalAlertMail;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierManualSyncQueue;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
|
||||
use App\Services\Supplier\Channel\FailoverProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row + queues alert mail', function (): void {
|
||||
Mail::fake();
|
||||
config(['services.supplier.alert_email' => 'ops@liderra.local']);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$tier1 = mock(SupplierProjectChannel::class);
|
||||
$tier1->shouldReceive('listProjects')->andReturn([]); // dedup-сверка: нет совпадений
|
||||
$tier1->shouldReceive('createProject')->andThrow(new SupplierClientException('Tier-1 mock fail'));
|
||||
|
||||
$tier2 = mock(SupplierProjectChannel::class);
|
||||
$tier2->shouldReceive('createProject')->andThrow(new RuntimeException('Tier-2 manage-project.js selector break'));
|
||||
|
||||
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'failover-smoke.example',
|
||||
limit: 1, workdays: [1, 2, 3, 4, 5], regions: [], regionsReverse: false, status: 'active',
|
||||
);
|
||||
|
||||
expect(fn () => $channel->createProjectForLiderra($project, $dto))
|
||||
->toThrow(TierEscalatedException::class);
|
||||
|
||||
expect(SupplierManualSyncQueue::where('project_id', $project->id)->count())->toBe(1);
|
||||
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class, fn ($m) => $m->alertType === 'manual_required');
|
||||
});
|
||||
|
||||
test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straight to Tier-3', function (): void {
|
||||
Mail::fake();
|
||||
config(['services.supplier.alert_email' => 'ops@liderra.local']);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$tier1 = mock(SupplierProjectChannel::class);
|
||||
$tier1->shouldReceive('listProjects')->andReturn([]);
|
||||
$tier1->shouldReceive('createProject')->andThrow(new SupplierTransientException('Connection refused'));
|
||||
|
||||
$tier2 = mock(SupplierProjectChannel::class);
|
||||
$tier2->shouldNotReceive('createProject'); // КЛЮЧЕВОЕ — transient НЕ должен попасть в tier-2
|
||||
|
||||
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'transient-smoke.example',
|
||||
limit: 1, workdays: [1, 2, 3, 4, 5], regions: [], regionsReverse: false, status: 'active',
|
||||
);
|
||||
|
||||
expect(fn () => $channel->createProjectForLiderra($project, $dto))
|
||||
->toThrow(TierEscalatedException::class);
|
||||
|
||||
$row = SupplierManualSyncQueue::where('project_id', $project->id)->first();
|
||||
expect($row->failure_reason)->toBe('portal_unreachable');
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\SupplierCsvParser;
|
||||
|
||||
function rowsOf(iterable $gen): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($gen as $row) {
|
||||
$out[] = $row;
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
it('parses 3-column Name;Tag;Phone CSV', function (): void {
|
||||
$csv = "Name;Tag;Phone\nB1_a.com;tagA;79991234567\nB2_79990001122;tagB;79993334455\n";
|
||||
$rows = rowsOf((new SupplierCsvParser)->parse($csv));
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
expect($rows[0])->toBe(['project' => 'B1_a.com', 'tag' => 'tagA', 'phone' => '79991234567']);
|
||||
expect($rows[1])->toBe(['project' => 'B2_79990001122', 'tag' => 'tagB', 'phone' => '79993334455']);
|
||||
});
|
||||
|
||||
it('strips UTF-8 BOM and normalizes CRLF', function (): void {
|
||||
$csv = "\xEF\xBB\xBFName;Tag;Phone\r\nB1_a.com;t;79991234567\r\n";
|
||||
$rows = rowsOf((new SupplierCsvParser)->parse($csv));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['project'])->toBe('B1_a.com');
|
||||
});
|
||||
|
||||
it('skips malformed rows with fewer than 3 columns', function (): void {
|
||||
$csv = "Name;Tag;Phone\nB1_a.com;t;79991234567\nbroken;row\nB2_b.com;t2;79990000000\n";
|
||||
$rows = rowsOf((new SupplierCsvParser)->parse($csv));
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
expect($rows[1]['project'])->toBe('B2_b.com');
|
||||
});
|
||||
|
||||
it('returns nothing for empty CSV', function (): void {
|
||||
expect(rowsOf((new SupplierCsvParser)->parse('')))->toBe([]);
|
||||
});
|
||||
|
||||
it('returns nothing for header-only CSV', function (): void {
|
||||
expect(rowsOf((new SupplierCsvParser)->parse("Name;Tag;Phone\n")))->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SupplierLead;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('allows supplier_leads with vid=NULL (CSV-recovered leads)', function (): void {
|
||||
$lead = SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79991234567',
|
||||
'vid' => null,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'tag' => 't', 'phone' => '79991234567'],
|
||||
'received_at' => now(),
|
||||
'source' => 'csv_recovery',
|
||||
'recovered_from_csv_at' => now(),
|
||||
]);
|
||||
|
||||
expect($lead->id)->toBeGreaterThan(0);
|
||||
expect($lead->fresh()->vid)->toBeNull();
|
||||
});
|
||||
|
||||
it('allows multiple supplier_leads with vid=NULL under the UNIQUE index', function (): void {
|
||||
foreach (['79990000001', '79990000002', '79990000003'] as $phone) {
|
||||
SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => $phone,
|
||||
'vid' => null,
|
||||
'raw_payload' => ['project' => 'B1_a.com', 'phone' => $phone],
|
||||
'received_at' => now(),
|
||||
'source' => 'csv_recovery',
|
||||
'recovered_from_csv_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
expect(SupplierLead::whereNull('vid')->count())->toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('still accepts a real numeric vid for webhook leads', function (): void {
|
||||
$lead = SupplierLead::create([
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
'phone' => '79991234567',
|
||||
'vid' => 432176649,
|
||||
'raw_payload' => ['vid' => 432176649, 'project' => 'B1_a.com'],
|
||||
'received_at' => now(),
|
||||
'source' => 'webhook',
|
||||
]);
|
||||
|
||||
expect($lead->fresh()->vid)->toBe(432176649);
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function (): void {
|
||||
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
|
||||
Cache::store('redis')->put('supplier:session', ['phpsessid' => 'test', 'csrf' => 'test'], now()->addHour());
|
||||
});
|
||||
|
||||
// Реальные endpoint'ы verified discovery T3 2026-05-19 через Playwright MCP:
|
||||
// POST /admin/report/save-report (JSON body, response "OK" — text, не JSON)
|
||||
// GET /admin/report/load-reports (array — каждая запись {id,title,status:"0"|"1",...})
|
||||
// GET /admin/report/getfile?id=N (raw CSV body)
|
||||
|
||||
it('requestNumbersReport posts save-report and resolves id via load-reports by title match', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/report/save-report' => Http::response('OK', 200),
|
||||
'crm.bp-gr.ru/admin/report/load-reports' => Http::response([
|
||||
['id' => '509196', 'title' => 'Запрос номеров с 2026-05-17 по 2026-05-18', 'status' => '0'],
|
||||
['id' => '508346', 'title' => 'Запрос номеров с 2026-04-01 по 2026-04-30', 'status' => '1'],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$client = app(SupplierPortalClient::class);
|
||||
$id = $client->requestNumbersReport(Carbon::parse('2026-05-17'), Carbon::parse('2026-05-18'));
|
||||
|
||||
expect($id)->toBe(509196);
|
||||
|
||||
Http::assertSent(function (Request $r): bool {
|
||||
if (! str_ends_with($r->url(), '/admin/report/save-report')) {
|
||||
return false;
|
||||
}
|
||||
if ($r->method() !== 'POST') {
|
||||
return false;
|
||||
}
|
||||
$body = $r->data();
|
||||
|
||||
return ($body['reportForm']['selectType'] ?? null) === 49
|
||||
&& ($body['reportFilter']['dateFrom'] ?? null) === '2026-05-17'
|
||||
&& ($body['reportFilter']['dateTo'] ?? null) === '2026-05-18'
|
||||
&& ($body['reportFilter']['types'] ?? null) === ['phones']
|
||||
&& ($body['reportFilter']['prophones'] ?? null) === 'curr'
|
||||
&& ($body['reportFilter']['gck_tech'] ?? null) === 'gck';
|
||||
});
|
||||
});
|
||||
|
||||
it('waitReportReady polls load-reports until our entry has status "1"', function (): void {
|
||||
Http::fakeSequence('crm.bp-gr.ru/admin/report/load-reports')
|
||||
->push([
|
||||
['id' => '509196', 'title' => 'Запрос номеров с 2026-05-17 по 2026-05-18', 'status' => '0'],
|
||||
], 200)
|
||||
->push([
|
||||
['id' => '509196', 'title' => 'Запрос номеров с 2026-05-17 по 2026-05-18', 'status' => '1'],
|
||||
], 200);
|
||||
|
||||
$client = app(SupplierPortalClient::class);
|
||||
$client->waitReportReady(509196);
|
||||
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('downloadReport returns raw CSV body from getfile', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/report/getfile*' => Http::response("Name;Tag;Phone\nB1_a.com;t;79991234567\n", 200),
|
||||
]);
|
||||
|
||||
$client = app(SupplierPortalClient::class);
|
||||
$csv = $client->downloadReport(509196);
|
||||
|
||||
expect($csv)->toContain('Name;Tag;Phone');
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Http\Client\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
/*
|
||||
* rt-project-* contract tests.
|
||||
*
|
||||
* Контракт верифицирован live 2026-05-19 (Playwright MCP recon — см. план
|
||||
* Task 1: создан LIDPOTOK_TEST_DELETE_ME на crm.bp-gr.ru, записаны сетевые
|
||||
* запросы, проект удалён). Endpoints:
|
||||
* POST /admin/visit/rt-project-save (JSON, конверт {status,message,result,id})
|
||||
* POST /admin/visit/rt-project-delete (JSON, конверт {status,message,result})
|
||||
* GET /admin/visit/rt-projects-load?src=none
|
||||
*
|
||||
* Tests use Http::fake — отделяем контракт SupplierPortalClient от реального портала.
|
||||
*/
|
||||
|
||||
beforeEach(function (): void {
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'test-session',
|
||||
'csrf' => 'test-csrf',
|
||||
], now()->addHour());
|
||||
|
||||
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
|
||||
});
|
||||
|
||||
it('saveProject POSTs to /admin/visit/rt-project-save with JSON body and parses id from envelope', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12721245'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1',
|
||||
signalType: 'site',
|
||||
uniqueKey: 'lidpotok-test.local',
|
||||
limit: 100,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
$externalId = app(SupplierPortalClient::class)->saveProject($dto);
|
||||
|
||||
expect($externalId)->toBe(12721245);
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
$expectsB1 = $request['srcrt'] === true && $request['srcbl'] === false && $request['srcmt'] === false;
|
||||
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://crm.bp-gr.ru/admin/visit/rt-project-save'
|
||||
&& $request->hasHeader('Content-Type', 'application/json')
|
||||
&& $request['id'] === 0
|
||||
&& $request['tag'] === '_lidpotok'
|
||||
&& $request['name'] === 'lidpotok-test.local'
|
||||
&& $request['content'] === 'lidpotok-test.local'
|
||||
&& $request['type'] === 'hosts'
|
||||
&& $expectsB1
|
||||
&& $request['limit'] === 100
|
||||
&& $request['workdays'] === ['1', '2', '3', '4', '5', '6', '7']
|
||||
&& $request['regions_reverse'] === false
|
||||
&& $request['status'] === true;
|
||||
});
|
||||
});
|
||||
|
||||
it('saveProject maps signalType call → type:"calls" and B2 → srcbl=true (single-true)', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12721244'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B2',
|
||||
signalType: 'call',
|
||||
uniqueKey: '79991112233',
|
||||
limit: 50,
|
||||
workdays: [1, 2, 3],
|
||||
regions: [77],
|
||||
regionsReverse: true,
|
||||
status: 'paused',
|
||||
);
|
||||
|
||||
app(SupplierPortalClient::class)->saveProject($dto);
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
return $request['type'] === 'calls'
|
||||
&& $request['srcrt'] === false
|
||||
&& $request['srcbl'] === true
|
||||
&& $request['srcmt'] === false
|
||||
&& $request['regions'] === [77]
|
||||
&& $request['regions_reverse'] === true
|
||||
&& $request['status'] === false;
|
||||
});
|
||||
});
|
||||
|
||||
it('updateProject POSTs to /admin/visit/rt-project-save with id:N (same endpoint as save)', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12721245'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B3',
|
||||
signalType: 'sms',
|
||||
uniqueKey: 'KEYWORD',
|
||||
limit: 25,
|
||||
workdays: [1, 5],
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
app(SupplierPortalClient::class)->updateProject(12721245, $dto);
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://crm.bp-gr.ru/admin/visit/rt-project-save'
|
||||
&& $request['id'] === 12721245
|
||||
&& $request['type'] === 'sms'
|
||||
&& $request['srcmt'] === true;
|
||||
});
|
||||
});
|
||||
|
||||
it('deleteProject POSTs to /admin/visit/rt-project-delete with JSON {id:"<string>"}', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
app(SupplierPortalClient::class)->deleteProject(12721245);
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
return $request->method() === 'POST'
|
||||
&& $request->url() === 'https://crm.bp-gr.ru/admin/visit/rt-project-delete'
|
||||
&& $request->hasHeader('Content-Type', 'application/json')
|
||||
&& $request['id'] === '12721245';
|
||||
});
|
||||
});
|
||||
|
||||
it('listProjects extracts projects[] from the envelope and returns raw rows', function (): void {
|
||||
// Verified live 2026-05-19: ответ — конверт {projects:[...], tags, users, ...},
|
||||
// НЕ голый массив. listProjects извлекает projects.
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response([
|
||||
'projects' => [
|
||||
['id' => '12721245', 'tag' => '_lidpotok', 'name' => 'B3_LIDPOTOK', 'type' => 'hosts', 'content' => 'foo.com'],
|
||||
],
|
||||
'tags' => [],
|
||||
'users' => [],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$list = app(SupplierPortalClient::class)->listProjects();
|
||||
|
||||
expect($list)->toHaveCount(1);
|
||||
expect($list[0]['id'])->toBe('12721245');
|
||||
expect($list[0]['name'])->toBe('B3_LIDPOTOK');
|
||||
|
||||
Http::assertSent(function (Request $request): bool {
|
||||
return $request->method() === 'GET'
|
||||
&& str_contains($request->url(), '/admin/visit/rt-projects-load')
|
||||
&& $request->data() === ['src' => 'none'];
|
||||
});
|
||||
});
|
||||
|
||||
it('listProjects returns empty array when envelope has no projects key', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(['tags' => []], 200),
|
||||
]);
|
||||
|
||||
expect(app(SupplierPortalClient::class)->listProjects())->toBe([]);
|
||||
});
|
||||
|
||||
it('saveProject throws SupplierClientException on HTTP 200 + status:"Error"', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'Error', 'message' => 'Лимит недостаточен!', 'result' => null],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1',
|
||||
signalType: 'site',
|
||||
uniqueKey: 'rejected.local',
|
||||
limit: 1,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: [],
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
);
|
||||
|
||||
expect(fn () => app(SupplierPortalClient::class)->saveProject($dto))
|
||||
->toThrow(SupplierClientException::class, 'Лимит недостаточен!');
|
||||
});
|
||||
|
||||
it('deleteProject throws SupplierClientException on HTTP 200 + status:"Error"', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
|
||||
['status' => 'Error', 'message' => 'Проект не найден', 'result' => null],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
expect(fn () => app(SupplierPortalClient::class)->deleteProject(99999999))
|
||||
->toThrow(SupplierClientException::class, 'Проект не найден');
|
||||
});
|
||||
@@ -10,6 +10,7 @@ use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\SupplierSyncLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\AjaxProjectChannel;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
@@ -64,17 +65,20 @@ test('creates supplier_project at supplier when supplier_external_id is null', f
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sp->refresh();
|
||||
expect($sp->supplier_external_id)->toBe('555')
|
||||
->and($sp->sync_status)->toBe('ok')
|
||||
->and($sp->current_limit)->toBe(3);
|
||||
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/rt-project-save'));
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save'));
|
||||
});
|
||||
|
||||
test('updates when diff detected', function (): void {
|
||||
@@ -101,16 +105,21 @@ test('updates when diff detected', function (): void {
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/rt-project-update' => Http::response([], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12345'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sp->refresh();
|
||||
expect($sp->current_limit)->toBe(10)
|
||||
->and($sp->sync_status)->toBe('ok');
|
||||
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/rt-project-update'));
|
||||
// Update теперь идёт на тот же endpoint что и save (verified 2026-05-19 — Task 1 recon),
|
||||
// с id:N в body вместо id:0.
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save') && $r['id'] === 12345);
|
||||
});
|
||||
|
||||
test('skips when no diff between current and computed allocation', function (): void {
|
||||
@@ -138,7 +147,7 @@ test('skips when no diff between current and computed allocation', function ():
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
(new SyncSupplierProjectsJob)->handle();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
@@ -188,11 +197,11 @@ test('isolates failure: one bad supplier_project does not stop others', function
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
|
||||
Http::fakeSequence('crm.bp-gr.ru/admin/rt-project-save')
|
||||
Http::fakeSequence('crm.bp-gr.ru/admin/visit/rt-project-save')
|
||||
->push('bad request', 422)
|
||||
->push(['id' => 777], 200);
|
||||
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '777'], 200);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
expect(
|
||||
SupplierSyncLog::on('pgsql_supplier')
|
||||
@@ -233,7 +242,7 @@ test('aborts after 50 consecutive transient failures and sends alert', function
|
||||
|
||||
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
|
||||
return $mail->alertType === 'mass_transient';
|
||||
@@ -264,10 +273,13 @@ test('writes supplier_sync_log row for each successful action', function (): voi
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 555], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$log = SupplierSyncLog::on('pgsql_supplier')
|
||||
->where('supplier_project_id', $sp->id)
|
||||
@@ -305,7 +317,7 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
(new SyncSupplierProjectsJob)->handle();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
@@ -370,7 +382,7 @@ test('sticky auth error throws and sends critical alert email', function (): voi
|
||||
'crm.bp-gr.ru/*' => Http::response('Unauthorized', 401),
|
||||
]);
|
||||
|
||||
expect(fn () => (new SyncSupplierProjectsJob)->handle())
|
||||
expect(fn () => (new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class)))
|
||||
->toThrow(SupplierAuthException::class);
|
||||
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
|
||||
@@ -403,10 +415,13 @@ test('outbound: copies project regions[] into supplier_project current_regions v
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 556], 200),
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '556'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sp->refresh();
|
||||
expect($sp->current_regions)->toBe([82, 83])
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import * as components from 'vuetify/components';
|
||||
import * as directives from 'vuetify/directives';
|
||||
import axios from 'axios';
|
||||
import AdminSupplierIntegrationView from '../../resources/js/views/admin/AdminSupplierIntegrationView.vue';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
const vuetify = createVuetify({ components, directives });
|
||||
|
||||
describe('AdminSupplierIntegrationView — manual queue section', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(axios.get as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
if (url.endsWith('/manual-queue')) {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
queue: [
|
||||
{
|
||||
id: 1,
|
||||
project_id: 42,
|
||||
platform: 'B1',
|
||||
operation: 'create',
|
||||
external_id: null,
|
||||
payload_snapshot: { limit: 10, signal_type: 'site', unique_key: 'foo.com' },
|
||||
failure_reason: 'contract_break',
|
||||
created_at: '2026-05-19T10:00:00Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ data: { health: null, history: [] } });
|
||||
});
|
||||
});
|
||||
|
||||
it('renders pending queue rows with payload + reason', async () => {
|
||||
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('foo.com');
|
||||
expect(text).toContain('contract_break');
|
||||
expect(text).toContain('B1');
|
||||
});
|
||||
|
||||
it('clicking «Отметить выполнено» calls resolve endpoint', async () => {
|
||||
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
data: { resolved: true, external_id: 700123 },
|
||||
});
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
const wrapper = mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const btn = wrapper.find('[data-testid="resolve-1"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
await btn.trigger('click');
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/manual-queue/1/resolve'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import * as components from 'vuetify/components';
|
||||
import * as directives from 'vuetify/directives';
|
||||
import axios from 'axios';
|
||||
import AdminSupplierIntegrationView from '../../resources/js/views/admin/AdminSupplierIntegrationView.vue';
|
||||
|
||||
vi.mock('axios');
|
||||
|
||||
const vuetify = createVuetify({ components, directives });
|
||||
|
||||
const healthPayload = {
|
||||
health: { last_run_at: '2026-05-18T12:00:00Z', last_status: 'ok', drift_ratio: 0.02, webhook_state: 'live' },
|
||||
history: [
|
||||
{
|
||||
started_at: '2026-05-18T12:00:00Z', finished_at: '2026-05-18T12:01:00Z',
|
||||
window_start: '2026-05-17T00:00:00Z', window_end: '2026-05-18T12:00:00Z',
|
||||
status: 'ok', total_csv_rows: 100, matched_count: 98, recovered_count: 2, drift_ratio: 0.02,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function mountView() {
|
||||
return mount(AdminSupplierIntegrationView, { global: { plugins: [vuetify] } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(axios.get as ReturnType<typeof vi.fn>).mockResolvedValue({ data: healthPayload });
|
||||
(axios.post as ReturnType<typeof vi.fn>).mockResolvedValue({ data: { dispatched: true } });
|
||||
});
|
||||
|
||||
describe('AdminSupplierIntegrationView', () => {
|
||||
it('loads channel health on mount', async () => {
|
||||
const wrapper = mountView();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
expect(axios.get).toHaveBeenCalledWith('/api/admin/supplier-integration');
|
||||
expect(wrapper.text()).toContain('live');
|
||||
});
|
||||
|
||||
it('renders reconcile history rows', async () => {
|
||||
const wrapper = mountView();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(wrapper.text()).toContain('100');
|
||||
});
|
||||
|
||||
it('triggers manual reconcile on button click', async () => {
|
||||
const wrapper = mountView();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await wrapper.find('[data-test="reconcile-now"]').trigger('click');
|
||||
expect(axios.post).toHaveBeenCalledWith('/api/admin/supplier-integration/reconcile');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import DealDetailBody from '../../resources/js/components/deals/DealDetailBody.vue';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
function makeDeal(overrides: Partial<MockDeal> = {}): MockDeal {
|
||||
return {
|
||||
id: 1, name: '+79991234567', phone: '+79991234567', statusSlug: 'new',
|
||||
project: 'p', manager: { initials: 'AD', name: 'Admin' }, cost: 0,
|
||||
receivedMinutesAgo: 1,
|
||||
projectSignalType: 'site', projectSignalIdentifier: 'krk-finance.ru',
|
||||
projectSmsKeyword: null, projectSmsSenders: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DealDetailBody — Тип и Источник (18.05.2026 ux)', () => {
|
||||
it('site: показывает Тип «Сайт» и Источник = signal_identifier', () => {
|
||||
setActivePinia(createPinia());
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal() },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.text()).toContain('Сайт');
|
||||
expect(w.text()).toContain('krk-finance.ru');
|
||||
});
|
||||
|
||||
it('call: Тип «Звонок» и Источник = телефонный номер', () => {
|
||||
setActivePinia(createPinia());
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal({
|
||||
projectSignalType: 'call',
|
||||
projectSignalIdentifier: '79992223344',
|
||||
}) },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.text()).toContain('Звонок');
|
||||
expect(w.text()).toContain('79992223344');
|
||||
});
|
||||
|
||||
it('sms с keyword: Источник = «sender (KEYWORD)»', () => {
|
||||
setActivePinia(createPinia());
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal({
|
||||
projectSignalType: 'sms',
|
||||
projectSignalIdentifier: null,
|
||||
projectSmsSenders: ['MTS', 'BEELINE'],
|
||||
projectSmsKeyword: 'КРЕДИТ',
|
||||
}) },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.text()).toContain('СМС');
|
||||
expect(w.text()).toContain('MTS (КРЕДИТ)');
|
||||
});
|
||||
|
||||
it('sms без keyword: Источник = только sender', () => {
|
||||
setActivePinia(createPinia());
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal({
|
||||
projectSignalType: 'sms',
|
||||
projectSignalIdentifier: null,
|
||||
projectSmsSenders: ['MTS'],
|
||||
projectSmsKeyword: null,
|
||||
}) },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.text()).toContain('СМС');
|
||||
expect(w.text()).toContain('MTS');
|
||||
// Никаких пустых скобок
|
||||
expect(w.text()).not.toMatch(/\(\s*\)/);
|
||||
});
|
||||
|
||||
it('не отображает «Менеджер»', () => {
|
||||
setActivePinia(createPinia());
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal() },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.text()).not.toContain('Менеджер');
|
||||
expect(w.text()).not.toContain('Не назначен');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import DealDetailHero from '../../resources/js/components/deals/DealDetailHero.vue';
|
||||
import type { MockDeal } from '../../resources/js/composables/mockDeals';
|
||||
import type { LeadStatus } from '../../resources/js/composables/leadStatuses';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const statuses: LeadStatus[] = [
|
||||
{ slug: 'new', nameRu: 'Новая сделка', colorHex: '#5b2db2', order: 1 } as LeadStatus,
|
||||
{ slug: 'viewed', nameRu: 'Просмотрено', colorHex: '#5a2db2', order: 2 } as LeadStatus,
|
||||
{ slug: 'won', nameRu: 'Куплено', colorHex: '#00A36C', order: 3 } as LeadStatus,
|
||||
];
|
||||
|
||||
function makeDeal(over: Partial<MockDeal> = {}): MockDeal {
|
||||
return {
|
||||
id: 1, name: '+79991234567', phone: '+79991234567', statusSlug: 'new',
|
||||
project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0,
|
||||
receivedMinutesAgo: 1, ...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DealDetailHero — inline status picker (18.05.2026)', () => {
|
||||
it('рендерит статус-chip с триггером (data-testid="status-chip-trigger")', () => {
|
||||
const w = mount(DealDetailHero, {
|
||||
props: { deal: makeDeal(), status: statuses[0], allStatuses: statuses },
|
||||
global: { plugins: [vuetify] },
|
||||
});
|
||||
expect(w.find('[data-testid="status-chip-trigger"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('клик по chip открывает меню (data-testid="status-option-{slug}" появляются)', async () => {
|
||||
const w = mount(DealDetailHero, {
|
||||
props: { deal: makeDeal(), status: statuses[0], allStatuses: statuses },
|
||||
global: { plugins: [vuetify], stubs: { teleport: false } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.find('[data-testid="status-chip-trigger"]').trigger('click');
|
||||
// Give v-menu time to mount (teleport target = body).
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
const options = document.body.querySelectorAll('[data-testid^="status-option-"]');
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
const wonOption = document.body.querySelector('[data-testid="status-option-won"]') as HTMLElement | null;
|
||||
expect(wonOption).not.toBeNull();
|
||||
wonOption?.click();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(w.emitted('change-status')?.[0]?.[0]).toBe('won');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,32 @@ describe('DealsView.vue — реестр лидов', () => {
|
||||
expect(vm.panelOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('при selected=1 drawer авто-открывается, bulk-полоса скрыта (18.05.2026 ux)', async () => {
|
||||
const w = await mountDeals();
|
||||
const vm = w.vm as unknown as {
|
||||
selected: number[]; panelOpen: boolean; selectedDeal: MockDeal | null;
|
||||
};
|
||||
vm.selected = [1];
|
||||
await flushPromises();
|
||||
expect(vm.panelOpen).toBe(true);
|
||||
expect(vm.selectedDeal?.id).toBe(1);
|
||||
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('при selected≥2 drawer закрывается, bulk-полоса видна (18.05.2026 ux)', async () => {
|
||||
const w = await mountDeals();
|
||||
const vm = w.vm as unknown as {
|
||||
selected: number[]; panelOpen: boolean; dealsState: MockDeal[];
|
||||
openPanel: (d: MockDeal) => void;
|
||||
};
|
||||
vm.openPanel(vm.dealsState[0]);
|
||||
expect(vm.panelOpen).toBe(true);
|
||||
vm.selected = [1, 2];
|
||||
await flushPromises();
|
||||
expect(vm.panelOpen).toBe(false);
|
||||
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('bulk-bar появляется при выборе и applyBulkStatus меняет статус', async () => {
|
||||
const w = await mountDeals();
|
||||
const vm = w.vm as unknown as {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { stripChannelPrefix } from '../../resources/js/composables/projectName';
|
||||
|
||||
/**
|
||||
* Имена проектов crm.bp префиксуются B1_/B2_/B3_ (источник-провайдер).
|
||||
* В UI Лидерры префикс убираем — он шум для пользователя; данные в БД не трогаем.
|
||||
*/
|
||||
describe('stripChannelPrefix', () => {
|
||||
it('убирает B1_ префикс', () => {
|
||||
expect(stripChannelPrefix('B1_73912557675 [35]')).toBe('73912557675 [35]');
|
||||
});
|
||||
|
||||
it('убирает B2_ префикс', () => {
|
||||
expect(stripChannelPrefix('B2_krk-finance.ru/cabinet/auth [24]')).toBe('krk-finance.ru/cabinet/auth [24]');
|
||||
});
|
||||
|
||||
it('убирает B3_ префикс', () => {
|
||||
expect(stripChannelPrefix('B3_kras.vashinvestor.ru [23]')).toBe('kras.vashinvestor.ru [23]');
|
||||
});
|
||||
|
||||
it('case-insensitive: b1_/b2_/b3_ тоже убирает', () => {
|
||||
expect(stripChannelPrefix('b1_test')).toBe('test');
|
||||
expect(stripChannelPrefix('b3_demo')).toBe('demo');
|
||||
});
|
||||
|
||||
it('не трогает имя без префикса', () => {
|
||||
expect(stripChannelPrefix('quidem fugiat unde')).toBe('quidem fugiat unde');
|
||||
expect(stripChannelPrefix('Натяжные потолки')).toBe('Натяжные потолки');
|
||||
});
|
||||
|
||||
it('не трогает B4_/B0_/Bx_ — только B1/B2/B3', () => {
|
||||
expect(stripChannelPrefix('B4_other')).toBe('B4_other');
|
||||
expect(stripChannelPrefix('B0_zero')).toBe('B0_zero');
|
||||
expect(stripChannelPrefix('BX_unknown')).toBe('BX_unknown');
|
||||
});
|
||||
|
||||
it('не трогает префикс внутри строки — только в начале', () => {
|
||||
expect(stripChannelPrefix('foo B1_bar')).toBe('foo B1_bar');
|
||||
});
|
||||
|
||||
it('терпит null/undefined/пустую строку', () => {
|
||||
expect(stripChannelPrefix(null)).toBe('');
|
||||
expect(stripChannelPrefix(undefined)).toBe('');
|
||||
expect(stripChannelPrefix('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,10 @@ use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
// Контракт парсера (эпик CSV-канал T2, 18.05.2026): отчёт «Запрос номеров»
|
||||
// crm.bp-gr.ru — 3 колонки Name;Tag;Phone. vid и время в отчёте отсутствуют.
|
||||
// SupplierCsvParser::parse() yields {project, tag, phone}. Spec §4.1.
|
||||
|
||||
beforeEach(function () {
|
||||
$this->parser = new SupplierCsvParser;
|
||||
});
|
||||
@@ -17,25 +21,24 @@ it('parses empty CSV → yields nothing', function () {
|
||||
expect($rows)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('parses 1 row → yields 1 struct with vid/project/phone/time', function () {
|
||||
$csv = "vid;project;tag;phone;phones;time\n"
|
||||
."1234;B1_example.com;;79991234567;79991234567;1715432400\n";
|
||||
it('parses 1 row → yields 1 struct with project/tag/phone', function () {
|
||||
$csv = "Name;Tag;Phone\n"
|
||||
."B1_example.com;mytag;79991234567\n";
|
||||
|
||||
$rows = iterator_to_array($this->parser->parse($csv));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0])->toMatchArray([
|
||||
'vid' => '1234',
|
||||
'project' => 'B1_example.com',
|
||||
'tag' => 'mytag',
|
||||
'phone' => '79991234567',
|
||||
'time' => 1715432400,
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses 1000 rows without OOM (streaming generator)', function () {
|
||||
$lines = ['vid;project;tag;phone;phones;time'];
|
||||
$lines = ['Name;Tag;Phone'];
|
||||
for ($i = 1; $i <= 1000; $i++) {
|
||||
$lines[] = "{$i};B1_test.com;;79991234567;79991234567;1715432400";
|
||||
$lines[] = "B1_test.com;tag{$i};79991234567";
|
||||
}
|
||||
$csv = implode("\n", $lines)."\n";
|
||||
|
||||
@@ -50,16 +53,16 @@ it('parses 1000 rows without OOM (streaming generator)', function () {
|
||||
it('skips malformed rows with missing columns + logs warning', function () {
|
||||
Log::spy();
|
||||
|
||||
$csv = "vid;project;tag;phone;phones;time\n"
|
||||
."1234;B1_example.com;;79991234567;79991234567;1715432400\n"
|
||||
$csv = "Name;Tag;Phone\n"
|
||||
."B1_example.com;mytag;79991234567\n"
|
||||
."broken-row-only-one-column\n"
|
||||
."5678;B1_another.com;;79991234567;79991234567;1715432500\n";
|
||||
."B1_another.com;tag2;79991234500\n";
|
||||
|
||||
$rows = iterator_to_array($this->parser->parse($csv));
|
||||
|
||||
expect($rows)->toHaveCount(2);
|
||||
expect($rows[0]['vid'])->toBe('1234');
|
||||
expect($rows[1]['vid'])->toBe('5678');
|
||||
expect($rows[0]['project'])->toBe('B1_example.com');
|
||||
expect($rows[1]['project'])->toBe('B1_another.com');
|
||||
|
||||
Log::shouldHaveReceived('warning')
|
||||
->with('supplier_csv_parser.malformed_row', Mockery::any())
|
||||
@@ -68,11 +71,11 @@ it('skips malformed rows with missing columns + logs warning', function () {
|
||||
|
||||
it('handles BOM + CRLF line endings', function () {
|
||||
$bom = "\xEF\xBB\xBF";
|
||||
$csv = $bom."vid;project;tag;phone;phones;time\r\n"
|
||||
."1234;B1_example.com;;79991234567;79991234567;1715432400\r\n";
|
||||
$csv = $bom."Name;Tag;Phone\r\n"
|
||||
."B1_example.com;mytag;79991234567\r\n";
|
||||
|
||||
$rows = iterator_to_array($this->parser->parse($csv));
|
||||
|
||||
expect($rows)->toHaveCount(1);
|
||||
expect($rows[0]['vid'])->toBe('1234');
|
||||
expect($rows[0]['project'])->toBe('B1_example.com');
|
||||
});
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Http\Client\Factory as HttpFactory;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'test-session', 'csrf' => 'test-csrf-token',
|
||||
], now()->addHour());
|
||||
|
||||
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
|
||||
});
|
||||
|
||||
it('GET /admin/report/index?type=49 returns CSV body on 200', function () {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/report/index*' => Http::response(
|
||||
"vid;project;tag;phone;phones;time\n1234;B1_example.com;;79991234567;79991234567;1715432400\n",
|
||||
200,
|
||||
['Content-Type' => 'text/csv'],
|
||||
),
|
||||
]);
|
||||
|
||||
$client = new SupplierPortalClient(app(HttpFactory::class));
|
||||
$body = $client->downloadLeadsCsv(
|
||||
Carbon::parse('2024-05-11 00:00:00'),
|
||||
Carbon::parse('2024-05-12 00:00:00'),
|
||||
);
|
||||
|
||||
expect($body)->toContain('1234;B1_example.com');
|
||||
});
|
||||
|
||||
it('401 → triggers session refresh → retry → 200', function () {
|
||||
Http::fakeSequence('crm.bp-gr.ru/admin/report/index*')
|
||||
->push('Unauthorized', 401)
|
||||
->push("vid;...\n", 200);
|
||||
|
||||
// RefreshSupplierSessionJob — мокаем
|
||||
app()->bind(RefreshSupplierSessionJob::class, function () {
|
||||
return new class
|
||||
{
|
||||
public function handle(): void
|
||||
{
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'refreshed', 'csrf' => 'refreshed-csrf',
|
||||
], now()->addHour());
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
$client = new SupplierPortalClient(app(HttpFactory::class));
|
||||
$body = $client->downloadLeadsCsv(
|
||||
Carbon::parse('2024-05-11'),
|
||||
Carbon::parse('2024-05-12'),
|
||||
);
|
||||
|
||||
expect($body)->toContain('vid');
|
||||
});
|
||||
|
||||
it('500 → SupplierTransientException', function () {
|
||||
Http::fake(['crm.bp-gr.ru/*' => Http::response('Internal Server Error', 500)]);
|
||||
|
||||
$client = new SupplierPortalClient(app(HttpFactory::class));
|
||||
|
||||
expect(fn () => $client->downloadLeadsCsv(
|
||||
Carbon::parse('2024-05-11'),
|
||||
Carbon::parse('2024-05-12'),
|
||||
))->toThrow(SupplierTransientException::class);
|
||||
});
|
||||
@@ -94,8 +94,13 @@ test('network error throws SupplierTransientException', function (): void {
|
||||
->toThrow(SupplierTransientException::class);
|
||||
});
|
||||
|
||||
test('saveProject POSTs to /admin/rt-project-save with full payload and returns external id', function (): void {
|
||||
Http::fake(['crm.bp-gr.ru/admin/rt-project-save' => Http::response(['id' => 99001], 200)]);
|
||||
test('saveProject POSTs to /admin/visit/rt-project-save with full payload and returns external id', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '99001'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1',
|
||||
@@ -111,13 +116,22 @@ test('saveProject POSTs to /admin/rt-project-save with full payload and returns
|
||||
$id = app(SupplierPortalClient::class)->saveProject($dto);
|
||||
|
||||
expect($id)->toBe(99001);
|
||||
// Verified live 2026-05-19 (Task 1 recon): тело Vuex-state c srcrt=true для B1.
|
||||
Http::assertSent(fn ($r): bool => $r->method() === 'POST'
|
||||
&& str_ends_with($r->url(), '/admin/rt-project-save')
|
||||
&& ($r['platform'] ?? null) === 'B1');
|
||||
&& str_ends_with($r->url(), '/admin/visit/rt-project-save')
|
||||
&& ($r['name'] ?? null) === 'example.com'
|
||||
&& ($r['type'] ?? null) === 'hosts'
|
||||
&& ($r['srcrt'] ?? null) === true
|
||||
&& ($r['id'] ?? null) === 0);
|
||||
});
|
||||
|
||||
test('updateProject POSTs to /admin/rt-project-update with id + full payload', function (): void {
|
||||
Http::fake(['crm.bp-gr.ru/admin/rt-project-update' => Http::response('', 200)]);
|
||||
test('updateProject POSTs to /admin/visit/rt-project-save with id + full payload', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12345'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B2',
|
||||
@@ -132,19 +146,28 @@ test('updateProject POSTs to /admin/rt-project-update with id + full payload', f
|
||||
|
||||
app(SupplierPortalClient::class)->updateProject(externalId: 12345, dto: $dto);
|
||||
|
||||
// Update = тот же endpoint что save, но с id:N (verified 2026-05-19 recon).
|
||||
Http::assertSent(fn ($r): bool => $r->method() === 'POST'
|
||||
&& str_ends_with($r->url(), '/admin/rt-project-update')
|
||||
&& ((int) ($r['id'] ?? 0)) === 12345);
|
||||
&& str_ends_with($r->url(), '/admin/visit/rt-project-save')
|
||||
&& ((int) ($r['id'] ?? 0)) === 12345
|
||||
&& ($r['type'] ?? null) === 'calls'
|
||||
&& ($r['srcbl'] ?? null) === true);
|
||||
});
|
||||
|
||||
test('deleteProject POSTs to /admin/rt-project-delete with id only', function (): void {
|
||||
Http::fake(['crm.bp-gr.ru/admin/rt-project-delete' => Http::response('', 200)]);
|
||||
test('deleteProject POSTs to /admin/visit/rt-project-delete with id only', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-delete' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
app(SupplierPortalClient::class)->deleteProject(externalId: 12345);
|
||||
|
||||
// ID идёт строкой в JSON-body (verified 2026-05-19 recon: {"id":"12345"}).
|
||||
Http::assertSent(fn ($r): bool => $r->method() === 'POST'
|
||||
&& str_ends_with($r->url(), '/admin/rt-project-delete')
|
||||
&& ((int) ($r['id'] ?? 0)) === 12345);
|
||||
&& str_ends_with($r->url(), '/admin/visit/rt-project-delete')
|
||||
&& ($r['id'] ?? null) === '12345');
|
||||
});
|
||||
|
||||
test('malformed portal_url throws SupplierClientException, not auth path', function (): void {
|
||||
@@ -201,3 +224,65 @@ test('RefreshSupplierSessionJob throws during initial loadSession translated to
|
||||
->and($caught->getPrevious())->toBeInstanceOf(RuntimeException::class)
|
||||
->and($caught->getPrevious()?->getMessage())->toBe('Simulated Playwright crash during loadSession');
|
||||
});
|
||||
|
||||
test('200 HTML login page triggers RefreshSupplierSessionJob sync and retries once', function (): void {
|
||||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||||
|
||||
Http::fakeSequence('crm.bp-gr.ru/*')
|
||||
->push(
|
||||
'<html><body><form action="/login"><input id="loginform-username" name="LoginForm[username]"></form></body></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html; charset=utf-8'],
|
||||
)
|
||||
->push('{"projects":[]}', 200, ['Content-Type' => 'application/json']);
|
||||
|
||||
app(SupplierPortalClient::class)->listProjects();
|
||||
|
||||
Bus::assertDispatchedSync(RefreshSupplierSessionJob::class);
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
|
||||
test('sticky HTML login page after retry throws SupplierAuthException', function (): void {
|
||||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||||
|
||||
Http::fakeSequence('crm.bp-gr.ru/*')
|
||||
->push(
|
||||
'<html><input id="loginform-username"></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html; charset=utf-8'],
|
||||
)
|
||||
->push(
|
||||
'<html><input id="loginform-username"></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html; charset=utf-8'],
|
||||
);
|
||||
|
||||
expect(fn () => app(SupplierPortalClient::class)->listProjects())
|
||||
->toThrow(SupplierAuthException::class, 'Portal returned login page after refresh');
|
||||
});
|
||||
|
||||
test('JSON response with substring "loginform-username" is NOT misclassified as login page', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/*' => Http::response(
|
||||
'{"projects":[{"name":"loginform-username is just a string here"}]}',
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
),
|
||||
]);
|
||||
|
||||
$result = app(SupplierPortalClient::class)->listProjects();
|
||||
|
||||
expect($result)->toHaveCount(1);
|
||||
Http::assertSentCount(1); // no retry — JSON header skips login-detect
|
||||
});
|
||||
|
||||
test('200 response without Content-Type header is NOT detected as login page', function (): void {
|
||||
// Документирует контракт: пустой Content-Type → str_starts_with('','text/html') === false → детект пропускается.
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/*' => Http::response('{"projects":[]}', 200), // no Content-Type header
|
||||
]);
|
||||
|
||||
app(SupplierPortalClient::class)->listProjects();
|
||||
|
||||
Http::assertSentCount(1); // no retry — empty Content-Type fails the text/html gate
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Test</title></head><body>
|
||||
<div id="add-project-modal" data-testid="add-project-form">
|
||||
<label><input type="checkbox" name="active" checked> Активный</label>
|
||||
<label>Тег <input type="text" name="tag" id="tag-input"></label>
|
||||
<fieldset>
|
||||
<legend>Источник данных</legend>
|
||||
<label><input type="checkbox" name="platform[]" value="B1" checked> B1</label>
|
||||
<label><input type="checkbox" name="platform[]" value="B2" checked> B2</label>
|
||||
<label><input type="checkbox" name="platform[]" value="B3" checked> B3</label>
|
||||
</fieldset>
|
||||
<label>Название проекта <input type="text" name="name" id="name-input" required></label>
|
||||
<label>Источники сбора <select name="signal_type" id="signal-type"><option>Сайты</option><option>Звонки</option><option>СМС</option></select></label>
|
||||
<fieldset>
|
||||
<legend>Регион</legend>
|
||||
<label><input type="radio" name="region_mode" value="include" checked> Включить</label>
|
||||
<label><input type="radio" name="region_mode" value="exclude"> Исключить</label>
|
||||
<input type="text" name="regions_filter" placeholder="Фильтр по регионам">
|
||||
</fieldset>
|
||||
<label>Список сайтов <textarea name="domains" id="domains-input"></textarea></label>
|
||||
<label>Лимит в день <input type="number" name="limit" id="limit-input" value="10"></label>
|
||||
<fieldset>
|
||||
<legend>Дни получения номеров</legend>
|
||||
<label><input type="checkbox" name="workdays[]" value="1" checked> Пн.</label>
|
||||
<label><input type="checkbox" name="workdays[]" value="2" checked> Вт.</label>
|
||||
<label><input type="checkbox" name="workdays[]" value="3" checked> Ср.</label>
|
||||
<label><input type="checkbox" name="workdays[]" value="4" checked> Чт.</label>
|
||||
<label><input type="checkbox" name="workdays[]" value="5" checked> Пт.</label>
|
||||
<label><input type="checkbox" name="workdays[]" value="6" checked> Сб.</label>
|
||||
<label><input type="checkbox" name="workdays[]" value="7" checked> Вс.</label>
|
||||
</fieldset>
|
||||
<button type="button" id="save-btn">Сохранить</button>
|
||||
<button type="button" id="cancel-btn">Отмена</button>
|
||||
</div>
|
||||
<table id="projects-table"><tbody></tbody></table>
|
||||
<script>
|
||||
document.getElementById('save-btn').addEventListener('click', function() {
|
||||
var tbody = document.querySelector('#projects-table tbody');
|
||||
var row = document.createElement('tr');
|
||||
row.dataset.id = String(Date.now());
|
||||
var tdId = document.createElement('td');
|
||||
tdId.textContent = row.dataset.id;
|
||||
var tdName = document.createElement('td');
|
||||
tdName.textContent = document.getElementById('name-input').value;
|
||||
row.appendChild(tdId);
|
||||
row.appendChild(tdName);
|
||||
tbody.appendChild(row);
|
||||
document.getElementById('add-project-modal').style.display = 'none';
|
||||
});
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
// Minimal Vitest config for tools/*.test.mjs (Node environment, no Vue/DOM).
|
||||
// Separate from vitest.config.ts which targets tests/Frontend/**/*.ts.
|
||||
// Run from repo root: node app/node_modules/vitest/vitest.mjs --config app/vitest.config.tools.mjs run
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['../tools/*.test.mjs'],
|
||||
exclude: ['../tools/ruflo-*.test.mjs', '../tools/subagent-prompt-prefix.test.mjs'],
|
||||
},
|
||||
});
|
||||
@@ -1428,3 +1428,73 @@ evals
|
||||
парсится
|
||||
ревьюить
|
||||
инвокацией
|
||||
|
||||
# ЭТАЛОН проекта (2026-05-18) — Russian IT vocabulary
|
||||
волатильный
|
||||
волатильную
|
||||
волатильно
|
||||
волатильны
|
||||
незакоммиченное
|
||||
бандл
|
||||
|
||||
# План «Сделки drawer + редактирование источника» (2026-05-18)
|
||||
табах
|
||||
|
||||
отревизован
|
||||
ребаланс
|
||||
квирком
|
||||
тулинг
|
||||
лоадит
|
||||
CCS
|
||||
промпта
|
||||
|
||||
# Компакция «мозга» findings 2/3/6/7 (2026-05-18)
|
||||
пин
|
||||
пинуют
|
||||
стабу
|
||||
клаузы
|
||||
коммичу
|
||||
|
||||
# Brain governance design (2026-05-19) — router-only + observer + 4 контролёра
|
||||
слойного
|
||||
слойный
|
||||
рецидивирующие
|
||||
зарегламентировать
|
||||
версионный
|
||||
стейлнес
|
||||
апдейты
|
||||
разруливают
|
||||
брейн
|
||||
DWC
|
||||
нодов
|
||||
креды
|
||||
Апи
|
||||
имплементациями
|
||||
алёрт
|
||||
инжектят
|
||||
инжектим
|
||||
фикстурный
|
||||
роута
|
||||
# Brain dashboard design spec (2026-05-19)
|
||||
визуализирующий
|
||||
анимируются
|
||||
неподсвеченными
|
||||
полл
|
||||
инференс
|
||||
вендорено
|
||||
|
||||
# Brain dashboard implementation plan (2026-05-19)
|
||||
visualises
|
||||
AGD
|
||||
agg
|
||||
|
||||
# Supplier migration follow-up (2026-05-19)
|
||||
ретрая
|
||||
детекта
|
||||
Регэксп
|
||||
фрэш
|
||||
дебагом
|
||||
srcrt
|
||||
srcbl
|
||||
srcmt
|
||||
симв
|
||||
|
||||
+28
-2
@@ -1,11 +1,37 @@
|
||||
# CHANGELOG schema.sql — Лидерра
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать две записи в обратном хронологическом порядке (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.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.23, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.25, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
**История записей:**
|
||||
|
||||
## v8.25 — 2026-05-19 — supplier_manual_sync_queue (Tier 3 резерва канала миграции проектов)
|
||||
|
||||
**+1 таблица** SaaS-level (без tenant_id / RLS, как `supplier_csv_reconcile_log`):
|
||||
|
||||
- `supplier_manual_sync_queue` — очередь яруса 3 резерва канала миграции проектов
|
||||
(spec `docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md` §4.5).
|
||||
- **+3 CHECK:** `chk_smsq_platform` (B1/B2/B3), `chk_smsq_operation` (create/update),
|
||||
`chk_smsq_status` (pending/resolved/cancelled).
|
||||
- **+2 индекса:** `idx_smsq_status_created`, `idx_smsq_project`.
|
||||
- **+2 FK:** `project_id → projects ON DELETE CASCADE`;
|
||||
`resolved_by_user_id → users ON DELETE SET NULL`.
|
||||
|
||||
Метрики после: 64 базовые таблицы (62 regular + 2 partitioned parents),
|
||||
12 партиций, 121 индекс, 40 RLS-политик, 5 функций, 13 триггеров.
|
||||
|
||||
Миграция: `2026_05_19_120000_create_supplier_manual_sync_queue.php` (idempotent
|
||||
guard через `to_regclass`).
|
||||
|
||||
## v8.24 — 2026-05-18 — supplier_leads.vid → nullable
|
||||
|
||||
`ALTER TABLE supplier_leads ALTER COLUMN vid DROP NOT NULL`. Резервный CSV-канал
|
||||
(Путь 2): отчёт поставщика «Запрос номеров» не содержит vid → CSV-recovered лиды
|
||||
имеют vid=NULL. UNIQUE-индекс idx_supplier_leads_vid_unique сохранён (в PostgreSQL
|
||||
NULL ≠ NULL — множественные NULL не конфликтуют). Миграция:
|
||||
2026_05_18_140000_supplier_leads_vid_nullable.php. RLS не затронут.
|
||||
|
||||
## v8.23 — 2026-05-17 — Редизайн «Сделки» (воронка статусов 14 → 5)
|
||||
|
||||
**Изменения:**
|
||||
|
||||
+35
-3
@@ -1,7 +1,8 @@
|
||||
-- =============================================================================
|
||||
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
|
||||
-- Версия: v8.23 (17.05.2026 — Редизайн «Сделки»: seed lead_statuses 14→5 (new/viewed/in_progress/won/lost))
|
||||
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 119 индексов / 40 RLS-политик / 5 функций / 13 триггеров
|
||||
-- Версия: v8.25 (19.05.2026 — supplier_manual_sync_queue: SaaS-level Tier 3 очередь резерва канала миграции проектов)
|
||||
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 121 индекс / 40 RLS-политик / 5 функций / 13 триггеров
|
||||
-- Базовая версия: v8.24 (18.05.2026 — supplier_leads.vid → nullable для CSV-recovered лидов (Путь 2))
|
||||
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
|
||||
-- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
|
||||
-- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth)
|
||||
@@ -1128,6 +1129,37 @@ CREATE INDEX supplier_csv_reconcile_log_status_index
|
||||
ON supplier_csv_reconcile_log(status)
|
||||
WHERE status IN ('drift_alert','failed');
|
||||
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- supplier_manual_sync_queue — Tier 3 очередь резерва канала миграции проектов (v8.25)
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- SaaS-level (не tenant-scoped, без RLS, как supplier_csv_reconcile_log).
|
||||
-- FailoverProjectChannel записывает строку при провале ярусов 1-2: оператор
|
||||
-- админ-экрана вносит проект вручную в crm.bp-gr.ru и помечает row resolved.
|
||||
-- Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE supplier_manual_sync_queue (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(8) NOT NULL,
|
||||
operation VARCHAR(16) NOT NULL,
|
||||
external_id VARCHAR(64),
|
||||
payload_snapshot JSONB NOT NULL,
|
||||
failure_reason VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
resolved_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
CONSTRAINT chk_smsq_platform CHECK (platform IN ('B1', 'B2', 'B3')),
|
||||
CONSTRAINT chk_smsq_operation CHECK (operation IN ('create', 'update')),
|
||||
CONSTRAINT chk_smsq_status CHECK (status IN ('pending', 'resolved', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_smsq_status_created
|
||||
ON supplier_manual_sync_queue (status, created_at DESC);
|
||||
CREATE INDEX idx_smsq_project
|
||||
ON supplier_manual_sync_queue (project_id);
|
||||
|
||||
-- GRANT-policy в db/02_grants.sql (для prod). Dev: postgres superuser.
|
||||
|
||||
|
||||
@@ -1910,7 +1942,7 @@ CREATE TABLE supplier_leads (
|
||||
supplier_project_id BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
raw_payload JSONB NOT NULL,
|
||||
vid BIGINT NOT NULL,
|
||||
vid BIGINT, -- nullable: NULL у CSV-recovered лидов (Путь 2)
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
source VARCHAR(16) NOT NULL DEFAULT 'webhook',
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.13)
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.17)
|
||||
|
||||
**Дата:** 18.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).
|
||||
**Дата:** 19.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.17** — 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), spec `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`, plan `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`.
|
||||
|
||||
**v3.16** — Brain evidence loop: новое R16 «Brain evidence loop» (R16.1 observer scope — Stop-хук пишет episodes-YYYY-MM.jsonl, 5 обязательных полей incl. `primary_rationale`; R16.2 plugin stack-conscious events — `routing_decision` / `skill_invoked` с `node_id` при использовании R6/R6.1/R15, факторная матрица 5 осей для `/brain-retro`; R16.3 не override — R0–R15 определяют выбор, R16 только фиксирует историю; R16.4 cross-refs ADR-011 / Pravila §16 / spec+plan+procedure). 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`.
|
||||
|
||||
**v3.15** — Компакция «мозга» (SYSTEM-аудит 18.05.2026, finding 3 — структурный дрейф счётчиков): R10.1 +note «счётчики и нумерация позиций тулчейна — канон [Tooling Прил. Н §0](Tooling_v8_3.md), anchor "КАНОН СЧЁТЧИКОВ"»; реестр R10.1 ссылается per-row на Tooling #NN, агрегатные числа не дублирует. Содержательных изменений R0–R15: 0. Связано: Tooling Прил.Н v2.16 (§0 +«КАНОН СЧЁТЧИКОВ»), CLAUDE.md v2.17 (§3.3 компакция), Pravila v1.30 (§13.2 пин, §14 dormant-метка); план `docs/superpowers/plans/2026-05-18-brain-compaction-findings-2-3-6-7.md`.
|
||||
|
||||
**v3.14** — Off-phase routing: **R15 новое правило** «Off-phase routing» — закрывает Rec5 SYSTEM-аудита 18.05.2026 «PSR_v1 R-аппарат UI-перекошен (R1-R9 / R11-R14 — UI; off-phase 30 узлов регулировались только R10.1 + меткой "вне R6/R14")». R15.1 — R6.0/R6.1/R14 не применяются к off-phase (codifies существующую практику); R15.2 — routing-таблица 30 узлов вынесена в `docs/routing-off-phase.md` (single home + 12 канонических связок Rec4); R15.3 — приоритет специфичности при коллизии узлов; R15.4 — Pravila §12/§14/§15 перевешивают R15; R15.5 — live-override (заказчик называет узел напрямую). UI-аппарат R0–R14 — без изменений. Финальная формула расширена. ruflo isolation 18.05 (Pravila §14.9) добавляет «ruflo dormant — не маршрутизировать». Связано: `docs/routing-off-phase.md` v1.0, snapshot `docs/discovery/2026-05-18-system-audit-brain.md` Rec5, Pravila v1.29 / Tooling v2.15 / CLAUDE.md v2.16 (pending sync).
|
||||
|
||||
**v3.13** — Anthropic dev-tooling: R10.1 Блок 1 +5 строк таблицы — **skill-creator** (#56) / **plugin-dev** (#57) / **hookify** (#58) / **claude-code-setup** (#59) / **context7** (#60) — 5 Anthropic-плагинов из `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level без формализации (L1-паттерн). +note (v3.13). Новые 13-я (**authoring-tooling** — #56-#58) и 14-я (**dev-support** — #59-#60) off-phase подкатегории — не UI → вне R6.0/R6.1/R14. **hookify HK1** — hard-rule pre-check на коллизию с economy/skill-discipline хуками; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Содержательных изменений R0–R14: 0. ADR-010. Связано: Tooling v2.14, Pravila v1.28, CLAUDE.md v2.15; план `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`.
|
||||
|
||||
@@ -406,6 +414,8 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
|
||||
Реестр разбит на три блока **по типу источника** (v1.5+) — раньше всё было одним списком, что путало «отключи в settings.json» с «не вызывай /команду». Каждый блок имеет свою механику включения и отмены.
|
||||
|
||||
**Счётчики (finding 3, v3.15):** числа позиций тулчейна и off-phase подкатегорий — канон [Tooling Прил. Н §0](Tooling_v8_3.md) (anchor «КАНОН СЧЁТЧИКОВ»). Реестр ниже ссылается per-row на Tooling #NN; агрегатные счётчики PSR_v1 не дублирует — это закрывает класс «дрейф счётчиков» (SYSTEM-аудит 18.05.2026).
|
||||
|
||||
#### Блок 1: `enabledPlugins` через marketplace (включаются в `~/.claude/settings.json`)
|
||||
|
||||
| Плагин | Marketplace | Роль | Когда инвокировать |
|
||||
@@ -767,9 +777,86 @@ Pipeline активируется при одновременном выполн
|
||||
|
||||
---
|
||||
|
||||
## Правило 15 — Off-phase routing
|
||||
|
||||
Закрывает Rec5 SYSTEM-аудита 18.05.2026: R-аппарат R0–R14 регулирует почти исключительно UI-фичи (stack-gate R0, классификация R1, фазы R2, фильтр R6, источники UI R11, паттерны решений R12, decision matrix R13, UI-pipeline R14). Off-phase множество (30 узлов #31-#60 + ruflo + infrastructure) регулировалось одним R10.1 + меткой «не UI → вне R6.0/R6.1/R14», без явной матрицы «задача → узел». R15 — собственный слой регламента для off-phase.
|
||||
|
||||
### 15.1. Off-phase узлы вне UI-фильтров
|
||||
|
||||
R6.0 / R6.1 / R14 pipeline **не применяются** к off-phase узлам. Причина: эти узлы не производят UI-код / визуал бренда — Trail of Bits сканирует security, deptrac анализирует слои зависимостей, openapi-mcp интроспектирует REST API, sentry читает production errors. Применять стек-фильтр к их выводу — категорийная ошибка. Codifies существующую практику (PSR_v1 v3.3–v3.13 каждая интеграция помечала off-phase как «не UI → вне R6/R14»; теперь это явно правило).
|
||||
|
||||
**R15 — пост-R1 слой.** Off-phase routing срабатывает **после** классификации задачи Правилом 1, как выбор инструмента внутри назначенной ветки, а не как отдельная шестая ветка R1. Задача «сделай security-аудит diff» классифицируется R1 как процессная → внутри stack работает Superpowers → если требуется off-phase инструмент (Trail of Bits #39), его выбор регулирует R15-таблица. Финальная формула это отражает: «→ если задача off-phase: Правило 15». R15 не конкурирует с R0/R1 за gate — он работает внутри их рамок.
|
||||
|
||||
### 15.2. Routing-таблица — внешний документ
|
||||
|
||||
Полная таблица «задача → off-phase узел» вынесена в [`docs/routing-off-phase.md`](routing-off-phase.md) v1.0+. Там же — 12 канонических связок 2+ узлов (L1–L12, закрывает Rec4 SYSTEM-аудита: brainstorming-chain, security-слой, project-management-связка, runtime-debug, ML-trio и т.д.) + список anti-pattern связок.
|
||||
|
||||
PSR_v1 не дублирует 30-строчную таблицу — single home в routing-off-phase.md. При коллизии содержимого побеждает routing-off-phase.md (он SoT по off-phase routing); R15.1/R15.3–R15.5 этого правила — мета-слой.
|
||||
|
||||
### 15.3. Приоритет специфичности при коллизии узлов
|
||||
|
||||
Если задача попадает под 2+ off-phase узлов:
|
||||
|
||||
1. **Более специфичный узел** перевешивает менее специфичный (например задача «процессное узкое место из кода Laravel» → `process-analysis` #53 специфичнее общего `operations` #51).
|
||||
2. **ADR-границы** имеют приоритет над интуицией: пары узлов, где границы зафиксированы в ADR (DI1–DI6 в ADR-009 для discovery-interview ↔ process-analysis; OPS1–OPS5 в ADR-008 для operations ↔ process-modeling; UI1–UI3 в ADR-006 для Universal Icons; TB1 для Trail of Bits ↔ Semgrep) — следуем ADR.
|
||||
3. **DEFERRED-узлы** (mcp_figma #44 / Jupyter MCP #50 / n8n-mcp #54) — пропускать; эскалация заказчику если задача требует их.
|
||||
4. **Изолированные узлы** (ruflo на 18.05.2026 — Pravila §14.9 dormant) — не маршрутизировать; queen-триггер сейчас не работает.
|
||||
|
||||
### 15.4. Hard-rules перевешивают R15
|
||||
|
||||
Pravila §12 (Superpowers инвокация первой), §14 (queen-роутинг — сейчас dormant), §15 (параллельные сессии + субагенты git Sonnet/Opus only) — explicit hard-rules. При коллизии с R15 побеждают они. Например запрос с триггером `queen` (когда §14 не dormant) маршрутизируется через ruflo Queen независимо от R15-таблицы; git-коммит-субагент идёт через Sonnet/Opus независимо от того, в каком off-phase узле задача.
|
||||
|
||||
### 15.5. Live-override
|
||||
|
||||
Заказчик может явно назвать узел в промпте («через `process-modeling`», «возьми `mermaid`», «`adr-kit` сделай»). В этом случае R15-таблица **не применяется** — выполнить именно названный узел. Если выбор кажется неоптимальным — кратко отметить (одна строка) и продолжить.
|
||||
|
||||
### 15.6. Гранулярные особенности категорий
|
||||
|
||||
- **debug-runtime** (#34 sentry, #35 redis) — READ-ONLY обязательно. Никаких DEL/SET/FLUSH из Claude.
|
||||
- **UI-пул** (#31 UPM, #32 21st) — здесь R15 не применяется; R14 pipeline ведёт (это UI-задачи по природе).
|
||||
- **infrastructure** (#33 claude-md-management) — единственный канал для правок CLAUDE.md (Pravila §5 п.10 + R10.1 Блок 1).
|
||||
- **authoring-tooling** (#56-#58) — политика триггеров: skill-creator ≥3 повторений workflow → новый скил; hookify повторяющаяся ошибка → новый хук (с pre-check HK1); plugin-dev — для расширений plugin-grain.
|
||||
- **business-process / discovery-tooling / ml-ai-tooling / architecture-tooling / audit-security / project-management / design-tooling / integration-tooling / dev-support** — следуют routing-off-phase.md.
|
||||
|
||||
### 15.7. Тип правила и enforcement
|
||||
|
||||
R15 — обычное правило (не hard-rule). Pravila §9 «Отступления» применяется при необходимости с явным указанием. Нарушение R15 (использование «не того» off-phase узла) — фиксируется в feedback memory, не trigger'ит hard-rule violations.
|
||||
|
||||
---
|
||||
|
||||
## Правило 16 — Brain evidence loop
|
||||
|
||||
**Status**: introduced PSR_v1 v3.16 (2026-05-19) per ADR-011.
|
||||
|
||||
### 16.1. Observer scope
|
||||
|
||||
Observer Stop-хук (`tools/observer-stop-hook.mjs`) пишет evidence в `docs/observer/episodes-YYYY-MM.jsonl` каждую сессию. Поля: `task_id` / `timestamps` / `path_type` / `outcome` / `primary_rationale` + optional `events[]` (per spec v1.1 §5.2.1).
|
||||
|
||||
Схема v2 (2026-05-19, ADR-011 amend): эпизод несёт `schema_version`, `decision_provenance` (autonomous / user_directed_method + контрфактуал), `environment` (`economy_level` / `model` / `post_compaction` / `session_turn` / `parallel_session`), `task_size`, `task_ref`, `prompt_signal`; события расширены `hook_fired` / `interrupt` / `retry` / `time_burn` / `parse_gap`. При внутреннем отказе хука — минимальный `observer_error` маркер вместо тихого пропуска. Spec — `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`.
|
||||
|
||||
### 16.2. Plugin stack-conscious events
|
||||
|
||||
Когда в сессии используется UI-фильтр стека (R6/R6.1) или off-phase узел (R15), observer записывает событие `routing_decision` или `skill_invoked` с `node_id` (ссылка на Tooling Прил. Н §4.NN). Это позволяет `/brain-retro` проагрегировать «какие R6/R15 решения чаще всего применялись» через факторную матрицу (5 осей: triggers_matched / candidates_dropped_because / boundaries_applied / hard_floor.rules / task_classification).
|
||||
|
||||
### 16.3. Не override
|
||||
|
||||
R16 — evidence-сбор, не правило выбора. R0–R15 продолжают определять выбор узлов; R16 фиксирует историю и enables факторный анализ.
|
||||
|
||||
### 16.4. Cross-refs
|
||||
|
||||
- ADR-011 `docs/adr/ADR-011-brain-governance.md`
|
||||
- Pravila §16 (brain governance hard-rule tier-§13)
|
||||
- spec: `docs/superpowers/specs/2026-05-19-brain-governance-design.md`
|
||||
- spec (factor-analysis): `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`
|
||||
- plan: `docs/superpowers/plans/2026-05-19-brain-governance.md`
|
||||
- plan (factor-analysis): `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`
|
||||
- procedure: `docs/router-procedure.md`
|
||||
|
||||
---
|
||||
|
||||
## Финальная формула
|
||||
|
||||
> **Любая задача → Правило 0 (gate, stack-головной) → Правило 1 (классификация по типу) → Правило 9 (решение, ≤2 итерации) → Правило 13 (decision matrix по уверенности) → Правило 2 (фаза UI-фичи) → исполнение по Правилам 3, 4, 6 → если нужен внешний UI-генератор: Правило 14 pipeline (UPM на фазах 1/2, 21st на фазе 5) → завершение по Правилу 7 → ревью по Правилу 5. Источники истины — Правило 11 (UI/UX). Паттерны решений — Правило 12. Координация с не-stack плагинами — Правило 10. Тай-брейкеры — Правило 8.**
|
||||
> **Любая задача → Правило 0 (gate, stack-головной) → Правило 1 (классификация по типу) → Правило 9 (решение, ≤2 итерации) → Правило 13 (decision matrix по уверенности) → Правило 2 (фаза UI-фичи) → исполнение по Правилам 3, 4, 6 → если нужен внешний UI-генератор: Правило 14 pipeline (UPM на фазах 1/2, 21st на фазе 5) → если задача off-phase (security / архитектура / процесс / discovery / ML / debug / интеграция / authoring / docs-tooling): Правило 15 (routing-off-phase.md + ADR-границы) → завершение по Правилу 7 → ревью по Правилу 5. Источники истины — Правило 11 (UI/UX). Паттерны решений — Правило 12. Координация с не-stack плагинами — Правило 10. Тай-брейкеры — Правило 8.**
|
||||
|
||||
---
|
||||
|
||||
@@ -799,6 +886,12 @@ Pipeline активируется при одновременном выполн
|
||||
|
||||
## История версий
|
||||
|
||||
- **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.
|
||||
|
||||
- **v3.14 (2026-05-18)** — Off-phase routing: новое R15 «Off-phase routing» (R15.1 off-phase узлы вне UI-фильтров R6.0/R6.1/R14 — codifies существующую практику; R15.2 routing-таблица в `docs/routing-off-phase.md` v1.0+ как single home; R15.3 приоритет специфичности + ADR-границы (DI1-DI6 / OPS1-OPS5 / UI1-UI3 / TB1) при коллизии; R15.4 Pravila §12/§14/§15 перевешивают R15; R15.5 live-override заказчика; R15.6 гранулярные категории; R15.7 обычное правило, не hard-rule). Финальная формула расширена шагом «→ Правило 15 (routing-off-phase.md + ADR-границы) для off-phase». Свойства свода — добавлено R15 в полноту и непротиворечивость. UI-аппарат R0-R14 — без изменений. Слот R15 был свободен после удаления motion-системы в v2.0; теперь занят off-phase routing. Связано: `docs/routing-off-phase.md` v1.0 (новый файл, 30 off-phase узлов + 12 канонических связок Rec4), Pravila v1.29 / Tooling v2.15 / CLAUDE.md v2.16 (pending sync). Snapshot — `docs/discovery/2026-05-18-system-audit-brain.md` Rec5. Через manual Edit. **In-place 18.05 вечер (аудит дисциплины R15):** R15.1 +абзац «R15 — пост-R1 слой» (off-phase routing срабатывает после классификации R1, как выбор инструмента внутри ветки, не отдельная шестая ветка R1 — M2-находка аудита). Содержательных изменений R-аппарата 0; routing-off-phase.md синхронно → v1.1 (note про UI-пул #31/#32 — делегирующие ссылки на R14, не R15-routed; +строка «диагностика конверсии» → process-analysis #53).
|
||||
|
||||
- **v3.8 (2026-05-17)** — A4 design-tooling: R10.1 Блок 1 +Design plugin (`anthropics/claude-plugins-official`, Anthropic Verified) — дизайн-критика и UX, новая 8-я off-phase подкатегория design-tooling; Блок 3 +Universal Icons MCP (`npx -y mcp-universal-icons`, MIT) + Figma MCP (remote `https://mcp.figma.com/mcp`, DEFERRED). Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.8, Pravila v1.22, CLAUDE.md v2.8. План `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`.
|
||||
|
||||
- **v3.7 (2026-05-17)** — A6-расширение deptrac: R10.1 Блок 1 +note «Блок 1 — note (v3.7)» — **deptrac** (`deptrac/deptrac` v4.6.1, BSD-3, Composer dev-dependency — **не** marketplace-плагин и **не** в `enabledPlugins`, регистрируется нотой как mermaid-skill/CCPM; врезан lefthook pre-commit job 10). Категория **architecture-tooling** (Tooling #43, раздел A6 карты) — 4-й инструмент подкатегории, не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.7, Pravila v1.21, CLAUDE.md v2.7. План `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`.
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
# Правила работы Claude в проекте «Лидерра»
|
||||
|
||||
**Версия:** v1.28 (18.05.2026)
|
||||
**Дата:** 18.05.2026
|
||||
**Версия:** v1.33 (19.05.2026)
|
||||
**Дата:** 19.05.2026
|
||||
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
|
||||
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
|
||||
|
||||
**Что изменилось в v1.33 относительно v1.32:** 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 в предыдущем ходе — например `1`, `в делаем`, `делай 2`). §16.7 +абзац: routing-gate НЕ блокирует `user_chose_from_options` (выбор из choice-space, сформулированного самим Claude — не навязанный извне метод). Детектор — `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.
|
||||
|
||||
**Что изменилось в v1.32 относительно v1.31:** 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 контролёр); §16.6 +cross-ref на factor-analysis spec. 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.
|
||||
|
||||
**Что изменилось в v1.31 относительно v1.30:** +§16 «Регламент «мозга» (brain governance)» — 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. ADR-011 enforcement через `adr-judge` lefthook job. Связано: ADR-011, spec `docs/superpowers/specs/2026-05-19-brain-governance-design.md`, plan `docs/superpowers/plans/2026-05-19-brain-governance.md`.
|
||||
|
||||
**Что изменилось в v1.29 относительно v1.28:** +§14.9 «Текущий статус: изолирован (18.05.2026, dormant)» — заказчик распорядился изолировать ruflo от активного потока без удаления артефактов (ход Rec2 SYSTEM-аудита `docs/discovery/2026-05-18-system-audit-brain.md`, маршрут «изолируй, не удаляй»). Live-связи ruflo с Claude-потоком отключены: оба `tools/ruflo-*-hook.mjs` сняты из `.claude/settings.json` UserPromptSubmit; `ruflo` MCP-server удалён из `.mcp.json`; PM2 `ruflo-daemon` остановлен + dump.pm2 = `[]`; Windows Task Scheduler `PM2-ruflo-daemon` оставлен (идемпотентен после пустого save). Артефакты сохранены: npm-пакет, файлы хуков `tools/ruflo-*-hook.mjs`, memory `mem_ruflo`, документация (этот §14, Tooling §4.10, CLAUDE.md §3.5). Queen-триггер §14.1 сейчас **dormant** — хук-инжектор не подаёт директиву; промпт с `queen`/`королева` выполняется напрямую. Откат §14 как нормативного текста заказчик не запрашивал — только изоляции рантайма. План реактивации — memory `feedback_ruflo_isolated.md`. Связано: Tooling v2.15. Архитектурных изменений в §§1–13 + §§14.1-14.8: 0.
|
||||
|
||||
**Что изменилось в v1.28 относительно v1.27:** §13.2 +абзац «Off-phase authoring-tooling + dev-support» — формализованы 5 Anthropic dev-плагинов из `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level без формализации (#56 skill-creator, #57 plugin-dev, #58 hookify — тринадцатая off-phase подкатегория authoring-tooling; #59 claude-code-setup, #60 context7 — четырнадцатая off-phase подкатегория dev-support). L1-паттерн «плагин включён без формализации» (повтор UPM/21st 10.05, Sentry/Redis 13.05). hookify несёт hard-rule HK1 — pre-check на коллизию с economy/skill-discipline хуками. Границы — ADR-010. Связано: Tooling v2.14 / PSR_v1 v3.13 / CLAUDE.md v2.15. План `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`.
|
||||
|
||||
**Что изменилось в v1.27 относительно v1.26:** +§15 hard-rule «Параллельные сессии» (15.1 субагенты+git Sonnet/Opus only, 15.2 нормативка+pre-flight sync, 15.3 cross-refs). §15 третье hard-rule после §12 и §14. Список «нормативка» — 8 позиций. Спек — `docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md`.
|
||||
@@ -591,6 +599,11 @@ P0 = блокер старта спринта или регуляторного
|
||||
| **v1.26** | **18.05.2026** | discovery-interview: §13.2 +абзац «Off-phase discovery-tooling» — формализован скил `discovery-interview` (Tooling #55, §4.30; self-authored project-скил `.claude/skills/discovery-interview/`, режимы FEATURE+SYSTEM — интервью-discovery до проектирования) как двенадцатая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. Как проектный скил регистрируется в §13.2, **не** в §12.2 (карта Superpowers-скилов); триггер-eval 20/20. Границы — ADR-009 (DI1–DI6). Связано: Tooling v2.13 / PSR_v1 v3.12 / CLAUDE.md v2.13. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн — как v1.24/v1.25). План `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.3–14: 0. |
|
||||
| **v1.27** | **18.05.2026** | Параллельные сессии: координация. +§15 hard-rule (15.1 субагенты+git Sonnet/Opus only, 15.2 нормативка+pre-flight sync, 15.3 cross-refs). §15 третье hard-rule после §12 и §14; список «нормативка» — 8 позиций. Лечит два класса инцидентов параллельных-сессий: (A) субагенты теряются между worktree (Sprint 6 прецедент), (B) нормативка/MEMORY дрейфует (Tooling v2.11 collision 17.05.2026). Спек — `docs/superpowers/specs/2026-05-18-parallel-sessions-coordination-design.md`, план — `docs/superpowers/plans/2026-05-18-parallel-sessions-coordination.md`. |
|
||||
| **v1.28** | **18.05.2026** | Anthropic dev-tooling: §13.2 +абзац «Off-phase authoring-tooling + dev-support» — формализованы 5 Anthropic-плагинов из `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level без формализации (#56 skill-creator / #57 plugin-dev / #58 hookify — тринадцатая off-phase подкатегория authoring-tooling; #59 claude-code-setup / #60 context7 — четырнадцатая off-phase подкатегория dev-support); не UI → вне R6.0/R6.1/R14. L1-паттерн «плагин включён без формализации» (повтор UPM/21st 10.05, Sentry/Redis 13.05). hookify несёт hard-rule HK1 — pre-check на коллизию с economy/skill-discipline хуками; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Границы — ADR-010 (SC1–SC3 / PD1–PD3 / HK1–HK3 / CCS1 / CTX1–CTX2). Связано: Tooling v2.14 / PSR_v1 v3.13 / CLAUDE.md v2.15. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн — как v1.24/v1.25/v1.26). **NB:** перенумеровано v1.27→v1.28 — v1.27 параллельно занят parallel-sessions §15 (origin/main `781a59c`); ветка `feat/anthropic-dev-tooling` ребейзнута на §15. План `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.3–14: 0. |
|
||||
| **v1.29** | **18.05.2026** | ruflo isolation (Rec2 SYSTEM-аудита 18.05): +§14.9 «Текущий статус: изолирован, dormant». Заказчик распорядился отрезать ruflo от активного потока без удаления артефактов. Live-связи отключены: `tools/ruflo-recall-hook.mjs` + `tools/ruflo-queen-hook.mjs` сняты из `.claude/settings.json` UserPromptSubmit; `ruflo` MCP-server удалён из `.mcp.json`; PM2 `ruflo-daemon` остановлен (`pm2 stop` + `delete` + `save --force`, `~/.pm2/dump.pm2` = `[]`); Task Scheduler `PM2-ruflo-daemon` оставлен (идемпотентен — после пустого save resurrect восстанавливает пустое состояние). Артефакты сохранены: npm-пакет `ruflo`, файлы хуков `tools/ruflo-*-hook.mjs`, memory `mem_ruflo`, документация. Queen-триггер §14.1 сейчас **dormant** — хук-инжектор не подаёт директиву; промпт с `queen`/`королева` выполняется напрямую. Откат §14 заказчик не запрашивал, только изоляции рантайма. Связано: Tooling v2.15, CLAUDE.md v2.16 (pending sync), memory `feedback_ruflo_isolated.md`. Snapshot — `docs/discovery/2026-05-18-system-audit-brain.md` Rec2. Через прямой Edit (нормативка) + Bash (pm2/runtime) + Edit `.claude/settings.json` + Edit `.mcp.json`. Архитектурных изменений в §§1–14.8: 0. |
|
||||
| **v1.30** | **18.05.2026** | Компакция «мозга» (SYSTEM-аудит findings 2/3/6/7, интервью с заказчиком). **§14 (finding 6):** заголовок +метка «СТАТУС: dormant с 18.05.2026 (§14.9)»; §14.1 +врезка о dormant-статусе перед нормативным текстом — раньше §14.5 объявлял §14 живым hard-rule, а §14.9 dormant-статус был виден только в конце параграфа; теперь читателю §14 виден сразу. **§13.2 (finding 3):** +note «счётчики off-phase подкатегорий/инструментов — канон [Tooling Прил. Н §0](Tooling_v8_3.md)»; ординалы в абзацах §13.2 объявлены описательными. Связано: CLAUDE.md v2.17 (§3.3 компакция + счётчики-пины + ruflo-стаб), Tooling Прил.Н v2.16 (§0 +«КАНОН СЧЁТЧИКОВ»), PSR_v1 v3.15 (R10.1 пин). План `docs/superpowers/plans/2026-05-18-brain-compaction-findings-2-3-6-7.md`. Через прямой Edit. Архитектурных изменений в §§1–14 (кроме §14 заголовок/§14.1 врезка + §13.2 note): 0. |
|
||||
| **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. |
|
||||
|
||||
---
|
||||
|
||||
@@ -723,6 +736,8 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
|
||||
|
||||
**Инфраструктурные плагины (вне расширенного UI-пула, v1.9+):** `claude-md-management` (skills `claude-md-improver` + `revise-claude-md`, marketplace `anthropics/claude-plugins-official`) — единственный интерфейс правок CLAUDE.md (CLAUDE.md §5 п.10). Категория **инфраструктурная**, не UI — поэтому не попадает под §13 (расширенный UI-пул) и не проходит R6.0/R6.1 фильтр / R14 pipeline. Регулируется PSR_v1 R10.1 блок 1 (`enabledPlugins`-плагины) как off-pool tool. Аналогичные инфраструктурные категории — built-in skills Claude Code (`review`, `security-review`, `init`, `simplify`, `update-config`, `keybindings-help`, `fewer-permission-prompts`, `loop`, `schedule`, `claude-api`) — активируются по явному `/имя` от пользователя; PSR_v1 R10.1 блок 2.
|
||||
|
||||
**Счётчики off-phase подкатегорий и инструментов** (ординалы «пятая… четырнадцатая подкатегория», номера #NN) в абзацах ниже — описательные. Канон числовых счётчиков тулчейна — [Tooling Прил. Н §0](Tooling_v8_3.md) (anchor «КАНОН СЧЁТЧИКОВ»); при расхождении приоритет — Tooling §0 (finding 3 SYSTEM-аудита 18.05.2026 — устранение дрейфа счётчиков).
|
||||
|
||||
**Off-phase MCP debug-runtime (отдельная категория, введена v1.13 Pravila, 13.05.2026 day +1):** `@sentry/mcp-server@0.33.0+` (Tooling #34, server `sentry` в `.mcp.json`) — отладка production errors в self-hosted Sentry (Yandex Cloud per CLAUDE.md §2; pending Б-1 ООО registration); `@modelcontextprotocol/server-redis@2025.4.25` (Tooling #35, server `redis` в `.mcp.json`; deprecated Anthropic source; Memurai PONG verified Task 4) — отладка Redis/Memurai runtime (очереди, кэш, Pest --parallel races per quirk 72/77). **Категория отдельная** от UI-пула (§13.2 paired-stack + UPM + 21st) и от infrastructure (claude-md-management §13.2 paragraph выше) — **не trigger'ит R6.0/R6.1 stack-фильтры** (READ-ONLY, не модифицируют code/UI/CLAUDE.md) и **не входит в R14 pipeline** UI-генераторов. Регулируется PSR_v1 R10.1 Блок 3 (`.mcp.json`-серверы) как debug-runtime off-phase tool. READ-ONLY usage обязателен — никаких mutation операций (DEL/FLUSHDB/SET/LPUSH для Redis; write actions для Sentry). Установлены retrospective на feat/claude-automation `6f7e7d7` (sentry) + `bd4ec48` (redis), merged через PR #3 (`cc5f63b`). PSR_v1 cross-ref: **v3.6+**, R10.1 Блок 3.
|
||||
|
||||
**Off-phase architecture-tooling (отдельная категория, v1.17, 17.05.2026; +deptrac v1.21):** четыре инструмента раздела A6 карты «Архитектура систем» — `adr-kit` (Tooling #36, marketplace `rvdbreemen/adr-kit`; ADR-решения в `docs/adr/`, `adr-judge` врезан в lefthook pre-commit job 9 декларативно, без `--llm`), `mermaid-skill` (Tooling #37, вендоренный сторонний скил `.claude/skills/mermaid/`; C4/architecture-диаграммы), `architecture-patterns` (Tooling #38, marketplace `secondsky/claude-skills`; knowledge-only справочник паттернов), `deptrac` (Tooling #43, Composer dev-dependency `deptrac/deptrac` v4.6.1 BSD-3; архитектурный fitness-гейт направления зависимостей / границ слоёв — врезан в lefthook pre-commit job 10, конфиг `app/deptrac.yaml` 13 слоёв, чистый PHP без вызовов LLM). **Категория отдельная** от UI-пула (UPM/21st), infrastructure (claude-md-management) и debug-runtime (Sentry/Redis) — не UI, **не trigger'ит R6.0/R6.1 stack-фильтры и не входит в R14 pipeline**. Регулируется PSR_v1 R10.1 Блок 1 (adr-kit, architecture-patterns) + Блок 1 notes (mermaid-skill — вендоренный скил, deptrac — composer dev-dep — оба вне типологии трёх блоков). Установлены 17.05.2026 (adr-kit/mermaid/architecture-patterns — ветка `feat/a6-architecture-tooling`, план `docs/superpowers/plans/2026-05-17-a6-architecture-tooling-integration.md`; deptrac — план `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`).
|
||||
@@ -825,12 +840,14 @@ Hard-link идёт через цепочку: R14 нарушено → R10.4 «
|
||||
|
||||
§13.10 — **второй hard-link** §13 (после §13.9). Mid-tier — между декларативными §§13.1–13.8 и hard-rule §12.
|
||||
|
||||
## 14. Ruflo Queen routing — hard rule (триггер queen/королева)
|
||||
## 14. Ruflo Queen routing — hard rule (триггер queen/королева) — СТАТУС: dormant с 18.05.2026 (§14.9)
|
||||
|
||||
Введено 15.05.2026 на явное требование заказчика: «зафиксируй жёсткое правило, что когда я пишу queen или королева ты запускаешь через ruflo, и так же подправь правила чтобы чаще отправлял задачи через руфло». Дизайн — через `superpowers:brainstorming` (spec `docs/superpowers/specs/2026-05-15-ruflo-queen-trigger-and-delegation-design.md`).
|
||||
|
||||
### 14.1. Принцип
|
||||
|
||||
**(СТАТУС: правило сейчас dormant — ruflo изолирован 18.05.2026, см. §14.9; промпт с `queen`/`королева` исполняется напрямую, директива не инжектится. Нормативный текст §14.1–§14.8 ниже вступает в силу при реактивации ruflo.)**
|
||||
|
||||
Если промпт заказчика содержит триггер-слово `queen` (англ.) или `королева` (рус., в любой падежной форме) — задача **безусловно** маршрутизируется через ruflo Queen. Это explicit hard-rule (§14.5). Claude не оспаривает маршрут, не предлагает прямой путь, не ссылается на тривиальность задачи.
|
||||
|
||||
### 14.2. Механизм и cost-gate
|
||||
@@ -866,6 +883,14 @@ Hard-link идёт через цепочку: R14 нарушено → R10.4 «
|
||||
|
||||
Откат §14 — только явным запросом заказчика «откати §14». При сбое `hive-mind spawn` (ruflo — alpha-софт) Claude сообщает о сбое и выполняет задачу напрямую как фоллбэк — это не нарушение §14 (правило требует попытки маршрута, а не работающей alpha-инфраструктуры).
|
||||
|
||||
### 14.9. Текущий статус: изолирован (18.05.2026, dormant)
|
||||
|
||||
Заказчик распорядился изолировать ruflo от активного потока, не удаляя артефакты (ход Rec2 SYSTEM-аудита 18.05.2026, маршрут «изолируй, не удаляй»). Live-связи ruflo с Claude-потоком отключены: оба `tools/ruflo-*-hook.mjs` сняты из `.claude/settings.json` UserPromptSubmit; `ruflo` MCP-server удалён из `.mcp.json`; PM2 `ruflo-daemon` остановлен (`pm2 stop` + `delete` + `save --force`, `~/.pm2/dump.pm2` = `[]`); Windows Task Scheduler `PM2-ruflo-daemon` оставлен — после пустого save идемпотентен, resurrect восстанавливает пустое состояние. Артефакты сохранены: npm-пакет `ruflo`, файлы `tools/ruflo-*-hook.mjs`, memory `mem_ruflo`, документация (Tooling §4.10, CLAUDE.md §3.5, этот §14).
|
||||
|
||||
**Следствие §14.1:** queen-триггер сейчас **dormant** — хук-инжектор отключён, директива в промпт не подаётся; промпт с `queen`/`королева` выполняется напрямую (как без триггера). При возобновлении подключения § 14.1 автоматически восстанавливает hard-rule статус — отката §14 как нормативного текста заказчик не запрашивал, только изоляции рантайма.
|
||||
|
||||
**Реактивация:** восстановить блок `UserPromptSubmit` в `.claude/settings.json` (2 хука) + `"ruflo": {...}` entry в `.mcp.json` + `pm2 start <ecosystem-config> && pm2 save --force`. Полный план реактивации — memory `feedback_ruflo_isolated.md` и `project_ruflo_integration.md`.
|
||||
|
||||
---
|
||||
|
||||
## 15. Параллельные сессии — hard rule (субагенты + git, нормативка + pre-flight sync)
|
||||
@@ -915,6 +940,85 @@ git fetch origin && git log HEAD..origin/main --oneline
|
||||
|
||||
---
|
||||
|
||||
## 16. Регламент «мозга» (brain governance)
|
||||
|
||||
**Hard-rule статус**: рекомендация уровня §13 (transitive через ADR-011 enforcement); НЕ override-floor §9. См. §16.5.
|
||||
|
||||
### 16.1. Router-only архитектура
|
||||
|
||||
Маршрутизация «задача → узел/узлы» исполняется ровно одной процедурой — [`docs/router-procedure.md`](../router-procedure.md). Никакого каталога «проверенных цепочек» нет; каждая задача — свежая сборка. Подробности — spec `docs/superpowers/specs/2026-05-19-brain-governance-design.md` §4.
|
||||
|
||||
### 16.2. Observer (scope B)
|
||||
|
||||
В Stop-event сессии Claude инвокирует хук `tools/observer-stop-hook.mjs`, который записывает одну JSONL-строку в `docs/observer/episodes-YYYY-MM.jsonl`. Дополнительные MD-заметки — `docs/observer/notes/YYYY-MM-DD-<slug>.md`.
|
||||
|
||||
Запись ОБЯЗАНА содержать 5 полей: `task_id` / `timestamps` / `path_type` / `outcome` / `primary_rationale` (structured object с 7 sub-fields per spec §5.2.1). Структурированные события (`routing_decision` / `hook_fired` / `chain_divergence` / `skill_invoked` / `error` / `confusion_marker` / `time_burn`) — опционально в массиве `events[]`.
|
||||
|
||||
**ПДн-фильтр** через regex (phone `+7XXXXXXXXXX`, email `***@***`, токены gitleaks-style) — обязателен перед write.
|
||||
|
||||
**Граница**: observer **только пишет**, не правит нормативку. Решения принимаются вручную заказчиком через `/brain-retro` skill.
|
||||
|
||||
**Схема эпизода v2 (2026-05-19, factor-analysis extension):** эпизод несёт `schema_version: 2` и поля для факторного анализа — `decision_provenance` (кто выбрал узел), `environment` (`economy_level` / `model` / `post_compaction` / `session_turn` / `parallel_session`), `task_size`, `task_ref`, `prompt_signal`; `outcome` при записи — `unknown` (уточняется `/brain-retro` по сентименту следующей реплики). Виды событий расширены: `hook_fired` / `interrupt` / `retry` / `time_burn` / `parse_gap`. При внутреннем отказе хука пишется минимальный маркер `observer_error` вместо тихого пропуска. Spec — `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`.
|
||||
|
||||
`decision_provenance.kind` ∈ `autonomous` | `user_directed_method` | `user_chose_from_options` (phase 1.1, spec §11). `autonomous` — дефолт. `user_directed_method` — заказчик навязал метод извне (routing-тег). `user_chose_from_options` — collaborative-choice: заказчик выбрал один из вариантов, предложенных Claude в предыдущем ходе (детектор `tools/observer-choice-detector.mjs` — детерминированный, тег не нужен). Для `user_chose_from_options` контрфактуал (`claude_would_have_chosen`) — рекомендованная Claude опция (первая из предложенных).
|
||||
|
||||
### 16.3. 5 контролёров
|
||||
|
||||
| # | Имя | Что закрывает | Реализация |
|
||||
|---|---|---|---|
|
||||
| C1 | L1-watcher | settings.json ↔ Tooling drift | lefthook + GitHub Actions weekly |
|
||||
| C2 | Cross-ref consistency | version drift нормативных файлов | lefthook, regex |
|
||||
| C3 | Observer-of-observer | observer evidence-loop устаревает | counter + lefthook warn, 54-week self-prune |
|
||||
| C4 | STATUS.md | приборная панель | post-commit regen `docs/observer/STATUS.md` |
|
||||
| C5 | Observer-coverage-checker | пропуски наблюдателя + целостность регистрации | lefthook warn-only + STATUS.md |
|
||||
|
||||
Все 5 — механические, 0 LLM-вызовов в hot path.
|
||||
|
||||
### 16.4. Поведенческое правило «не использован ≠ проблема»
|
||||
|
||||
Узел «мозга», не задействованный на реальной задаче, **не** считается проблемой и **не** подлежит автоматической пометке. Это — capability-readiness, осознанная стратегия заказчика. См. `memory/feedback_brain_unused_tools_not_problem.md`.
|
||||
|
||||
**Исключение**: deprecated upstream-пакеты или физически сломанные инструменты (отдельная категория, `npm audit` / `composer outdated`).
|
||||
|
||||
### 16.5. Не override-floor §9
|
||||
|
||||
§16 — рекомендация tier-уровня §13, НЕ explicit hard-rule вне §9. Тремя hard-rules вне §9 остаются §12 (Superpowers), §14 (Ruflo Queen — dormant), §15 (параллельные сессии).
|
||||
|
||||
ADR-011 enforcement через `adr-judge` lefthook job гарантирует существование секции `## Enforcement` в самом ADR.
|
||||
|
||||
### 16.6. Cross-refs
|
||||
|
||||
- ADR-011 `docs/adr/ADR-011-brain-governance.md`
|
||||
- spec: `docs/superpowers/specs/2026-05-19-brain-governance-design.md`
|
||||
- spec (factor-analysis): `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`
|
||||
- plan: `docs/superpowers/plans/2026-05-19-brain-governance.md`
|
||||
- plan (factor-analysis): `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`
|
||||
- plan (factor-analysis phase 1.1): `docs/superpowers/plans/2026-05-19-observer-factor-analysis-phase-1-1.md`
|
||||
- procedure: `docs/router-procedure.md`
|
||||
- routing-table: `docs/routing-off-phase.md`
|
||||
- evidence: `docs/observer/`
|
||||
- memory: `feedback_brain_unused_tools_not_problem.md`, `project_brain_governance_design.md`
|
||||
|
||||
### 16.7. Routing-тег-дисциплина
|
||||
|
||||
Когда заказчик навязал конкретный метод/узел (директива `запусти X` / `используй X` / `через X` / `/команда`), Claude ОБЯЗАН в том же ходе эмитить routing-тег — одну строку-HTML-комментарий:
|
||||
|
||||
`<!-- routing: provenance=user_directed_method node=<выбранный> counterfactual=<узел, который Claude выбрал бы автономно> -->`
|
||||
|
||||
Enforcement — механический, не поведенческая просьба: `tools/observer-stop-hook.mjs` содержит routing-gate (`routingGateDecision` + `detectMethodDirected`). Детектор видит навязанный метод, тега нет → Stop-хук возвращает `decision: block`, и ход не завершается без тега. Это хук, а не tier-§13-правило — обойти рационализацией нельзя. Гейт срабатывает не более одного раза за ход (`stop_hook_active` guard против петли).
|
||||
|
||||
**Граница `user_chose_from_options` (phase 1.1):** routing-gate НЕ блокирует ход, классифицированный как `user_chose_from_options` — заказчик выбрал из вариантов, которые Claude сам же и предложил (collaborative-choice, не навязанный извне метод). Routing-тег для этого случая не обязателен: детектор `observer-choice-detector.mjs` восстанавливает провенанс детерминированно из транскрипта. Тег Claude может эмитить добровольно (для прозрачности), но Stop-хук его не требует.
|
||||
|
||||
### 16.8. Самодисциплина наблюдателя
|
||||
|
||||
Наблюдатель фиксирует каждый Stop без молчаливых пропусков:
|
||||
|
||||
- Внутренний отказ хука → строка-маркер `observer_error` в JSONL (не тихий `exit 0` без записи).
|
||||
- Доля непарсибельных строк транскрипта выше порога → событие `parse_gap`.
|
||||
- Контролёр **C5 observer-coverage-checker** (lefthook, warn-only) сверяет покрытие (git-активность без эпизодов) и целостность регистрации (Stop-хук в `.claude/settings.json`, `post-commit` установлен); расхождение — флаг в `docs/observer/STATUS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Что сделано после утверждения
|
||||
|
||||
Заказчик согласовал v1.1-DRAFT (короткий ответ «а» = вариант A: поправить §4.8 и шапку, выпустить v1.1) в сессии 05.05.2026. Claude выполнил:
|
||||
|
||||
+448
-3
File diff suppressed because one or more lines are too long
@@ -0,0 +1,102 @@
|
||||
---
|
||||
id: ADR-011
|
||||
title: Brain governance — router-only + observer + 4 mechanical controllers
|
||||
status: Accepted
|
||||
date: 2026-05-19
|
||||
related:
|
||||
- docs/superpowers/specs/2026-05-19-brain-governance-design.md
|
||||
- docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md
|
||||
- docs/discovery/2026-05-18-system-audit-brain.md
|
||||
- ADR-010 (HK1 hard-rule, hook collision pre-check)
|
||||
---
|
||||
|
||||
# ADR-011: Brain governance — router-only + observer + 4 mechanical controllers
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-05-19). **Amended 2026-05-19** — observer factor-analysis extension: episode schema v2, two-sided enforcement (routing-gate + C5 controller). See Decision §5.
|
||||
|
||||
## Context
|
||||
|
||||
The Лидерра «brain» (60 formal positions + 20 ruflo plugins per Tooling Прил. Н §0) accreted faster than it was regulated. SYSTEM-аудит 18.05.2026 (`docs/discovery/2026-05-18-system-audit-brain.md`) closed Rec1–Rec5; intervention session 19.05.2026 went deeper to design ongoing governance.
|
||||
|
||||
Three recurring problems were identified:
|
||||
|
||||
1. **L1-pattern**: plugin enabled in `~/.claude/settings.json` user-level without formalization in Tooling Прил. Н. Occurred 3× in 8 days (UPM/21st 10.05; Sentry/Redis 13.05; Anthropic dev-tooling 18.05).
|
||||
2. **Version drift** between 8 normative files. Tooling v2.11 collision 17.05.2026 — two parallel sessions consumed the same version number.
|
||||
3. **Speculative regulation ahead of usage**. Initial recommendation «prune unused» rejected by owner — capability-readiness is an explicit strategy.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Router-only
|
||||
|
||||
The brain has a single routing source of truth: the existing registry in [Tooling Прил. Н](../Tooling_v8_3.md) §4.X (extended with 9 obligatory attributes per spec §4.1) + the procedure in [`docs/router-procedure.md`](../router-procedure.md).
|
||||
|
||||
There is **no cache of «verified chains»**. There is **no 3-layer update mechanism**. There is **no forced-choice gate**. Every task is a fresh router-derived path.
|
||||
|
||||
Canonical chains L1–L12 in [`docs/routing-off-phase.md`](../routing-off-phase.md) remain as general-shape recommendations, not history-based records.
|
||||
|
||||
### 2. Observer (scope B, full package from day 1)
|
||||
|
||||
A passive Stop-event hook appends one JSONL line per session to `docs/observer/episodes-YYYY-MM.jsonl` and optionally a MD note in `docs/observer/notes/`. **Observer only writes; never intervenes.** PII-filter (gitleaks-like regex) is mandatory pre-write.
|
||||
|
||||
**Each episode has 5 mandatory fields** including a structured `primary_rationale` (7 sub-fields per spec §5.2.1: `step` / `node_chosen` / `triggers_matched` / `candidates_considered` / `boundaries_applied` / `hard_floor` / `task_classification`). Each individual router decision is also recorded as a `routing_decision` event in `events[]` (one per node-choice for chains). This enables **factor analysis** through `/brain-retro` — answers «which factors most often resolve conflicts between nodes X and Y» rather than just «node X used N times».
|
||||
|
||||
A `/brain-retro` skill aggregates evidence once per sprint and proposes regulatory candidates; the owner accepts or rejects manually.
|
||||
|
||||
### 3. 5 mechanical controllers
|
||||
|
||||
All 5 are mechanical (regex/diff/JSON math). 0 LLM calls in hot path.
|
||||
|
||||
- **C1 L1-watcher** — lefthook job + weekly cron. Detects plugins in `settings.json` not formalized in Tooling Прил. Н.
|
||||
- **C2 Cross-ref consistency** — lefthook job, regex-style (adr-judge analog). Detects version drift between normative files.
|
||||
- **C3 Observer-of-observer** — counter + lefthook warn. Self-prune through **54 weeks** without reads.
|
||||
- **C4 STATUS dashboard** — `docs/observer/STATUS.md`, regenerated per-commit.
|
||||
- **C5 Observer-coverage-checker** — lefthook warn-only job. Flags observer coverage gaps (git activity but 0 episodes) and registration-integrity breaks (Stop-hook missing from `settings.json`, `post-commit` not installed). Surfaced in STATUS.md.
|
||||
|
||||
### 4. Behavioral rule «unused ≠ problem»
|
||||
|
||||
The capability-readiness strategy is explicit. A node never used on a real task is **not** a problem and **not** an auto-removal candidate. Used-count is informational, never an alert. This rule overrides the analytical instinct to «prune unused».
|
||||
|
||||
Exception: deprecated upstream packages or physically broken tools (separate category — `npm audit` / `composer outdated`).
|
||||
|
||||
### 5. Observer factor-analysis extension (v2)
|
||||
|
||||
The observer episode is extended to `schema_version: 2` so a real factor analysis becomes possible: `decision_provenance` (autonomous vs user-dictated method, with a counterfactual), `environment` factors, `task_size`, `prompt_signal`, and an honest `outcome` of `unknown` at write time. Four layers — schema v2, deterministic capture + a routing-tag, two-sided enforcement (Stop-hook routing-gate + C5 self-discipline controller), `/brain-retro` analysis. The routing-gate makes provenance reliable: when the user dictates a method and the routing-tag is missing, the Stop-hook returns `decision: block`. Spec: `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Speculative regulation eliminated structurally — no chain catalog can drift.
|
||||
- Evidence-loop active from day 1 — owner has data for monthly/quarterly review.
|
||||
- 3 recurring problem classes (L1-pattern, version drift, evidence consumption) closed mechanically with 0 LLM cost.
|
||||
- Capability-readiness preserved — installed-but-unused tools are not flagged.
|
||||
|
||||
### Negative / risks
|
||||
|
||||
- 4 new lefthook jobs add ~1–2s to pre-commit.
|
||||
- Observer JSONL grows ~50–200KB/month; archival after 12 months is a manual task.
|
||||
- C3 54-week threshold is long — if observer infra is broken silently, detection waits up to a year. Mitigator: C4 STATUS.md shows weekly read-counter.
|
||||
|
||||
### Neutral
|
||||
|
||||
- The decision is reversible at low cost: removing controllers = `lefthook.yml` revert; removing observer = unregister Stop-hook + archive `docs/observer/`.
|
||||
|
||||
## Enforcement
|
||||
|
||||
- C1 / C2 / C3 lefthook jobs fail-fast on commit when invariants break.
|
||||
- C4 STATUS.md regeneration on post-commit (informational; not a gate).
|
||||
- Observer routing-gate runs inside `observer-stop-hook.mjs` (`decision: block` when a method is dictated without a routing-tag); C5 observer-coverage-checker is a warn-only lefthook job.
|
||||
- ADR-011 itself is enforced by **adr-judge** (lefthook job 9) — this section's existence is verified per-commit (regex `^## Enforcement$`).
|
||||
|
||||
## References
|
||||
|
||||
- spec: `docs/superpowers/specs/2026-05-19-brain-governance-design.md`
|
||||
- spec (extension): `docs/superpowers/specs/2026-05-19-observer-factor-analysis-design.md`
|
||||
- plan: `docs/superpowers/plans/2026-05-19-brain-governance.md`
|
||||
- plan (extension): `docs/superpowers/plans/2026-05-19-observer-factor-analysis.md`
|
||||
- ADR-010 (HK1 pre-check hard-rule)
|
||||
- Pravila §12 / §14 / §15 (hard-floor for router procedure step 1)
|
||||
- PSR_v1 R15 (off-phase routing extends to brain governance)
|
||||
- memory: `feedback_brain_unused_tools_not_problem.md`, `project_brain_governance_design.md`
|
||||
@@ -0,0 +1,601 @@
|
||||
// ════════════════════════════════════════════════════
|
||||
// automation-graph-data.js — shared topology constants
|
||||
// Consumed by:
|
||||
// • docs/automation-graph.html (classic <script>, reads bare consts via shared lexical scope)
|
||||
// • docs/observer/dashboard.html (classic <script>, same mechanism)
|
||||
// Do NOT add ES-module syntax (import/export) — keep as classic script.
|
||||
// ════════════════════════════════════════════════════
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 1: NODES
|
||||
// ════════════════════════════════════════════════════
|
||||
|
||||
// Радиально-секторная компоновка.
|
||||
// Сектора (по 90°): N=workflow (0–90), E=UI (90–180), S=infra (180–270), W=data/RLS (270–360).
|
||||
const RADII = [0, 220, 400, 600, 800, 1000, 1180];
|
||||
function pos(ring, angleDeg) {
|
||||
const r = RADII[ring];
|
||||
const a = angleDeg * Math.PI / 180;
|
||||
return { x: Math.round(r * Math.cos(a)), y: Math.round(r * Math.sin(a)) };
|
||||
}
|
||||
|
||||
const NODES = [
|
||||
// ── ПРАВИЛА (5) ── центр + первое кольцо ───────
|
||||
{ id: 'pravila', label: 'Pravila v1.33', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.20', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.17', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.17', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
{ id: 'router_procedure', label: 'router-procedure v1.0', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
|
||||
|
||||
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
|
||||
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
|
||||
{ id: 'fd_plugin', label: 'Frontend Design', group: 'plugins', size: 26, ring: 2, ...pos(2, 135) },
|
||||
{ id: 'upm', label: 'UI UX Pro Max', group: 'plugins', size: 22, ring: 2, ...pos(2, 165) },
|
||||
{ id: 'claude_md_mgmt', label: 'claude-md-mgmt', group: 'plugins', size: 22, ring: 2, ...pos(2, 225) },
|
||||
{ id: 'hookify_plugin', label: 'hookify (плагин)', group: 'plugins', size: 22, ring: 2, ...pos(2, 200) },
|
||||
{ id: 'skill_creator', label: 'skill-creator', group: 'plugins', size: 20, ring: 2, ...pos(2, 70) },
|
||||
{ id: 'claude_setup', label: 'claude-code-setup', group: 'plugins', size: 22, ring: 2, ...pos(2, 90) },
|
||||
{ id: 'plugin_dev', label: 'plugin-dev', group: 'plugins', size: 22, ring: 2, ...pos(2, 290) },
|
||||
{ id: 'context7', label: 'context7 (docs MCP)', group: 'plugins', size: 20, ring: 2, ...pos(2, 315) },
|
||||
// A6 architecture-tooling — adr-kit / architecture-patterns (плагины) + deptrac (composer dev-dep, job 10) — раздел «Архитектура систем»
|
||||
{ id: 'adr_kit', label: 'adr-kit', group: 'plugins', size: 22, ring: 2, ...pos(2, 240) },
|
||||
{ id: 'arch_patterns', label: 'architecture-patterns',group: 'plugins', size: 20, ring: 2, ...pos(2, 250) },
|
||||
{ id: 'deptrac', label: 'deptrac', group: 'plugins', size: 20, ring: 2, ...pos(2, 260) },
|
||||
// D3 audit-security (17.05.2026) — 2 плагина раздела «Аудит и управление рисками»
|
||||
{ id: 'tob_skills', label: 'Trail of Bits\nskills', group: 'plugins', size: 22, ring: 2, ...pos(2, 330) },
|
||||
{ id: 'sec_guidance', label: 'Security\nGuidance', group: 'plugins', size: 20, ring: 2, ...pos(2, 345) },
|
||||
// C9 project-management-tooling (17.05.2026) — плагин раздела «Управление проектами»
|
||||
{ id: 'product_mgmt', label: 'product-\nmanagement', group: 'plugins', size: 20, ring: 2, ...pos(2, 355) },
|
||||
// A4 design-tooling (17.05.2026) — раздел «Дизайн (UI/UX, графика, бренд)» (плагины)
|
||||
{ id: 'design_plugin', label: 'Design\nplugin', group: 'plugins', size: 20, ring: 2, ...pos(2, 155) },
|
||||
|
||||
// ── СКИЛЫ SUPERPOWERS (14) — N sector (0–90) ────
|
||||
{ id: 'sk_brainstorm', label: 'brainstorming', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 5) },
|
||||
{ id: 'sk_wplans', label: 'writing-plans', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 11) },
|
||||
{ id: 'sk_eplans', label: 'executing-plans', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 17) },
|
||||
{ id: 'sk_subagent', label: 'subagent-driven', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 23) },
|
||||
{ id: 'sk_tdd', label: 'TDD', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 29) },
|
||||
{ id: 'sk_verify', label: 'verification-before-completion', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 36) },
|
||||
{ id: 'sk_debug', label: 'systematic-debugging', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 43) },
|
||||
{ id: 'sk_parallel', label: 'parallel-work', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 50) },
|
||||
{ id: 'sk_worktree', label: 'worktree', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 57) },
|
||||
{ id: 'sk_pr', label: 'finishing-pr', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 64) },
|
||||
{ id: 'sk_coderev', label: 'code-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 71) },
|
||||
{ id: 'sk_spreview', label: 'spec-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 78) },
|
||||
{ id: 'sk_wskills', label: 'writing-skills', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 85) },
|
||||
{ id: 'sk_elements', label: 'elements-of-style', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 92) },
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА (6) — W sector (RLS/arch/audit) ────
|
||||
{ id: 'sk_rls', label: 'rls-check', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 305) },
|
||||
{ id: 'sk_qitem', label: 'q-item-add', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 220) },
|
||||
{ id: 'sk_regression', label: 'regression', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 260) },
|
||||
// A6 architecture-tooling (17.05.2026) — вендоренный скил диаграмм
|
||||
{ id: 'mermaid_skill', label: 'mermaid (skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 280) },
|
||||
// D3 audit-security (17.05.2026) — скилы раздела «Аудит и управление рисками»
|
||||
{ id: 'sk_security_review', label: 'security-review', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 315) },
|
||||
{ id: 'sk_audit_portal', label: 'audit-portal', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 325) },
|
||||
// C9 project-management-tooling (17.05.2026) — вендоренный скил раздела «Управление проектами»
|
||||
{ id: 'ccpm', label: 'CCPM\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 335) },
|
||||
// A11 ml-ai-tooling (17.05.2026) — скилы и CLI раздела «ML / AI-разработка»
|
||||
{ id: 'claude_api', label: 'claude-api\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 345) },
|
||||
{ id: 'data_scientist', label: 'Data Scientist\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 355) },
|
||||
{ id: 'promptfoo', label: 'promptfoo', group: 'plugins', size: 20, ring: 2, ...pos(2, 365) },
|
||||
// C10 business-process (17.05.2026) — плагин и скилы раздела «Бизнес-процессы (общее)»
|
||||
{ id: 'ops_plugin', label: 'operations\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 385) },
|
||||
{ id: 'process_modeling', label: 'process-modeling\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 367) },
|
||||
{ id: 'process_analysis', label: 'process-analysis\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 377) },
|
||||
// discovery-tooling (18.05.2026) — self-authored скил интервью-discovery
|
||||
{ id: 'discovery_interview', label: 'discovery-interview\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 387) },
|
||||
// brain governance iter9 (19.05.2026) — проектный скил факторного анализа
|
||||
{ id: 'sk_brain_retro', label: '/brain-retro\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 210) },
|
||||
|
||||
// ── ХУКИ (13) — S+infra + E (economy/skill/brain) ───
|
||||
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
|
||||
{ id: 'hk_economy', label: 'UserPromptSubmit:\neconomy-mode', group: 'hooks', size: 22, ring: 4, ...pos(4, 95) },
|
||||
{ id: 'hk_pre_claude', label: 'PreToolUse:\nCLAUDE.md-warn', group: 'hooks', size: 22, ring: 4, ...pos(4, 215) },
|
||||
{ id: 'hk_post_md', label: 'PostToolUse:\nmarkdownlint', group: 'hooks', size: 20, ring: 4, ...pos(4, 195) },
|
||||
{ id: 'hk_post_schema', label: 'PostToolUse:\nschema-changelog',group: 'hooks', size: 20, ring: 4, ...pos(4, 300) },
|
||||
{ id: 'hk_self_check', label: 'SessionStart:\neconomy-self-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 105) },
|
||||
{ id: 'hk_skill_marker', label: 'PreToolUse:\nskill-marker', group: 'hooks', size: 20, ring: 4, ...pos(4, 115) },
|
||||
{ id: 'hk_skill_check', label: 'PreToolUse:\nskill-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 125) },
|
||||
{ id: 'hk_state_guard', label: 'PreToolUse:\neconomy-state-guard', group: 'hooks', size: 20, ring: 4, ...pos(4, 135) },
|
||||
{ id: 'hk_postcompact', label: 'PostCompact:\neconomy-postcompact', group: 'hooks', size: 20, ring: 4, ...pos(4, 145) },
|
||||
{ id: 'hk_verifier', label: 'Stop:\neconomy-verifier (агент)', group: 'hooks', size: 22, ring: 4, ...pos(4, 155) },
|
||||
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: 'ruflo', size: 20, ring: 4, ...pos(4, 165) },
|
||||
// brain governance iter9 (19.05.2026) — Stop-хук observer
|
||||
{ id: 'observer_stophook', label: 'Stop:\nobserver-stop-hook', group: 'hooks', size: 22, ring: 4, ...pos(4, 205) },
|
||||
|
||||
// ── АГЕНТЫ (11) — N (workflow) + W (RLS) ──────
|
||||
{ id: 'ag_explore', label: 'Explore', group: 'agents', size: 20, ring: 4, ...pos(4, 10) },
|
||||
{ id: 'ag_general', label: 'general-purpose', group: 'agents', size: 20, ring: 4, ...pos(4, 25) },
|
||||
{ id: 'ag_plan', label: 'Plan', group: 'agents', size: 20, ring: 4, ...pos(4, 40) },
|
||||
{ id: 'ag_pest', label: 'pest-parallel-debugger', group: 'agents', size: 24, ring: 4, ...pos(4, 55) },
|
||||
{ id: 'ag_guide', label: 'claude-code-guide', group: 'agents', size: 18, ring: 4, ...pos(4, 70) },
|
||||
{ id: 'ag_statusline', label: 'statusline-setup', group: 'agents', size: 18, ring: 4, ...pos(4, 85) },
|
||||
{ id: 'ag_hookify', label: 'hookify:\nconversation-analyzer', group: 'agents', size: 18, ring: 4, ...pos(4, 230) },
|
||||
{ id: 'ag_pcreator', label: 'plugin-dev:\nagent-creator', group: 'agents', size: 16, ring: 4, ...pos(4, 245) },
|
||||
{ id: 'ag_pvalid', label: 'plugin-dev:\nplugin-validator',group: 'agents', size: 16, ring: 4, ...pos(4, 260) },
|
||||
{ id: 'ag_skreview', label: 'plugin-dev:\nskill-reviewer', group: 'agents', size: 16, ring: 4, ...pos(4, 275) },
|
||||
{ id: 'ag_rls', label: 'rls-reviewer', group: 'agents', size: 22, ring: 4, ...pos(4, 315) },
|
||||
// A3 integration-tooling (17.05.2026) — agent раздела «Программирование — интеграции»
|
||||
{ id: 'ag_apidocs', label: 'api-docs (agent)', group: 'agents', size: 18, ring: 4, ...pos(4, 175) },
|
||||
|
||||
// ── MCP-СЕРВЕРЫ (9) — E (UI) + W (data) ───────
|
||||
{ id: 'mcp_21st', label: 'MCP: 21st.dev Magic', group: 'mcp', size: 20, ring: 5, ...pos(5, 130) },
|
||||
// A4 design-tooling (17.05.2026) — MCP-серверы раздела «Дизайн (UI/UX, графика, бренд)»
|
||||
{ id: 'mcp_figma', label: 'MCP: Figma\n(DEFERRED)', group: 'mcp', size: 18, ring: 5, ...pos(5, 140) },
|
||||
{ id: 'mcp_icons', label: 'MCP: Universal\nIcons', group: 'mcp', size: 18, ring: 5, ...pos(5, 120) },
|
||||
{ id: 'mcp_pw', label: 'MCP: playwright', group: 'mcp', size: 22, ring: 5, ...pos(5, 110) },
|
||||
{ id: 'mcp_gh', label: 'MCP: github', group: 'mcp', size: 22, ring: 5, ...pos(5, 75) },
|
||||
{ id: 'mcp_boost', label: 'MCP: laravel-boost', group: 'mcp', size: 24, ring: 5, ...pos(5, 290) },
|
||||
{ id: 'mcp_redis', label: 'MCP: redis', group: 'mcp', size: 22, ring: 5, ...pos(5, 310) },
|
||||
{ id: 'mcp_sentry', label: 'MCP: sentry', group: 'mcp', size: 22, ring: 5, ...pos(5, 330) },
|
||||
{ id: 'mcp_semgrep', label: 'MCP: semgrep', group: 'mcp', size: 20, ring: 5, ...pos(5, 350) },
|
||||
// A3 integration-tooling (17.05.2026) — MCP-сервер раздела «Программирование — интеграции»
|
||||
{ id: 'mcp_openapi', label: 'MCP: openapi', group: 'mcp', size: 20, ring: 5, ...pos(5, 5) },
|
||||
|
||||
// ── LEFTHOOK JOBS (15) — S+W (infra/data/brain) ─────
|
||||
{ id: 'lh_mdlint', label: 'lefthook:\nmarkdownlint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 185) },
|
||||
{ id: 'lh_cspell', label: 'lefthook:\ncspell', group: 'lefthook', size: 18, ring: 5, ...pos(5, 200) },
|
||||
{ id: 'lh_stylelint', label: 'lefthook:\nstylelint', group: 'lefthook', size: 16, ring: 5, ...pos(5, 215) },
|
||||
{ id: 'lh_eslint', label: 'lefthook:\neslint-vue', group: 'lefthook', size: 18, ring: 5, ...pos(5, 230) },
|
||||
{ id: 'lh_lychee', label: 'lefthook:\nlychee-links', group: 'lefthook', size: 18, ring: 5, ...pos(5, 245) },
|
||||
{ id: 'lh_gitleaks', label: 'lefthook:\ngitleaks', group: 'lefthook', size: 18, ring: 5, ...pos(5, 260) },
|
||||
{ id: 'lh_gitleaks2', label: 'lefthook:\ngitleaks pre-push', group: 'lefthook', size: 18, ring: 5, ...pos(5, 275) },
|
||||
{ id: 'lh_pint', label: 'lefthook:\npint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 25) },
|
||||
{ id: 'lh_larastan', label: 'lefthook:\nlarastan', group: 'lefthook', size: 18, ring: 5, ...pos(5, 50) },
|
||||
{ id: 'lh_squawk', label: 'lefthook:\nsquawk', group: 'lefthook', size: 18, ring: 5, ...pos(5, 320) },
|
||||
// brain governance iter9 (19.05.2026) — 5 контролёров C1-C5 (lefthook jobs 11-15)
|
||||
{ id: 'lh_l1watcher', label: 'lefthook:\nl1-watcher (C1)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 150) },
|
||||
{ id: 'lh_crossref', label: 'lefthook:\ncross-ref-checker (C2)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 157) },
|
||||
{ id: 'lh_obs_obs', label: 'lefthook:\nobserver-of-observer (C3)',group: 'lefthook', size: 16, ring: 5, ...pos(5, 164) },
|
||||
{ id: 'lh_status_md', label: 'lefthook:\nstatus-md (C4)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 171) },
|
||||
{ id: 'lh_obs_cov', label: 'lefthook:\nobserver-coverage (C5)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 178) },
|
||||
|
||||
// ── MEMORY FILES (24) — внешнее кольцо ──────────
|
||||
{ id: 'mem_user', label: 'memory:\nuser_profile', group: 'memory', size: 16, ring: 6, ...pos(6, 0) },
|
||||
{ id: 'mem_comm', label: 'memory:\nfeedback_comm', group: 'memory', size: 14, ring: 6, ...pos(6, 24) },
|
||||
{ id: 'mem_env', label: 'memory:\nfeedback_env', group: 'memory', size: 16, ring: 6, ...pos(6, 48) },
|
||||
{ id: 'mem_sp', label: 'memory:\nfeedback_superpowers',group: 'memory', size: 16, ring: 6, ...pos(6, 72) },
|
||||
{ id: 'mem_plugins', label: 'memory:\nfeedback_plugins', group: 'memory', size: 16, ring: 6, ...pos(6, 96) },
|
||||
{ id: 'mem_handoff', label: 'memory:\nreference_handoff', group: 'memory', size: 14, ring: 6, ...pos(6, 120) },
|
||||
{ id: 'mem_redesign', label: 'memory:\nportal_redesign', group: 'memory', size: 14, ring: 6, ...pos(6, 144) },
|
||||
{ id: 'mem_devindices', label: 'memory:\ndev_indices', group: 'memory', size: 12, ring: 6, ...pos(6, 168) },
|
||||
{ id: 'mem_phase1', label: 'memory:\nphase1_strategy', group: 'memory', size: 14, ring: 6, ...pos(6, 192) },
|
||||
{ id: 'mem_state', label: 'memory:\nproject_state', group: 'memory', size: 16, ring: 6, ...pos(6, 216) },
|
||||
{ id: 'mem_brain', label: 'memory:\nclaude_brain', group: 'memory', size: 14, ring: 6, ...pos(6, 240) },
|
||||
{ id: 'mem_supplier', label: 'memory:\nsupplier_integration',group: 'memory', size: 14, ring: 6, ...pos(6, 264) },
|
||||
{ id: 'mem_audit', label: 'memory:\naudit_2026-05-13', group: 'memory', size: 14, ring: 6, ...pos(6, 288) },
|
||||
{ id: 'mem_archive', label: 'memory:\nreference_archive', group: 'memory', size: 14, ring: 6, ...pos(6, 312) },
|
||||
{ id: 'mem_github', label: 'memory:\nreference_github', group: 'memory', size: 14, ring: 6, ...pos(6, 336) },
|
||||
{ id: 'mem_audit_b', label: 'memory:\naudit_B_status', group: 'memory', size: 12, ring: 6, ...pos(6, 12) },
|
||||
{ id: 'mem_audit_c', label: 'memory:\naudit_C_pending', group: 'memory', size: 12, ring: 6, ...pos(6, 36) },
|
||||
{ id: 'mem_suppliercrm',label: 'memory:\nsupplier_crm', group: 'memory', size: 12, ring: 6, ...pos(6, 60) },
|
||||
{ id: 'mem_audit12', label: 'memory:\nfull_audit_05-12', group: 'memory', size: 12, ring: 6, ...pos(6, 84) },
|
||||
{ id: 'mem_audit14', label: 'memory:\nfull_audit_05-14', group: 'memory', size: 12, ring: 6, ...pos(6, 108) },
|
||||
{ id: 'mem_sprint1', label: 'memory:\nsprint1_p0_closure', group: 'memory', size: 12, ring: 6, ...pos(6, 132) },
|
||||
{ id: 'mem_sprint2', label: 'memory:\nsprint2_p1_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 156) },
|
||||
{ id: 'mem_sprint3', label: 'memory:\nsprint3_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 180) },
|
||||
// brain governance iter9 (19.05.2026) — хранилище evidence «мозга»
|
||||
{ id: 'observer_evidence', label: 'docs/observer/\nepisodes+STATUS', group: 'memory', size: 16, ring: 6, ...pos(6, 204) },
|
||||
|
||||
// ── RUFLO ОРКЕСТРАТОР (9) — фактический реколлаж iter5 — кластер вне радиального layout (верх-лево) ──
|
||||
{ id: 'ruflo_queen', label: 'ruflo Queen\n(hive-mind)', group: 'ruflo', size: 44, x: -1340, y: -700 },
|
||||
{ id: 'ruflo_plugins', label: 'плагины ruflo\n0 из 20 · скилов 0', group: 'ruflo', size: 20, x: -1340, y: -880 },
|
||||
{ id: 'ruflo_workers', label: '10 воркеров\nhive-mind (idle)', group: 'ruflo', size: 26, x: -1160, y: -800 },
|
||||
{ id: 'ruflo_agents_catalog', label: 'каталог агентов ruflo\n(100 определений)', group: 'ruflo', size: 24, x: -1530, y: -830 },
|
||||
{ id: 'ruflo_commands', label: 'slash-команды\nruflo (88)', group: 'ruflo', size: 22, x: -1140, y: -630 },
|
||||
{ id: 'ruflo_daemon', label: 'демон ruflo\n(воркеры падают)', group: 'ruflo', size: 24, x: -1560, y: -650 },
|
||||
{ id: 'ruflo_memory', label: 'память ruflo\n(~0 записей)', group: 'ruflo', size: 24, x: -1380, y: -500 },
|
||||
{ id: 'ruflo_mcp', label: 'ruflo MCP\n(~210 инструментов)', group: 'ruflo', size: 26, x: -1190, y: -460 },
|
||||
{ id: 'ruflo_recall_hook', label: 'хук recall\n(UserPromptSubmit)', group: 'ruflo', size: 22, x: -1570, y: -470 },
|
||||
|
||||
// ── MEMORY +1 (артефакт ruflo big-bang) ──
|
||||
{ id: 'mem_ruflo', label: 'memory:\nproject_ruflo_integration', group: 'memory', size: 14, x: -1740, y: -620 },
|
||||
];
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 2: EDGES
|
||||
// ════════════════════════════════════════════════════
|
||||
const CONFLICT_TYPES = {
|
||||
RED: { color: '#ff5f57', bg: '#2d0000', emoji: '🔴', label: 'Не закрыт правилом', rank: 1 },
|
||||
BLACK: { color: '#888888', bg: '#1a1a1a', emoji: '⚫', label: 'Возник на практике', rank: 2 },
|
||||
GREEN: { color: '#859900', bg: '#0e1a00', emoji: '🟢', label: 'Закрыт правилом', rank: 3 },
|
||||
};
|
||||
const E = (from, to, label) => ({
|
||||
from, to,
|
||||
title: label,
|
||||
color: { color: '#586e75', highlight: '#93a1a1', hover: '#93a1a1' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.6 } },
|
||||
smooth: { type: 'continuous', roundness: 0.5 }
|
||||
});
|
||||
const CONFLICT = (from, to, label, type = 'RED') => ({
|
||||
from, to,
|
||||
title: label,
|
||||
label: CONFLICT_TYPES[type].emoji,
|
||||
dashes: true,
|
||||
width: 2,
|
||||
color: { color: CONFLICT_TYPES[type].color, highlight: '#ff8880', hover: '#ff8880' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.7 }, from: { enabled: true, scaleFactor: 0.7 } },
|
||||
font: { color: CONFLICT_TYPES[type].color, size: 14, align: 'middle', strokeWidth: 3, strokeColor: '#1e1e2e' },
|
||||
smooth: { type: 'curvedCW', roundness: 0.35 }
|
||||
});
|
||||
|
||||
const EDGES = [
|
||||
// ── ПРАВИЛА — иерархия ──────────────────────────
|
||||
E('pravila', 'claude_md', 'подчиняет\n(уровень 1→2a)'),
|
||||
E('pravila', 'psr_v1', 'подчиняет\n(уровень 1→3)'),
|
||||
E('claude_md', 'tooling', 'ссылается\nна реестр'),
|
||||
E('pravila', 'superpowers', '§12: обязывает\nинвокировать 1-м'),
|
||||
|
||||
// ── PSR_v1 координирует плагины ─────────────────
|
||||
E('psr_v1', 'superpowers', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'fd_plugin', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'upm', 'R14.3: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'mcp_21st', 'R14.4: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'claude_md_mgmt','R10.1 блок 1:\nинфраструктурный'),
|
||||
|
||||
// ── CLAUDE.md ────────────────────────────────────
|
||||
E('claude_md', 'mcp_boost', 'описывает §3.2'),
|
||||
E('claude_md', 'mcp_sentry', 'описывает §4.8'),
|
||||
E('claude_md', 'mcp_redis', 'описывает §4.9'),
|
||||
E('claude_md', 'claude_md_mgmt', '§5п.10:\nединственный канал'),
|
||||
E('claude_md', 'ag_pest', 'описывает\nкогда вызывать'),
|
||||
E('claude_md', 'ag_rls', 'описывает\nкогда вызывать'),
|
||||
|
||||
// ── ХУКИ ────────────────────────────────────────
|
||||
E('hk_pre_claude', 'claude_md', 'проверяет\nпри Edit/Write'),
|
||||
E('hk_post_md', 'lh_mdlint', 'дублирует задачу\n(локально)'),
|
||||
E('hk_post_schema', 'claude_md', 'напоминает про\nCHANGELOG_schema'),
|
||||
E('hk_session', 'mem_user', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_env', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_sp', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_plugins', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_state', 'читает\nпри старте'),
|
||||
E('hk_economy', 'superpowers', 'парсит уровень\nэкономии'),
|
||||
|
||||
// ── SUPERPOWERS содержит скилы ──────────────────
|
||||
E('superpowers', 'sk_brainstorm', 'содержит'),
|
||||
E('superpowers', 'sk_tdd', 'содержит'),
|
||||
E('superpowers', 'sk_debug', 'содержит'),
|
||||
E('superpowers', 'sk_wplans', 'содержит'),
|
||||
E('superpowers', 'sk_eplans', 'содержит'),
|
||||
E('superpowers', 'sk_verify', 'содержит'),
|
||||
E('superpowers', 'sk_parallel', 'содержит'),
|
||||
E('superpowers', 'sk_worktree', 'содержит'),
|
||||
E('superpowers', 'sk_pr', 'содержит'),
|
||||
E('superpowers', 'sk_subagent', 'содержит'),
|
||||
E('superpowers', 'sk_wskills', 'содержит'),
|
||||
E('superpowers', 'sk_spreview', 'содержит'),
|
||||
E('superpowers', 'sk_coderev', 'содержит'),
|
||||
E('superpowers', 'sk_elements', 'содержит'),
|
||||
|
||||
// ── СКИЛЫ вызывают друг друга ───────────────────
|
||||
E('sk_brainstorm', 'sk_wplans', 'вызывает\nпосле дизайна'),
|
||||
E('sk_wplans', 'sk_eplans', 'вызывает\nдля выполнения'),
|
||||
E('sk_wplans', 'sk_subagent','альтернатива\nexecuting-plans'),
|
||||
E('sk_subagent', 'ag_explore', 'запускает\nдля поиска'),
|
||||
E('sk_subagent', 'ag_general', 'запускает\nдля задач'),
|
||||
E('sk_subagent', 'ag_plan', 'запускает\nдля архитектуры'),
|
||||
E('sk_parallel', 'sk_worktree','использует\nдля изоляции'),
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА ───────────────────────────────
|
||||
E('sk_rls', 'tooling', 'использует\nsquawk + grep §3.2'),
|
||||
E('sk_rls', 'mcp_boost', 'SQL запросы\nк схеме'),
|
||||
E('sk_qitem', 'claude_md_mgmt','делегирует\nправку CLAUDE.md'),
|
||||
|
||||
// ── CLAUDE-MD-MGMT ──────────────────────────────
|
||||
E('claude_md_mgmt', 'claude_md', 'единственный\nканал правок'),
|
||||
|
||||
// ── HOOKIFY ─────────────────────────────────────
|
||||
E('ag_hookify', 'hookify_plugin', 'передаёт\nанализ'),
|
||||
E('hookify_plugin', 'hk_pre_claude', 'может создавать\nновые хуки'),
|
||||
E('hookify_plugin', 'hk_economy', 'может создавать\nновые хуки'),
|
||||
|
||||
// ── АГЕНТЫ используют MCP ───────────────────────
|
||||
E('ag_pest', 'mcp_redis', 'читает\nочереди/кэш'),
|
||||
E('ag_rls', 'mcp_boost', 'SQL запросы\nк БД'),
|
||||
E('ag_guide', 'mcp_gh', 'ищет\nв репозитории'),
|
||||
|
||||
// ── LEFTHOOK вызывается git ──────────────────────
|
||||
E('lh_gitleaks', 'mem_plugins', 'блокирует коммит\nпри ПДн в staged'),
|
||||
E('lh_larastan', 'mcp_boost', 'Boost даёт\nконтекст типов'),
|
||||
E('lh_squawk', 'tooling', 'соответствует\n§3.2 #15'),
|
||||
E('lh_gitleaks2', 'lh_gitleaks', 'строже:\nвся история'),
|
||||
E('lh_lychee', 'claude_md', 'проверяет\nссылки в .md'),
|
||||
|
||||
// ── MEMORY читается Claude ──────────────────────
|
||||
E('mem_env', 'ag_pest', 'квирки 73/77\nиспользует агент'),
|
||||
E('mem_plugins', 'psr_v1', 'отражает\nтекущие версии'),
|
||||
E('mem_archive', 'claude_md', 'синхронизирует\nверсии доков'),
|
||||
|
||||
// ── MCP ─────────────────────────────────────────
|
||||
E('mcp_pw', 'hk_session', 'используется\nдля a11y smoke'),
|
||||
E('mcp_gh', 'sk_pr', 'PR, issues\nпри finishing-pr'),
|
||||
E('mcp_boost', 'ag_rls', 'схема БД\nдля RLS-review'),
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — связи новых узлов ──
|
||||
// 4 ребра psr_v1→skill_creator/claude_setup/plugin_dev/context7 — перенесены
|
||||
// в ADT-блок 18.05.2026 (точные категории authoring-tooling/dev-support, дедуп)
|
||||
E('plugin_dev', 'ag_pcreator', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_pvalid', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_skreview', 'содержит\nагента'),
|
||||
E('skill_creator', 'sk_wskills', 'обе создают\nскилы'),
|
||||
E('hk_self_check', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_skill_marker', 'hk_skill_check', 'пара\nmarker/check'),
|
||||
E('hk_skill_check', 'superpowers', 'энфорсит §12:\nскил перед кодом'),
|
||||
E('hk_state_guard', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_postcompact', 'hk_economy', 'переинжект\nрежима после компакта'),
|
||||
E('hk_verifier', 'sk_verify', 'энфорсит\nпроверку готовности'),
|
||||
E('hk_ruflo_queen', 'ruflo_queen', '§14: маршрут\nqueen-задач'),
|
||||
E('sk_regression', 'ag_pest', 'передаёт разбор\nпадений Pest --parallel'),
|
||||
|
||||
// ── A6 ARCHITECTURE-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'adr_kit', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('psr_v1', 'arch_patterns', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('tooling', 'mermaid_skill', '§4.12: реестр\n(вендоренный скил)'),
|
||||
E('psr_v1', 'deptrac', 'R10.1 блок 1 note:\narchitecture-tooling'),
|
||||
|
||||
// ── A4 DESIGN-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'design_plugin', 'R10.1 блок 1:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_icons', 'R10.1 блок 3:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_figma', 'R10.1 блок 3:\ndesign-tooling (DEFERRED)'),
|
||||
|
||||
// ── D3 AUDIT-SECURITY 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'tob_skills', 'R10.1 блок 1:\naudit-security'),
|
||||
E('psr_v1', 'sec_guidance', 'R10.1 блок 1:\naudit-security'),
|
||||
E('tooling', 'tob_skills', '§4.14 #39 — реестр'),
|
||||
E('tooling', 'sec_guidance', '§4.15 #40 — реестр'),
|
||||
E('sk_audit_portal', 'sk_security_review', 'оркеструет\nкак фазу аудита'),
|
||||
E('sk_audit_portal', 'tob_skills', 'оркеструет\nглубокие кампании'),
|
||||
E('sk_audit_portal', 'sk_regression', 'использует\nна фазе тестов'),
|
||||
CONFLICT('tob_skills', 'mcp_semgrep', 'TB1: граница разграничена — Semgrep = inline SAST, Trail of Bits = глубокие on-demand аудит-кампании. Параллельное использование разрешено при разных сценариях.', 'GREEN'),
|
||||
|
||||
// ── A3 INTEGRATION-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'mcp_openapi', 'R10.1 блок 3:\nintegration-tooling'),
|
||||
E('tooling', 'mcp_openapi', '§4.22 #47 — реестр'),
|
||||
E('ag_apidocs', 'mcp_openapi', 'спека → MCP-ресурс'),
|
||||
|
||||
// ── A11 ML-AI-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'promptfoo', 'R10.1 блок 1:\nml-ai-tooling'),
|
||||
E('tooling', 'claude_api', 'reuse — built-in skill\n(PSR_v1 R10.1 блок 2)'),
|
||||
E('tooling', 'data_scientist', '§4.24 #49 — реестр'),
|
||||
|
||||
// ── C10 BUSINESS-PROCESS 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'ops_plugin', 'R10.1 блок 1:\nbusiness-process'),
|
||||
E('tooling', 'process_modeling', '§4.27 #52 — реестр'),
|
||||
E('tooling', 'process_analysis', '§4.28 #53 — реестр'),
|
||||
|
||||
// ── DISCOVERY-TOOLING 18.05.2026 — связи узла discovery-interview ──
|
||||
E('tooling', 'discovery_interview', '§4.30 #55 — реестр'),
|
||||
E('psr_v1', 'discovery_interview', 'R10.1 блок 1 note:\ndiscovery-tooling'),
|
||||
E('discovery_interview', 'sk_brainstorm', 'хэндофф:\nFEATURE-brief'),
|
||||
E('discovery_interview', 'process_analysis', 'граница: слой-источник\n(ADR-009 DI2)'),
|
||||
|
||||
// ── ANTHROPIC DEV-TOOLING 18.05.2026 — связи 5 узлов ──
|
||||
E('psr_v1', 'skill_creator', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'plugin_dev', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'hookify_plugin', 'R10.1 блок 1:\nauthoring-tooling (HK1)'),
|
||||
E('psr_v1', 'claude_setup', 'R10.1 блок 1:\ndev-support'),
|
||||
E('psr_v1', 'context7', 'R10.1 блок 1:\ndev-support'),
|
||||
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) — связи 9 новых узлов ──
|
||||
E('claude_md', 'router_procedure', '§3.6: SoT\nпроцедуры роутера'),
|
||||
E('tooling', 'router_procedure', '§4.X реестр →\nшаг 3 роутера'),
|
||||
E('pravila', 'router_procedure', '§12/§14/§15\nhard-floor'),
|
||||
E('pravila', 'observer_stophook', '§16: observer\n+ routing-тег'),
|
||||
E('observer_stophook', 'observer_evidence', 'пишет эпизоды\n+ routing-gate'),
|
||||
E('pravila', 'sk_brain_retro', '§16: факторный\nанализ раз в спринт'),
|
||||
E('sk_brain_retro', 'observer_evidence', 'читает эпизоды\n(факторный анализ)'),
|
||||
E('lh_l1watcher', 'tooling', 'C1 STRICT: settings.json\n↔ Tooling drift'),
|
||||
E('lh_crossref', 'claude_md', 'C2 STRICT: version\ndrift §0 cross-refs'),
|
||||
E('lh_obs_obs', 'observer_evidence', 'C3 warn: счётчик\n+54w self-prune'),
|
||||
E('lh_status_md', 'observer_evidence', 'C4: генерит\nSTATUS.md'),
|
||||
E('lh_obs_cov', 'observer_evidence', 'C5 warn: покрытие\n+ регистрация'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
|
||||
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
|
||||
// ══════════════════════════════════════════════════
|
||||
CONFLICT('sk_rls', 'ag_rls', 'RLS: граница задана — скил по таблице, агент по diff/PR (spec 2026-05-16)', 'GREEN'),
|
||||
CONFLICT('hookify_plugin', 'hk_pre_claude', 'Закрыто правилом HK1 (ADR-010, PSR_v1 R10.1 v3.14): hookify вызывается только по явному /hookify + обязательный pre-check на коллизию с зарегистрированными хуками; перезапись economy/skill-discipline архитектуры запрещена', 'GREEN'),
|
||||
CONFLICT('mcp_pw', 'sk_parallel', 'Профиль Playwright MCP хэшируется per-cwd (квирк #95) → worktrees получают разные mcp-chrome-{hash}, не конфликтуют. Same-dir parallel — редкий случай (две Claude-сессии в одной dir), регулируется Pravila §15.2 claim в docs/sessions/CURRENT.md', 'GREEN'),
|
||||
CONFLICT('ag_pest', 'mcp_redis', 'Квирк 72 устранён 16.05.2026 (commit 0fa1a73 — array-стор в тестах): гонки в Redis при Pest --parallel больше нет', 'GREEN'),
|
||||
CONFLICT('psr_v1', 'claude_md', 'Закрыто §5п.10 CLAUDE.md + хук CLAUDE.md-warn', 'GREEN'),
|
||||
CONFLICT('upm', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('mcp_21st', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('hk_economy', 'superpowers', '§12 — hard-rule уровня 0; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),
|
||||
CONFLICT('observer_stophook', 'hk_verifier', 'HK1 §5.3: оба на Stop-event — коллизии нет (append-chain). Оба способны decision:block; Claude Code прогоняет все Stop-хуки, любой block ⇒ продолжение хода. observer-gate детерминированный и дешёвый.', 'GREEN'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// RUFLO ОРКЕСТРАТОР — фактический реколлаж (iter5, 2026-05-15)
|
||||
// ══════════════════════════════════════════════════
|
||||
// Queen → артефакты установки ruflo init (рой idle, артефакты не задействованы)
|
||||
E('ruflo_queen', 'ruflo_workers', 'координирует\n(0 задач)'),
|
||||
E('ruflo_queen', 'ruflo_agents_catalog', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_commands', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_plugins', 'плагинов ruflo:\n0 установлено'),
|
||||
// MCP-сервер ruflo — связывает половины кластера + читает/пишет память
|
||||
E('ruflo_mcp', 'ruflo_queen', 'инструменты\nуправления роем'),
|
||||
E('ruflo_mcp', 'ruflo_memory', 'читает/пишет\nпамять'),
|
||||
// память ruflo — recall-хук и воркер consolidate демона
|
||||
E('ruflo_recall_hook', 'ruflo_memory', 'запускает\nruflo memory search'),
|
||||
E('ruflo_daemon', 'ruflo_memory', 'воркер consolidate\nобращается к памяти'),
|
||||
// 4 узла-правила → Queen (реколлаж 16.05.2026: ruflo — advisory-подсистема; Pravila §14 — queen-триггер)
|
||||
E('pravila', 'ruflo_queen', '§14:\nqueen-триггер'),
|
||||
E('claude_md', 'ruflo_queen', '§3.5: описывает\n(advisory-подсистема)'),
|
||||
E('psr_v1', 'ruflo_queen', '§14:\ncross-ref'),
|
||||
E('tooling', 'ruflo_queen', '§4.10: реестр\n(advisory-подсистема)'),
|
||||
// memory → ruflo
|
||||
E('mem_ruflo', 'ruflo_queen', 'документирует\nинтеграцию'),
|
||||
|
||||
// 3 конфликта ruflo (3-color, iter2 §4)
|
||||
CONFLICT('ruflo_queen', 'pravila', 'Закрыто реколлажем 16.05.2026: нормативка приведена к рантайму — ruflo переописан в advisory/automation-подсистему, декларация уровня −1 убрана', 'GREEN'),
|
||||
CONFLICT('ruflo_memory', 'mem_state', 'Два хранилища памяти не синхронизированы; память ruflo почти пуста (0 записей)', 'BLACK'),
|
||||
CONFLICT('ruflo_daemon', 'ag_pest', 'Worker-jitter демона ruflo усиливает Pest-квирки 73/77 (квирк 72 устранён 16.05 — его jitter больше не усиливает)', 'BLACK'),
|
||||
];
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3: CATEGORY LABELS
|
||||
// ════════════════════════════════════════════════════
|
||||
const CATEGORY_LABELS = {
|
||||
rules: 'Правило', plugins: 'Плагин', skills_sp: 'Скил Superpowers',
|
||||
skills_proj: 'Скил проекта', hooks: 'Хук .claude', agents: 'Агент',
|
||||
mcp: 'MCP-сервер', lefthook: 'Lefthook job', memory: 'Memory-файл',
|
||||
ruflo: 'ruflo (изолирован)'
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3.4: SECTION BUCKETS & SECTIONS
|
||||
// ════════════════════════════════════════════════════
|
||||
const SECTION_BUCKETS = [
|
||||
{ id: 'A', label: 'Технические и продуктовые' },
|
||||
{ id: 'B', label: 'Коммуникации' },
|
||||
{ id: 'C', label: 'Бизнес и операции' },
|
||||
{ id: 'D', label: 'Право и комплаенс' },
|
||||
{ id: 'E', label: 'Мета и управление' },
|
||||
];
|
||||
const SECTIONS = [
|
||||
{ id: 'A1', bucket: 'A', label: 'Программирование — backend' },
|
||||
{ id: 'A2', bucket: 'A', label: 'Программирование — frontend' },
|
||||
{ id: 'A3', bucket: 'A', label: 'Программирование — интеграции (API, вебхуки)' },
|
||||
{ id: 'A4', bucket: 'A', label: 'Дизайн (UI/UX, графика, бренд)' },
|
||||
{ id: 'A5', bucket: 'A', label: 'Тестирование, QA и отладка' },
|
||||
{ id: 'A6', bucket: 'A', label: 'Архитектура систем' },
|
||||
{ id: 'A7', bucket: 'A', label: 'DevOps, инфраструктура, деплой' },
|
||||
{ id: 'A8', bucket: 'A', label: 'Информационная безопасность' },
|
||||
{ id: 'A9', bucket: 'A', label: 'Работа с данными (БД, миграции, RLS)' },
|
||||
{ id: 'A10', bucket: 'A', label: 'Аналитика и отчётность (BI)' },
|
||||
{ id: 'A11', bucket: 'A', label: 'ML / AI-разработка' },
|
||||
{ id: 'B1', bucket: 'B', label: 'Голосовое общение по телефону' },
|
||||
{ id: 'B2', bucket: 'B', label: 'Мессенджеры' },
|
||||
{ id: 'B3', bucket: 'B', label: 'Электронная почта' },
|
||||
{ id: 'B4', bucket: 'B', label: 'SMS-рассылки' },
|
||||
{ id: 'B5', bucket: 'B', label: 'Видеосвязь' },
|
||||
{ id: 'B6', bucket: 'B', label: 'Чат на сайте / онлайн-консультант' },
|
||||
{ id: 'B7', bucket: 'B', label: 'Социальные сети' },
|
||||
{ id: 'B8', bucket: 'B', label: 'Push / in-app уведомления' },
|
||||
{ id: 'C1', bucket: 'C', label: 'Маркетинг и лидогенерация' },
|
||||
{ id: 'C2', bucket: 'C', label: 'Продажи' },
|
||||
{ id: 'C3', bucket: 'C', label: 'Квалификация и обработка лидов' },
|
||||
{ id: 'C4', bucket: 'C', label: 'Работа с поставщиками лидов' },
|
||||
{ id: 'C5', bucket: 'C', label: 'Клиентский успех, поддержка, удержание' },
|
||||
{ id: 'C6', bucket: 'C', label: 'Финансы — биллинг и тарификация' },
|
||||
{ id: 'C7', bucket: 'C', label: 'Финансы — бухгалтерия и налоги' },
|
||||
{ id: 'C8', bucket: 'C', label: 'HR и управление персоналом' },
|
||||
{ id: 'C9', bucket: 'C', label: 'Управление проектами' },
|
||||
{ id: 'C10', bucket: 'C', label: 'Бизнес-процессы (общее)' },
|
||||
{ id: 'D1', bucket: 'D', label: 'Юриспруденция и договорная работа' },
|
||||
{ id: 'D2', bucket: 'D', label: 'Защита ПДн (152-ФЗ, РКН)' },
|
||||
{ id: 'D3', bucket: 'D', label: 'Аудит и управление рисками' },
|
||||
{ id: 'E1', bucket: 'E', label: 'Мета — правила и нормативка' },
|
||||
{ id: 'E2', bucket: 'E', label: 'Мета — оркестрация и автоматизация (Claude-воркфлоу)' },
|
||||
{ id: 'E3', bucket: 'E', label: 'Документация' },
|
||||
{ id: 'E4', bucket: 'E', label: 'Управление знаниями и память' },
|
||||
{ id: 'E5', bucket: 'E', label: 'Стратегия и принятие решений' },
|
||||
{ id: 'E6', bucket: 'E', label: 'Обучение и онбординг' },
|
||||
{ id: 'E7', bucket: 'E', label: 'Исследования' },
|
||||
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
|
||||
];
|
||||
// Узел -> раздел. Покрывает все 134 узла карты.
|
||||
const NODE_SECTION = {
|
||||
// правила (4)
|
||||
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
|
||||
// плагины (5)
|
||||
superpowers: 'E2', fd_plugin: 'A4', upm: 'A4', claude_md_mgmt: 'E1', hookify_plugin: 'E2',
|
||||
// скилы superpowers (14)
|
||||
sk_brainstorm: 'E5', sk_wplans: 'E2', sk_eplans: 'E2', sk_subagent: 'E2',
|
||||
sk_tdd: 'A5', sk_verify: 'A5', sk_debug: 'A5', sk_parallel: 'E2',
|
||||
sk_worktree: 'E2', sk_pr: 'E2', sk_coderev: 'A5', sk_spreview: 'A5',
|
||||
sk_wskills: 'E2', sk_elements: 'E3',
|
||||
// скилы проекта (2)
|
||||
sk_rls: 'A9', sk_qitem: 'E3',
|
||||
// хуки (5)
|
||||
hk_session: 'E4', hk_economy: 'E2', hk_pre_claude: 'E1', hk_post_md: 'E3', hk_post_schema: 'A9',
|
||||
// агенты (11)
|
||||
ag_explore: 'E2', ag_general: 'E2', ag_plan: 'E2', ag_pest: 'A5', ag_guide: 'E6',
|
||||
ag_statusline: 'E2', ag_hookify: 'E2', ag_pcreator: 'E2', ag_pvalid: 'E2',
|
||||
ag_skreview: 'E2', ag_rls: 'A9',
|
||||
// MCP-серверы (7)
|
||||
mcp_21st: 'A4', mcp_pw: 'A5', mcp_gh: 'A7', mcp_boost: 'A1',
|
||||
mcp_redis: 'A7', mcp_sentry: 'A7', mcp_semgrep: 'A8',
|
||||
// lefthook jobs (10)
|
||||
lh_mdlint: 'E3', lh_cspell: 'E3', lh_stylelint: 'A2', lh_eslint: 'A2',
|
||||
lh_lychee: 'E3', lh_gitleaks: 'A8', lh_gitleaks2: 'A8', lh_pint: 'A1',
|
||||
lh_larastan: 'A1', lh_squawk: 'A9',
|
||||
// memory files (16)
|
||||
mem_user: 'E4', mem_comm: 'E4', mem_env: 'E4', mem_sp: 'E4', mem_plugins: 'E4',
|
||||
mem_handoff: 'E4', mem_redesign: 'E4', mem_devindices: 'E4', mem_phase1: 'E4',
|
||||
mem_state: 'E4', mem_brain: 'E4', mem_supplier: 'E4', mem_audit: 'E4',
|
||||
mem_archive: 'E4', mem_github: 'E4', mem_ruflo: 'E4',
|
||||
// ruflo (9)
|
||||
ruflo_queen: 'E2', ruflo_plugins: 'E2', ruflo_workers: 'E2', ruflo_agents_catalog: 'E2',
|
||||
ruflo_commands: 'E2', ruflo_daemon: 'E2', ruflo_memory: 'E4', ruflo_mcp: 'E2',
|
||||
ruflo_recall_hook: 'E4',
|
||||
// АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — новые узлы
|
||||
skill_creator: 'E8', claude_setup: 'E8', plugin_dev: 'E2', context7: 'E7',
|
||||
hk_self_check: 'E2', hk_skill_marker: 'E2', hk_skill_check: 'E2', hk_state_guard: 'E2',
|
||||
hk_postcompact: 'E2', hk_verifier: 'E2', hk_ruflo_queen: 'E2',
|
||||
sk_regression: 'A5',
|
||||
mem_audit_b: 'E4', mem_audit_c: 'E4', mem_suppliercrm: 'E4', mem_audit12: 'E4',
|
||||
mem_audit14: 'E4', mem_sprint1: 'E4', mem_sprint2: 'E4', mem_sprint3: 'E4',
|
||||
// A6 architecture-tooling 17.05.2026 — раздел «Архитектура систем» наполнен (+deptrac)
|
||||
adr_kit: 'A6', arch_patterns: 'A6', mermaid_skill: 'A6', deptrac: 'A6',
|
||||
// D3 audit-security 17.05.2026 — раздел «Аудит и управление рисками» наполнен
|
||||
tob_skills: 'D3', sec_guidance: 'D3', sk_security_review: 'D3', sk_audit_portal: 'D3',
|
||||
// C9 project-management-tooling 17.05.2026 — раздел «Управление проектами» наполнен
|
||||
ccpm: 'C9', product_mgmt: 'C9',
|
||||
// A4 design-tooling 17.05.2026 — раздел «Дизайн (UI/UX, графика, бренд)» расширен (3→6 узлов)
|
||||
mcp_figma: 'A4', mcp_icons: 'A4', design_plugin: 'A4',
|
||||
// A3 integration-tooling 17.05.2026 — раздел «Программирование — интеграции» наполнен
|
||||
ag_apidocs: 'A3', mcp_openapi: 'A3',
|
||||
// A11 ml-ai-tooling 17.05.2026 — раздел «ML / AI-разработка» наполнен
|
||||
claude_api: 'A11', promptfoo: 'A11', data_scientist: 'A11',
|
||||
// C10 business-process 17.05.2026 — раздел «Бизнес-процессы (общее)» наполнен
|
||||
ops_plugin: 'C10', process_modeling: 'C10', process_analysis: 'C10',
|
||||
// discovery-interview 18.05.2026 — раздел E5 «Стратегия и принятие решений» (рядом с brainstorming)
|
||||
discovery_interview: 'E5',
|
||||
// brain governance iter9 19.05.2026 — ADR-011 подсистема
|
||||
router_procedure: 'E1', observer_stophook: 'E2', sk_brain_retro: 'E8', observer_evidence: 'E4',
|
||||
lh_l1watcher: 'E1', lh_crossref: 'E1', lh_obs_obs: 'E2', lh_status_md: 'E2', lh_obs_cov: 'E2',
|
||||
};
|
||||
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
|
||||
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
|
||||
// частично кросс-реф существующих интеграционных инструментов. NODE_SECTION 1:1 не трогается.
|
||||
const NODE_SECTION_SECONDARY = {
|
||||
mcp_boost: ['A3'],
|
||||
context7: ['A3'],
|
||||
ag_pest: ['A3'],
|
||||
mcp_semgrep: ['A3'],
|
||||
mcp_sentry: ['A3'],
|
||||
// C10 business-process 17.05.2026 — кросс-реф reuse-инструментов раздела «Бизнес-процессы»
|
||||
mermaid_skill: ['C10'],
|
||||
arch_patterns: ['C10'],
|
||||
ccpm: ['C10'],
|
||||
product_mgmt: ['C10'],
|
||||
sk_wplans: ['C10'],
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 4: VIS GROUPS
|
||||
// ════════════════════════════════════════════════════
|
||||
const GROUPS = {
|
||||
rules: { color: { background: '#073642', border: '#268bd2', highlight: { border: '#93a1a1', background: '#0d4a5a' } }, font: { color: '#fdf6e3', size: 13, bold: true } },
|
||||
plugins: { color: { background: '#001a00', border: '#859900', highlight: { border: '#b8cc00', background: '#002600' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
skills_sp: { color: { background: '#1a0033', border: '#6c71c4', highlight: { border: '#9b9fea', background: '#250047' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
skills_proj: { color: { background: '#2d0020', border: '#d33682', highlight: { border: '#e869a8', background: '#3d0028' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
hooks: { color: { background: '#002233', border: '#2aa198', highlight: { border: '#4dd7ce', background: '#003344' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
agents: { color: { background: '#1a1200', border: '#b58900', highlight: { border: '#e0ad00', background: '#261a00' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
mcp: { color: { background: '#2d1200', border: '#cb4b16', highlight: { border: '#ff6b30', background: '#3d1900' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
lefthook: { color: { background: '#2d0000', border: '#dc322f', highlight: { border: '#ff5f5c', background: '#3d0000' } }, font: { color: '#fdf6e3', size: 10 } },
|
||||
memory: { color: { background: '#112233', border: '#586e75', highlight: { border: '#839496', background: '#1a2f40' } }, font: { color: '#eee8d5', size: 10 } },
|
||||
ruflo: { color: { background: '#262626', border: '#555555', highlight: { border: '#777777', background: '#333333' } }, font: { color: '#8a8a8a', size: 12, bold: true }, shapeProperties: { borderDashes: [4, 4] } },
|
||||
};
|
||||
|
||||
// Expose for ES-module consumers (the dashboard). The map's classic inline
|
||||
// script reads the bare consts directly via the shared global lexical scope.
|
||||
window.AGD = {
|
||||
NODES, EDGES, SECTIONS, SECTION_BUCKETS,
|
||||
NODE_SECTION, NODE_SECTION_SECONDARY,
|
||||
CONFLICT_TYPES, GROUPS, CATEGORY_LABELS,
|
||||
};
|
||||
+198
-594
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Система автоматизации Лидерры</title>
|
||||
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
||||
<script src="automation-graph-data.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #0d0d1a; color: #fdf6e3; font-family: 'Segoe UI', system-ui, sans-serif; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
@@ -201,7 +202,7 @@
|
||||
<div class="cat-item" data-filter-key="group:mcp"><div class="cat-dot" style="background:#cb4b16"></div>MCP-серверы</div>
|
||||
<div class="cat-item" data-filter-key="group:lefthook"><div class="cat-dot" style="background:#dc322f"></div>Lefthook jobs</div>
|
||||
<div class="cat-item" data-filter-key="group:memory"><div class="cat-dot" style="background:#586e75"></div>Memory files</div>
|
||||
<div class="cat-item" data-filter-key="group:ruflo"><div class="cat-dot" style="background:#ff8800"></div>🌊 ruflo (оркестратор)</div>
|
||||
<div class="cat-item" data-filter-key="group:ruflo"><div class="cat-dot" style="background:#555555; border:1px dashed #888888"></div>🔇 ruflo (изолирован 18.05)</div>
|
||||
<div class="cat-item" data-filter-key="conflict:RED"><div class="cat-dot" style="background:#ff5f57; border:1px dashed #ff5f57"></div>🔴 Не закрыт правилом</div>
|
||||
<div class="cat-item" data-filter-key="conflict:BLACK"><div class="cat-dot" style="background:#888888; border:1px dashed #888888"></div>⚫ Возник на практике</div>
|
||||
<div class="cat-item" data-filter-key="conflict:GREEN"><div class="cat-dot" style="background:#859900; border:1px dashed #859900"></div>🟢 Закрыт правилом</div>
|
||||
@@ -217,412 +218,21 @@
|
||||
// SECTION 1: NODES
|
||||
// ════════════════════════════════════════════════════
|
||||
|
||||
// Радиально-секторная компоновка.
|
||||
// Сектора (по 90°): N=workflow (0–90), E=UI (90–180), S=infra (180–270), W=data/RLS (270–360).
|
||||
const RADII = [0, 220, 400, 600, 800, 1000, 1180];
|
||||
function pos(ring, angleDeg) {
|
||||
const r = RADII[ring];
|
||||
const a = angleDeg * Math.PI / 180;
|
||||
return { x: Math.round(r * Math.cos(a)), y: Math.round(r * Math.sin(a)) };
|
||||
}
|
||||
// RADII, pos() — moved to automation-graph-data.js
|
||||
|
||||
const NODES = [
|
||||
// ── ПРАВИЛА (4) ── центр + первое кольцо ───────
|
||||
{ id: 'pravila', label: 'Pravila v1.28', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.15', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.13', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.14', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
|
||||
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
|
||||
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
|
||||
{ id: 'fd_plugin', label: 'Frontend Design', group: 'plugins', size: 26, ring: 2, ...pos(2, 135) },
|
||||
{ id: 'upm', label: 'UI UX Pro Max', group: 'plugins', size: 22, ring: 2, ...pos(2, 165) },
|
||||
{ id: 'claude_md_mgmt', label: 'claude-md-mgmt', group: 'plugins', size: 22, ring: 2, ...pos(2, 225) },
|
||||
{ id: 'hookify_plugin', label: 'hookify (плагин)', group: 'plugins', size: 22, ring: 2, ...pos(2, 200) },
|
||||
{ id: 'skill_creator', label: 'skill-creator', group: 'plugins', size: 20, ring: 2, ...pos(2, 70) },
|
||||
{ id: 'claude_setup', label: 'claude-code-setup', group: 'plugins', size: 22, ring: 2, ...pos(2, 90) },
|
||||
{ id: 'plugin_dev', label: 'plugin-dev', group: 'plugins', size: 22, ring: 2, ...pos(2, 290) },
|
||||
{ id: 'context7', label: 'context7 (docs MCP)', group: 'plugins', size: 20, ring: 2, ...pos(2, 315) },
|
||||
// A6 architecture-tooling — adr-kit / architecture-patterns (плагины) + deptrac (composer dev-dep, job 10) — раздел «Архитектура систем»
|
||||
{ id: 'adr_kit', label: 'adr-kit', group: 'plugins', size: 22, ring: 2, ...pos(2, 240) },
|
||||
{ id: 'arch_patterns', label: 'architecture-patterns',group: 'plugins', size: 20, ring: 2, ...pos(2, 250) },
|
||||
{ id: 'deptrac', label: 'deptrac', group: 'plugins', size: 20, ring: 2, ...pos(2, 260) },
|
||||
// D3 audit-security (17.05.2026) — 2 плагина раздела «Аудит и управление рисками»
|
||||
{ id: 'tob_skills', label: 'Trail of Bits\nskills', group: 'plugins', size: 22, ring: 2, ...pos(2, 330) },
|
||||
{ id: 'sec_guidance', label: 'Security\nGuidance', group: 'plugins', size: 20, ring: 2, ...pos(2, 345) },
|
||||
// C9 project-management-tooling (17.05.2026) — плагин раздела «Управление проектами»
|
||||
{ id: 'product_mgmt', label: 'product-\nmanagement', group: 'plugins', size: 20, ring: 2, ...pos(2, 355) },
|
||||
// A4 design-tooling (17.05.2026) — раздел «Дизайн (UI/UX, графика, бренд)» (плагины)
|
||||
{ id: 'design_plugin', label: 'Design\nplugin', group: 'plugins', size: 20, ring: 2, ...pos(2, 155) },
|
||||
|
||||
// ── СКИЛЫ SUPERPOWERS (14) — N sector (0–90) ────
|
||||
{ id: 'sk_brainstorm', label: 'brainstorming', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 5) },
|
||||
{ id: 'sk_wplans', label: 'writing-plans', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 11) },
|
||||
{ id: 'sk_eplans', label: 'executing-plans', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 17) },
|
||||
{ id: 'sk_subagent', label: 'subagent-driven', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 23) },
|
||||
{ id: 'sk_tdd', label: 'TDD', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 29) },
|
||||
{ id: 'sk_verify', label: 'verification-before-completion', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 36) },
|
||||
{ id: 'sk_debug', label: 'systematic-debugging', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 43) },
|
||||
{ id: 'sk_parallel', label: 'parallel-work', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 50) },
|
||||
{ id: 'sk_worktree', label: 'worktree', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 57) },
|
||||
{ id: 'sk_pr', label: 'finishing-pr', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 64) },
|
||||
{ id: 'sk_coderev', label: 'code-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 71) },
|
||||
{ id: 'sk_spreview', label: 'spec-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 78) },
|
||||
{ id: 'sk_wskills', label: 'writing-skills', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 85) },
|
||||
{ id: 'sk_elements', label: 'elements-of-style', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 92) },
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА (6) — W sector (RLS/arch/audit) ────
|
||||
{ id: 'sk_rls', label: 'rls-check', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 305) },
|
||||
{ id: 'sk_qitem', label: 'q-item-add', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 220) },
|
||||
{ id: 'sk_regression', label: 'regression', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 260) },
|
||||
// A6 architecture-tooling (17.05.2026) — вендоренный скил диаграмм
|
||||
{ id: 'mermaid_skill', label: 'mermaid (skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 280) },
|
||||
// D3 audit-security (17.05.2026) — скилы раздела «Аудит и управление рисками»
|
||||
{ id: 'sk_security_review', label: 'security-review', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 315) },
|
||||
{ id: 'sk_audit_portal', label: 'audit-portal', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 325) },
|
||||
// C9 project-management-tooling (17.05.2026) — вендоренный скил раздела «Управление проектами»
|
||||
{ id: 'ccpm', label: 'CCPM\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 335) },
|
||||
// A11 ml-ai-tooling (17.05.2026) — скилы и CLI раздела «ML / AI-разработка»
|
||||
{ id: 'claude_api', label: 'claude-api\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 345) },
|
||||
{ id: 'data_scientist', label: 'Data Scientist\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 355) },
|
||||
{ id: 'promptfoo', label: 'promptfoo', group: 'plugins', size: 20, ring: 2, ...pos(2, 365) },
|
||||
// C10 business-process (17.05.2026) — плагин и скилы раздела «Бизнес-процессы (общее)»
|
||||
{ id: 'ops_plugin', label: 'operations\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 385) },
|
||||
{ id: 'process_modeling', label: 'process-modeling\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 367) },
|
||||
{ id: 'process_analysis', label: 'process-analysis\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 377) },
|
||||
// discovery-tooling (18.05.2026) — self-authored скил интервью-discovery
|
||||
{ id: 'discovery_interview', label: 'discovery-interview\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 387) },
|
||||
|
||||
// ── ХУКИ (12) — S+infra + E (economy/skill) ───
|
||||
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
|
||||
{ id: 'hk_economy', label: 'UserPromptSubmit:\neconomy-mode', group: 'hooks', size: 22, ring: 4, ...pos(4, 95) },
|
||||
{ id: 'hk_pre_claude', label: 'PreToolUse:\nCLAUDE.md-warn', group: 'hooks', size: 22, ring: 4, ...pos(4, 215) },
|
||||
{ id: 'hk_post_md', label: 'PostToolUse:\nmarkdownlint', group: 'hooks', size: 20, ring: 4, ...pos(4, 195) },
|
||||
{ id: 'hk_post_schema', label: 'PostToolUse:\nschema-changelog',group: 'hooks', size: 20, ring: 4, ...pos(4, 300) },
|
||||
{ id: 'hk_self_check', label: 'SessionStart:\neconomy-self-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 105) },
|
||||
{ id: 'hk_skill_marker', label: 'PreToolUse:\nskill-marker', group: 'hooks', size: 20, ring: 4, ...pos(4, 115) },
|
||||
{ id: 'hk_skill_check', label: 'PreToolUse:\nskill-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 125) },
|
||||
{ id: 'hk_state_guard', label: 'PreToolUse:\neconomy-state-guard', group: 'hooks', size: 20, ring: 4, ...pos(4, 135) },
|
||||
{ id: 'hk_postcompact', label: 'PostCompact:\neconomy-postcompact', group: 'hooks', size: 20, ring: 4, ...pos(4, 145) },
|
||||
{ id: 'hk_verifier', label: 'Stop:\neconomy-verifier (агент)', group: 'hooks', size: 22, ring: 4, ...pos(4, 155) },
|
||||
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: 'hooks', size: 20, ring: 4, ...pos(4, 165) },
|
||||
|
||||
// ── АГЕНТЫ (11) — N (workflow) + W (RLS) ──────
|
||||
{ id: 'ag_explore', label: 'Explore', group: 'agents', size: 20, ring: 4, ...pos(4, 10) },
|
||||
{ id: 'ag_general', label: 'general-purpose', group: 'agents', size: 20, ring: 4, ...pos(4, 25) },
|
||||
{ id: 'ag_plan', label: 'Plan', group: 'agents', size: 20, ring: 4, ...pos(4, 40) },
|
||||
{ id: 'ag_pest', label: 'pest-parallel-debugger', group: 'agents', size: 24, ring: 4, ...pos(4, 55) },
|
||||
{ id: 'ag_guide', label: 'claude-code-guide', group: 'agents', size: 18, ring: 4, ...pos(4, 70) },
|
||||
{ id: 'ag_statusline', label: 'statusline-setup', group: 'agents', size: 18, ring: 4, ...pos(4, 85) },
|
||||
{ id: 'ag_hookify', label: 'hookify:\nconversation-analyzer', group: 'agents', size: 18, ring: 4, ...pos(4, 230) },
|
||||
{ id: 'ag_pcreator', label: 'plugin-dev:\nagent-creator', group: 'agents', size: 16, ring: 4, ...pos(4, 245) },
|
||||
{ id: 'ag_pvalid', label: 'plugin-dev:\nplugin-validator',group: 'agents', size: 16, ring: 4, ...pos(4, 260) },
|
||||
{ id: 'ag_skreview', label: 'plugin-dev:\nskill-reviewer', group: 'agents', size: 16, ring: 4, ...pos(4, 275) },
|
||||
{ id: 'ag_rls', label: 'rls-reviewer', group: 'agents', size: 22, ring: 4, ...pos(4, 315) },
|
||||
// A3 integration-tooling (17.05.2026) — agent раздела «Программирование — интеграции»
|
||||
{ id: 'ag_apidocs', label: 'api-docs (agent)', group: 'agents', size: 18, ring: 4, ...pos(4, 175) },
|
||||
|
||||
// ── MCP-СЕРВЕРЫ (9) — E (UI) + W (data) ───────
|
||||
{ id: 'mcp_21st', label: 'MCP: 21st.dev Magic', group: 'mcp', size: 20, ring: 5, ...pos(5, 130) },
|
||||
// A4 design-tooling (17.05.2026) — MCP-серверы раздела «Дизайн (UI/UX, графика, бренд)»
|
||||
{ id: 'mcp_figma', label: 'MCP: Figma\n(DEFERRED)', group: 'mcp', size: 18, ring: 5, ...pos(5, 140) },
|
||||
{ id: 'mcp_icons', label: 'MCP: Universal\nIcons', group: 'mcp', size: 18, ring: 5, ...pos(5, 120) },
|
||||
{ id: 'mcp_pw', label: 'MCP: playwright', group: 'mcp', size: 22, ring: 5, ...pos(5, 110) },
|
||||
{ id: 'mcp_gh', label: 'MCP: github', group: 'mcp', size: 22, ring: 5, ...pos(5, 75) },
|
||||
{ id: 'mcp_boost', label: 'MCP: laravel-boost', group: 'mcp', size: 24, ring: 5, ...pos(5, 290) },
|
||||
{ id: 'mcp_redis', label: 'MCP: redis', group: 'mcp', size: 22, ring: 5, ...pos(5, 310) },
|
||||
{ id: 'mcp_sentry', label: 'MCP: sentry', group: 'mcp', size: 22, ring: 5, ...pos(5, 330) },
|
||||
{ id: 'mcp_semgrep', label: 'MCP: semgrep', group: 'mcp', size: 20, ring: 5, ...pos(5, 350) },
|
||||
// A3 integration-tooling (17.05.2026) — MCP-сервер раздела «Программирование — интеграции»
|
||||
{ id: 'mcp_openapi', label: 'MCP: openapi', group: 'mcp', size: 20, ring: 5, ...pos(5, 5) },
|
||||
|
||||
// ── LEFTHOOK JOBS (10) — S+W (infra/data) ─────
|
||||
{ id: 'lh_mdlint', label: 'lefthook:\nmarkdownlint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 185) },
|
||||
{ id: 'lh_cspell', label: 'lefthook:\ncspell', group: 'lefthook', size: 18, ring: 5, ...pos(5, 200) },
|
||||
{ id: 'lh_stylelint', label: 'lefthook:\nstylelint', group: 'lefthook', size: 16, ring: 5, ...pos(5, 215) },
|
||||
{ id: 'lh_eslint', label: 'lefthook:\neslint-vue', group: 'lefthook', size: 18, ring: 5, ...pos(5, 230) },
|
||||
{ id: 'lh_lychee', label: 'lefthook:\nlychee-links', group: 'lefthook', size: 18, ring: 5, ...pos(5, 245) },
|
||||
{ id: 'lh_gitleaks', label: 'lefthook:\ngitleaks', group: 'lefthook', size: 18, ring: 5, ...pos(5, 260) },
|
||||
{ id: 'lh_gitleaks2', label: 'lefthook:\ngitleaks pre-push', group: 'lefthook', size: 18, ring: 5, ...pos(5, 275) },
|
||||
{ id: 'lh_pint', label: 'lefthook:\npint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 25) },
|
||||
{ id: 'lh_larastan', label: 'lefthook:\nlarastan', group: 'lefthook', size: 18, ring: 5, ...pos(5, 50) },
|
||||
{ id: 'lh_squawk', label: 'lefthook:\nsquawk', group: 'lefthook', size: 18, ring: 5, ...pos(5, 320) },
|
||||
|
||||
// ── MEMORY FILES (23) — внешнее кольцо ──────────
|
||||
{ id: 'mem_user', label: 'memory:\nuser_profile', group: 'memory', size: 16, ring: 6, ...pos(6, 0) },
|
||||
{ id: 'mem_comm', label: 'memory:\nfeedback_comm', group: 'memory', size: 14, ring: 6, ...pos(6, 24) },
|
||||
{ id: 'mem_env', label: 'memory:\nfeedback_env', group: 'memory', size: 16, ring: 6, ...pos(6, 48) },
|
||||
{ id: 'mem_sp', label: 'memory:\nfeedback_superpowers',group: 'memory', size: 16, ring: 6, ...pos(6, 72) },
|
||||
{ id: 'mem_plugins', label: 'memory:\nfeedback_plugins', group: 'memory', size: 16, ring: 6, ...pos(6, 96) },
|
||||
{ id: 'mem_handoff', label: 'memory:\nreference_handoff', group: 'memory', size: 14, ring: 6, ...pos(6, 120) },
|
||||
{ id: 'mem_redesign', label: 'memory:\nportal_redesign', group: 'memory', size: 14, ring: 6, ...pos(6, 144) },
|
||||
{ id: 'mem_devindices', label: 'memory:\ndev_indices', group: 'memory', size: 12, ring: 6, ...pos(6, 168) },
|
||||
{ id: 'mem_phase1', label: 'memory:\nphase1_strategy', group: 'memory', size: 14, ring: 6, ...pos(6, 192) },
|
||||
{ id: 'mem_state', label: 'memory:\nproject_state', group: 'memory', size: 16, ring: 6, ...pos(6, 216) },
|
||||
{ id: 'mem_brain', label: 'memory:\nclaude_brain', group: 'memory', size: 14, ring: 6, ...pos(6, 240) },
|
||||
{ id: 'mem_supplier', label: 'memory:\nsupplier_integration',group: 'memory', size: 14, ring: 6, ...pos(6, 264) },
|
||||
{ id: 'mem_audit', label: 'memory:\naudit_2026-05-13', group: 'memory', size: 14, ring: 6, ...pos(6, 288) },
|
||||
{ id: 'mem_archive', label: 'memory:\nreference_archive', group: 'memory', size: 14, ring: 6, ...pos(6, 312) },
|
||||
{ id: 'mem_github', label: 'memory:\nreference_github', group: 'memory', size: 14, ring: 6, ...pos(6, 336) },
|
||||
{ id: 'mem_audit_b', label: 'memory:\naudit_B_status', group: 'memory', size: 12, ring: 6, ...pos(6, 12) },
|
||||
{ id: 'mem_audit_c', label: 'memory:\naudit_C_pending', group: 'memory', size: 12, ring: 6, ...pos(6, 36) },
|
||||
{ id: 'mem_suppliercrm',label: 'memory:\nsupplier_crm', group: 'memory', size: 12, ring: 6, ...pos(6, 60) },
|
||||
{ id: 'mem_audit12', label: 'memory:\nfull_audit_05-12', group: 'memory', size: 12, ring: 6, ...pos(6, 84) },
|
||||
{ id: 'mem_audit14', label: 'memory:\nfull_audit_05-14', group: 'memory', size: 12, ring: 6, ...pos(6, 108) },
|
||||
{ id: 'mem_sprint1', label: 'memory:\nsprint1_p0_closure', group: 'memory', size: 12, ring: 6, ...pos(6, 132) },
|
||||
{ id: 'mem_sprint2', label: 'memory:\nsprint2_p1_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 156) },
|
||||
{ id: 'mem_sprint3', label: 'memory:\nsprint3_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 180) },
|
||||
|
||||
// ── RUFLO ОРКЕСТРАТОР (9) — фактический реколлаж iter5 — кластер вне радиального layout (верх-лево) ──
|
||||
{ id: 'ruflo_queen', label: 'ruflo Queen\n(hive-mind)', group: 'ruflo', size: 44, x: -1340, y: -700 },
|
||||
{ id: 'ruflo_plugins', label: 'плагины ruflo\n0 из 20 · скилов 0', group: 'ruflo', size: 20, x: -1340, y: -880 },
|
||||
{ id: 'ruflo_workers', label: '10 воркеров\nhive-mind (idle)', group: 'ruflo', size: 26, x: -1160, y: -800 },
|
||||
{ id: 'ruflo_agents_catalog', label: 'каталог агентов ruflo\n(100 определений)', group: 'ruflo', size: 24, x: -1530, y: -830 },
|
||||
{ id: 'ruflo_commands', label: 'slash-команды\nruflo (88)', group: 'ruflo', size: 22, x: -1140, y: -630 },
|
||||
{ id: 'ruflo_daemon', label: 'демон ruflo\n(воркеры падают)', group: 'ruflo', size: 24, x: -1560, y: -650 },
|
||||
{ id: 'ruflo_memory', label: 'память ruflo\n(~0 записей)', group: 'ruflo', size: 24, x: -1380, y: -500 },
|
||||
{ id: 'ruflo_mcp', label: 'ruflo MCP\n(~210 инструментов)', group: 'ruflo', size: 26, x: -1190, y: -460 },
|
||||
{ id: 'ruflo_recall_hook', label: 'хук recall\n(UserPromptSubmit)', group: 'ruflo', size: 22, x: -1570, y: -470 },
|
||||
|
||||
// ── MEMORY +1 (артефакт ruflo big-bang) ──
|
||||
{ id: 'mem_ruflo', label: 'memory:\nproject_ruflo_integration', group: 'memory', size: 14, x: -1740, y: -620 },
|
||||
];
|
||||
// NODES — moved to automation-graph-data.js
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 2: EDGES
|
||||
// ════════════════════════════════════════════════════
|
||||
const CONFLICT_TYPES = {
|
||||
RED: { color: '#ff5f57', bg: '#2d0000', emoji: '🔴', label: 'Не закрыт правилом', rank: 1 },
|
||||
BLACK: { color: '#888888', bg: '#1a1a1a', emoji: '⚫', label: 'Возник на практике', rank: 2 },
|
||||
GREEN: { color: '#859900', bg: '#0e1a00', emoji: '🟢', label: 'Закрыт правилом', rank: 3 },
|
||||
};
|
||||
const E = (from, to, label) => ({
|
||||
from, to,
|
||||
title: label,
|
||||
color: { color: '#586e75', highlight: '#93a1a1', hover: '#93a1a1' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.6 } },
|
||||
smooth: { type: 'continuous', roundness: 0.5 }
|
||||
});
|
||||
const CONFLICT = (from, to, label, type = 'RED') => ({
|
||||
from, to,
|
||||
title: label,
|
||||
label: CONFLICT_TYPES[type].emoji,
|
||||
dashes: true,
|
||||
width: 2,
|
||||
color: { color: CONFLICT_TYPES[type].color, highlight: '#ff8880', hover: '#ff8880' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.7 }, from: { enabled: true, scaleFactor: 0.7 } },
|
||||
font: { color: CONFLICT_TYPES[type].color, size: 14, align: 'middle', strokeWidth: 3, strokeColor: '#1e1e2e' },
|
||||
smooth: { type: 'curvedCW', roundness: 0.35 }
|
||||
});
|
||||
// CONFLICT_TYPES, E, CONFLICT — moved to automation-graph-data.js
|
||||
|
||||
const EDGES = [
|
||||
// ── ПРАВИЛА — иерархия ──────────────────────────
|
||||
E('pravila', 'claude_md', 'подчиняет\n(уровень 1→2a)'),
|
||||
E('pravila', 'psr_v1', 'подчиняет\n(уровень 1→3)'),
|
||||
E('claude_md', 'tooling', 'ссылается\nна реестр'),
|
||||
E('pravila', 'superpowers', '§12: обязывает\nинвокировать 1-м'),
|
||||
|
||||
// ── PSR_v1 координирует плагины ─────────────────
|
||||
E('psr_v1', 'superpowers', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'fd_plugin', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'upm', 'R14.3: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'mcp_21st', 'R14.4: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'claude_md_mgmt','R10.1 блок 1:\nинфраструктурный'),
|
||||
|
||||
// ── CLAUDE.md ────────────────────────────────────
|
||||
E('claude_md', 'mcp_boost', 'описывает §3.2'),
|
||||
E('claude_md', 'mcp_sentry', 'описывает §4.8'),
|
||||
E('claude_md', 'mcp_redis', 'описывает §4.9'),
|
||||
E('claude_md', 'claude_md_mgmt', '§5п.10:\nединственный канал'),
|
||||
E('claude_md', 'ag_pest', 'описывает\nкогда вызывать'),
|
||||
E('claude_md', 'ag_rls', 'описывает\nкогда вызывать'),
|
||||
|
||||
// ── ХУКИ ────────────────────────────────────────
|
||||
E('hk_pre_claude', 'claude_md', 'проверяет\nпри Edit/Write'),
|
||||
E('hk_post_md', 'lh_mdlint', 'дублирует задачу\n(локально)'),
|
||||
E('hk_post_schema', 'claude_md', 'напоминает про\nCHANGELOG_schema'),
|
||||
E('hk_session', 'mem_user', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_env', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_sp', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_plugins', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_state', 'читает\nпри старте'),
|
||||
E('hk_economy', 'superpowers', 'парсит уровень\nэкономии'),
|
||||
|
||||
// ── SUPERPOWERS содержит скилы ──────────────────
|
||||
E('superpowers', 'sk_brainstorm', 'содержит'),
|
||||
E('superpowers', 'sk_tdd', 'содержит'),
|
||||
E('superpowers', 'sk_debug', 'содержит'),
|
||||
E('superpowers', 'sk_wplans', 'содержит'),
|
||||
E('superpowers', 'sk_eplans', 'содержит'),
|
||||
E('superpowers', 'sk_verify', 'содержит'),
|
||||
E('superpowers', 'sk_parallel', 'содержит'),
|
||||
E('superpowers', 'sk_worktree', 'содержит'),
|
||||
E('superpowers', 'sk_pr', 'содержит'),
|
||||
E('superpowers', 'sk_subagent', 'содержит'),
|
||||
E('superpowers', 'sk_wskills', 'содержит'),
|
||||
E('superpowers', 'sk_spreview', 'содержит'),
|
||||
E('superpowers', 'sk_coderev', 'содержит'),
|
||||
E('superpowers', 'sk_elements', 'содержит'),
|
||||
|
||||
// ── СКИЛЫ вызывают друг друга ───────────────────
|
||||
E('sk_brainstorm', 'sk_wplans', 'вызывает\nпосле дизайна'),
|
||||
E('sk_wplans', 'sk_eplans', 'вызывает\nдля выполнения'),
|
||||
E('sk_wplans', 'sk_subagent','альтернатива\nexecuting-plans'),
|
||||
E('sk_subagent', 'ag_explore', 'запускает\nдля поиска'),
|
||||
E('sk_subagent', 'ag_general', 'запускает\nдля задач'),
|
||||
E('sk_subagent', 'ag_plan', 'запускает\nдля архитектуры'),
|
||||
E('sk_parallel', 'sk_worktree','использует\nдля изоляции'),
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА ───────────────────────────────
|
||||
E('sk_rls', 'tooling', 'использует\nsquawk + grep §3.2'),
|
||||
E('sk_rls', 'mcp_boost', 'SQL запросы\nк схеме'),
|
||||
E('sk_qitem', 'claude_md_mgmt','делегирует\nправку CLAUDE.md'),
|
||||
|
||||
// ── CLAUDE-MD-MGMT ──────────────────────────────
|
||||
E('claude_md_mgmt', 'claude_md', 'единственный\nканал правок'),
|
||||
|
||||
// ── HOOKIFY ─────────────────────────────────────
|
||||
E('ag_hookify', 'hookify_plugin', 'передаёт\nанализ'),
|
||||
E('hookify_plugin', 'hk_pre_claude', 'может создавать\nновые хуки'),
|
||||
E('hookify_plugin', 'hk_economy', 'может создавать\nновые хуки'),
|
||||
|
||||
// ── АГЕНТЫ используют MCP ───────────────────────
|
||||
E('ag_pest', 'mcp_redis', 'читает\nочереди/кэш'),
|
||||
E('ag_rls', 'mcp_boost', 'SQL запросы\nк БД'),
|
||||
E('ag_guide', 'mcp_gh', 'ищет\nв репозитории'),
|
||||
|
||||
// ── LEFTHOOK вызывается git ──────────────────────
|
||||
E('lh_gitleaks', 'mem_plugins', 'блокирует коммит\nпри ПДн в staged'),
|
||||
E('lh_larastan', 'mcp_boost', 'Boost даёт\nконтекст типов'),
|
||||
E('lh_squawk', 'tooling', 'соответствует\n§3.2 #15'),
|
||||
E('lh_gitleaks2', 'lh_gitleaks', 'строже:\nвся история'),
|
||||
E('lh_lychee', 'claude_md', 'проверяет\nссылки в .md'),
|
||||
|
||||
// ── MEMORY читается Claude ──────────────────────
|
||||
E('mem_env', 'ag_pest', 'квирки 73/77\nиспользует агент'),
|
||||
E('mem_plugins', 'psr_v1', 'отражает\nтекущие версии'),
|
||||
E('mem_archive', 'claude_md', 'синхронизирует\nверсии доков'),
|
||||
|
||||
// ── MCP ─────────────────────────────────────────
|
||||
E('mcp_pw', 'hk_session', 'используется\nдля a11y smoke'),
|
||||
E('mcp_gh', 'sk_pr', 'PR, issues\nпри finishing-pr'),
|
||||
E('mcp_boost', 'ag_rls', 'схема БД\nдля RLS-review'),
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — связи новых узлов ──
|
||||
// 4 ребра psr_v1→skill_creator/claude_setup/plugin_dev/context7 — перенесены
|
||||
// в ADT-блок 18.05.2026 (точные категории authoring-tooling/dev-support, дедуп)
|
||||
E('plugin_dev', 'ag_pcreator', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_pvalid', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_skreview', 'содержит\nагента'),
|
||||
E('skill_creator', 'sk_wskills', 'обе создают\nскилы'),
|
||||
E('hk_self_check', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_skill_marker', 'hk_skill_check', 'пара\nmarker/check'),
|
||||
E('hk_skill_check', 'superpowers', 'энфорсит §12:\nскил перед кодом'),
|
||||
E('hk_state_guard', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_postcompact', 'hk_economy', 'переинжект\nрежима после компакта'),
|
||||
E('hk_verifier', 'sk_verify', 'энфорсит\nпроверку готовности'),
|
||||
E('hk_ruflo_queen', 'ruflo_queen', '§14: маршрут\nqueen-задач'),
|
||||
E('sk_regression', 'ag_pest', 'передаёт разбор\nпадений Pest --parallel'),
|
||||
|
||||
// ── A6 ARCHITECTURE-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'adr_kit', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('psr_v1', 'arch_patterns', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('tooling', 'mermaid_skill', '§4.12: реестр\n(вендоренный скил)'),
|
||||
E('psr_v1', 'deptrac', 'R10.1 блок 1 note:\narchitecture-tooling'),
|
||||
|
||||
// ── A4 DESIGN-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'design_plugin', 'R10.1 блок 1:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_icons', 'R10.1 блок 3:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_figma', 'R10.1 блок 3:\ndesign-tooling (DEFERRED)'),
|
||||
|
||||
// ── D3 AUDIT-SECURITY 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'tob_skills', 'R10.1 блок 1:\naudit-security'),
|
||||
E('psr_v1', 'sec_guidance', 'R10.1 блок 1:\naudit-security'),
|
||||
E('tooling', 'tob_skills', '§4.14 #39 — реестр'),
|
||||
E('tooling', 'sec_guidance', '§4.15 #40 — реестр'),
|
||||
E('sk_audit_portal', 'sk_security_review', 'оркеструет\nкак фазу аудита'),
|
||||
E('sk_audit_portal', 'tob_skills', 'оркеструет\nглубокие кампании'),
|
||||
E('sk_audit_portal', 'sk_regression', 'использует\nна фазе тестов'),
|
||||
CONFLICT('tob_skills', 'mcp_semgrep', 'TB1: граница разграничена — Semgrep = inline SAST, Trail of Bits = глубокие on-demand аудит-кампании. Параллельное использование разрешено при разных сценариях.', 'GREEN'),
|
||||
|
||||
// ── A3 INTEGRATION-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'mcp_openapi', 'R10.1 блок 3:\nintegration-tooling'),
|
||||
E('tooling', 'mcp_openapi', '§4.22 #47 — реестр'),
|
||||
E('ag_apidocs', 'mcp_openapi', 'спека → MCP-ресурс'),
|
||||
|
||||
// ── A11 ML-AI-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'promptfoo', 'R10.1 блок 1:\nml-ai-tooling'),
|
||||
E('tooling', 'claude_api', 'reuse — built-in skill\n(PSR_v1 R10.1 блок 2)'),
|
||||
E('tooling', 'data_scientist', '§4.24 #49 — реестр'),
|
||||
|
||||
// ── C10 BUSINESS-PROCESS 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'ops_plugin', 'R10.1 блок 1:\nbusiness-process'),
|
||||
E('tooling', 'process_modeling', '§4.27 #52 — реестр'),
|
||||
E('tooling', 'process_analysis', '§4.28 #53 — реестр'),
|
||||
|
||||
// ── DISCOVERY-TOOLING 18.05.2026 — связи узла discovery-interview ──
|
||||
E('tooling', 'discovery_interview', '§4.30 #55 — реестр'),
|
||||
E('psr_v1', 'discovery_interview', 'R10.1 блок 1 note:\ndiscovery-tooling'),
|
||||
E('discovery_interview', 'sk_brainstorm', 'хэндофф:\nFEATURE-brief'),
|
||||
E('discovery_interview', 'process_analysis', 'граница: слой-источник\n(ADR-009 DI2)'),
|
||||
|
||||
// ── ANTHROPIC DEV-TOOLING 18.05.2026 — связи 5 узлов ──
|
||||
E('psr_v1', 'skill_creator', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'plugin_dev', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'hookify_plugin', 'R10.1 блок 1:\nauthoring-tooling (HK1)'),
|
||||
E('psr_v1', 'claude_setup', 'R10.1 блок 1:\ndev-support'),
|
||||
E('psr_v1', 'context7', 'R10.1 блок 1:\ndev-support'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
|
||||
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
|
||||
// ══════════════════════════════════════════════════
|
||||
CONFLICT('sk_rls', 'ag_rls', 'RLS: граница задана — скил по таблице, агент по diff/PR (spec 2026-05-16)', 'GREEN'),
|
||||
CONFLICT('hookify_plugin', 'hk_pre_claude', 'Закрыто правилом HK1 (ADR-010, PSR_v1 R10.1 v3.13): hookify вызывается только по явному /hookify + обязательный pre-check на коллизию с зарегистрированными хуками; перезапись economy/skill-discipline архитектуры запрещена', 'GREEN'),
|
||||
CONFLICT('mcp_pw', 'sk_parallel', 'Browser is already in use (квирк #2)', 'BLACK'),
|
||||
CONFLICT('ag_pest', 'mcp_redis', 'Квирк 72 устранён 16.05.2026 (commit 0fa1a73 — array-стор в тестах): гонки в Redis при Pest --parallel больше нет', 'GREEN'),
|
||||
CONFLICT('psr_v1', 'claude_md', 'Закрыто §5п.10 CLAUDE.md + хук CLAUDE.md-warn', 'GREEN'),
|
||||
CONFLICT('upm', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('mcp_21st', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('hk_economy', 'superpowers', '§12 — hard-rule уровня 0; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// RUFLO ОРКЕСТРАТОР — фактический реколлаж (iter5, 2026-05-15)
|
||||
// ══════════════════════════════════════════════════
|
||||
// Queen → артефакты установки ruflo init (рой idle, артефакты не задействованы)
|
||||
E('ruflo_queen', 'ruflo_workers', 'координирует\n(0 задач)'),
|
||||
E('ruflo_queen', 'ruflo_agents_catalog', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_commands', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_plugins', 'плагинов ruflo:\n0 установлено'),
|
||||
// MCP-сервер ruflo — связывает половины кластера + читает/пишет память
|
||||
E('ruflo_mcp', 'ruflo_queen', 'инструменты\nуправления роем'),
|
||||
E('ruflo_mcp', 'ruflo_memory', 'читает/пишет\nпамять'),
|
||||
// память ruflo — recall-хук и воркер consolidate демона
|
||||
E('ruflo_recall_hook', 'ruflo_memory', 'запускает\nruflo memory search'),
|
||||
E('ruflo_daemon', 'ruflo_memory', 'воркер consolidate\nобращается к памяти'),
|
||||
// 4 узла-правила → Queen (реколлаж 16.05.2026: ruflo — advisory-подсистема; Pravila §14 — queen-триггер)
|
||||
E('pravila', 'ruflo_queen', '§14:\nqueen-триггер'),
|
||||
E('claude_md', 'ruflo_queen', '§3.5: описывает\n(advisory-подсистема)'),
|
||||
E('psr_v1', 'ruflo_queen', '§14:\ncross-ref'),
|
||||
E('tooling', 'ruflo_queen', '§4.10: реестр\n(advisory-подсистема)'),
|
||||
// memory → ruflo
|
||||
E('mem_ruflo', 'ruflo_queen', 'документирует\nинтеграцию'),
|
||||
|
||||
// 3 конфликта ruflo (3-color, iter2 §4)
|
||||
CONFLICT('ruflo_queen', 'pravila', 'Закрыто реколлажем 16.05.2026: нормативка приведена к рантайму — ruflo переописан в advisory/automation-подсистему, декларация уровня −1 убрана', 'GREEN'),
|
||||
CONFLICT('ruflo_memory', 'mem_state', 'Два хранилища памяти не синхронизированы; память ruflo почти пуста (0 записей)', 'BLACK'),
|
||||
CONFLICT('ruflo_daemon', 'ag_pest', 'Worker-jitter демона ruflo усиливает Pest-квирки 73/77 (квирк 72 устранён 16.05 — его jitter больше не усиливает)', 'BLACK'),
|
||||
];
|
||||
// EDGES — moved to automation-graph-data.js
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3: NODE DETAILS
|
||||
// ════════════════════════════════════════════════════
|
||||
const CATEGORY_LABELS = {
|
||||
rules: 'Правило', plugins: 'Плагин', skills_sp: 'Скил Superpowers',
|
||||
skills_proj: 'Скил проекта', hooks: 'Хук .claude', agents: 'Агент',
|
||||
mcp: 'MCP-сервер', lefthook: 'Lefthook job', memory: 'Memory-файл',
|
||||
ruflo: 'ruflo (оркестратор)'
|
||||
};
|
||||
// CATEGORY_LABELS — moved to automation-graph-data.js
|
||||
|
||||
function nd(desc, when, limits, reportsTo, manages, together, conflicts) {
|
||||
// Backward-compat: old 5-arg signature was nd(desc, reportsTo, manages, together, conflicts).
|
||||
@@ -663,7 +273,7 @@ const NODE_DETAILS = {
|
||||
'Править можно только через скил `/claude-md-management:claude-md-improver` или `:revise-claude-md` (правило §5 п.10). Прямые Edit/Write блокируются хуком предупреждения.',
|
||||
[{ name: 'Pravila', cond: 'всегда подчинён (уровень 2a)' }],
|
||||
[
|
||||
{ name: 'Tooling v2.10', cond: 'ссылается как на реестр инструментов' },
|
||||
{ name: 'Tooling v2.15', cond: 'ссылается как на реестр инструментов' },
|
||||
{ name: 'плагин claude-md-management', cond: 'правило §5 п.10 — единственный канал правок' }
|
||||
],
|
||||
[
|
||||
@@ -1000,7 +610,7 @@ const NODE_DETAILS = {
|
||||
[{ name: 'плагин Superpowers', cond: 'содержит' }],
|
||||
[],
|
||||
[{ name: 'скил worktree', cond: 'parallel-work использует worktree для изоляции' }],
|
||||
[{ name: 'MCP-сервер playwright', desc: 'Браузер уже занят (Browser is already in use) при одновременном запуске нескольких сессий через worktree', type: 'BLACK' }]
|
||||
[{ name: 'MCP-сервер playwright', desc: 'Профили per-cwd hash (квирк #95) → worktrees получают разные mcp-chrome-{hash} директории, не конфликтуют. Same-dir parallel — редкий runtime, регулируется Pravila §15.2 claim', type: 'GREEN' }]
|
||||
),
|
||||
sk_worktree: nd(
|
||||
'Создаёт изолированную копию репозитория (worktree) для рискованной или параллельной работы.',
|
||||
@@ -1231,11 +841,11 @@ const NODE_DETAILS = {
|
||||
mcp_pw: nd(
|
||||
'Управляет браузером — снимает скриншоты, кликает, заполняет формы для smoke- и a11y-тестов.',
|
||||
'При визуальной проверке прототипов (фаза 0), при a11y smoke (axe-core), при UI integration smoke.',
|
||||
'Не для боевых пользователей. На сессию один общий браузер — при parallel-work возможны столкновения (см. квирк #2 в memory).',
|
||||
'Не для боевых пользователей. Профиль persistent кэшируется per-cwd hash (квирк #95 в memory) → разные worktrees получают разные mcp-chrome-{hash} директории и не конфликтуют. Конфликт остаётся только при same-dir parallel (две Claude-сессии в одной dir одновременно вызывают browser).',
|
||||
[{ name: 'CLAUDE.md §3.1 #2', cond: 'активен с фазы 0' }],
|
||||
[],
|
||||
[{ name: 'SessionStart хук', cond: 'используется для визуальной проверки прототипов' }],
|
||||
[{ name: 'parallel-work скил', desc: 'Один shared browser на сессию — конкуренция при параллельной работе через worktrees (memory квирк #2)', type: 'BLACK' }]
|
||||
[{ name: 'parallel-work скил', desc: 'Профили per-cwd hash → worktrees не конфликтуют (квирк #95). Same-dir parallel регулируется Pravila §15.2 claim в CURRENT.md', type: 'GREEN' }]
|
||||
),
|
||||
mcp_gh: nd(
|
||||
'GitHub API — читает/создаёт PR, issues, коммиты, ветки в репозитории CoralMinister/lidpotok.',
|
||||
@@ -1751,6 +1361,88 @@ const NODE_DETAILS = {
|
||||
'Снимок-история, обновляется по ходу спринта.',
|
||||
[], [], []
|
||||
),
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
router_procedure: nd(
|
||||
'Единый источник истины процедуры роутера «задача → узел(ы)» — docs/router-procedure.md v1.0. 5 шагов: hard-floor (§12/§14/§15) → классификация → выбор по триггерам (Tooling Прил. Н §4.X) → проверка связок L1-L12 → исполнение. ADR-011.',
|
||||
'При любой задаче (имплицитно) определяет узел/связку; явно — при разборе routing-решений и в /brain-retro.',
|
||||
'Не вводит новый реестр — формализует процедуру над существующим (Tooling §4.X). Кэша «проверенных цепочек» нет (router-only). Каждая задача — свежая сборка пути.',
|
||||
[{ name: 'Pravila §12/§14/§15', cond: 'hard-floor — шаг 1 процедуры' }, { name: 'CLAUDE.md §3.6', cond: 'cross-ref на router-procedure.md' }],
|
||||
[{ name: 'Tooling Прил. Н §4.X', cond: 'реестр узлов — вход шага 3' }],
|
||||
[{ name: 'observer (Stop-хук)', cond: 'пишет evidence о routing-решениях' }, { name: '/brain-retro', cond: 'факторный анализ routing' }],
|
||||
[]
|
||||
),
|
||||
observer_stophook: nd(
|
||||
'Stop-хук observer (tools/observer-stop-hook.mjs, project-level) — пишет один JSONL-эпизод в docs/observer/episodes-YYYY-MM.jsonl в конце каждого хода + routing-gate. Внутри: transcript-parser (схема v2), routing-detector + choice-detector (provenance), pii-filter (маскирование ПДн). ADR-011 + observer factor-analysis.',
|
||||
'Конец каждого хода (Stop-event). routing-gate: при навязанном методе без routing-тега → decision:block (необойдёмо).',
|
||||
'Только пишет evidence, не вмешивается в нормативку. При внутреннем отказе — маркер observer_error, не тихий пропуск. HK1 §5.3: сосуществует с economy-verifier на Stop (append-chain).',
|
||||
[{ name: 'Pravila §16', cond: 'observer + routing-тег-дисциплина' }, { name: '.claude/settings.json', cond: 'зарегистрирован как Stop-хук' }],
|
||||
[{ name: 'observer-transcript-parser / routing-detector / choice-detector / pii-filter', cond: 'внутренние .mjs модули' }],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'пишет эпизоды' }, { name: '/brain-retro', cond: 'читает то, что хук пишет' }],
|
||||
[{ name: 'hk_verifier', desc: 'HK1 §5.3: оба на Stop-event — коллизии нет (append-chain), оба decision:block отрабатываются', type: 'GREEN' }]
|
||||
),
|
||||
sk_brain_retro: nd(
|
||||
'Проектный скил /brain-retro (.claude/skills/brain-retro/) — раз в спринт читает docs/observer/episodes-*.jsonl и строит факторный анализ: распределение path_type, топ-узлы/связки, вывод исхода, факторная матрица (9 осей × outcome). Анализатор tools/brain-retro-analyzer.mjs.',
|
||||
'Раз в спринт по команде заказчика («брейн-ретро»). Read-only агрегатор.',
|
||||
'Только читает и предлагает кандидатов на корректировку нормативки — не пишет в логи, не правит Tooling/Pravila/PSR_v1. Решение по правкам — за заказчиком.',
|
||||
[{ name: 'Pravila §16', cond: 'evidence-loop, раз в спринт' }, { name: 'PSR_v1 R16', cond: 'brain evidence loop' }],
|
||||
[{ name: 'tools/brain-retro-analyzer.mjs', cond: 'детерминированный анализатор' }],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'читает эпизоды' }],
|
||||
[]
|
||||
),
|
||||
observer_evidence: nd(
|
||||
'Хранилище evidence «мозга» — docs/observer/: помесячные episodes-YYYY-MM.jsonl (схема v2), STATUS.md (панель C1-C5), .read-counter.json (для C3), notes/. Визуализируется страницей docs/observer/dashboard.html (Карта/Лента/Разбор/Агрегат/конфликты; кормится из общего automation-graph-data.js).',
|
||||
'Пишется Stop-хуком (эпизоды) + контролёрами (STATUS.md, счётчик); читается /brain-retro и dashboard.',
|
||||
'ПДн маскируется pii-filter перед записью (§5.4). Помесячное rotation; архив после 12 месяцев. Память ruflo (.swarm/memory.db) — отдельное хранилище, не связано.',
|
||||
[{ name: 'observer Stop-хук', cond: 'источник эпизодов' }],
|
||||
[],
|
||||
[{ name: '/brain-retro', cond: 'читатель' }, { name: 'C3/C4/C5 контролёры', cond: 'счётчик / STATUS / покрытие' }],
|
||||
[]
|
||||
),
|
||||
lh_l1watcher: nd(
|
||||
'Контролёр C1 (lefthook pre-commit job 11, tools/l1-watcher.mjs) — детектор «плагин включён в settings.json без формализации в Tooling Прил. Н». Закрывает трижды повторившийся L1-паттерн (UPM/21st, Sentry/Redis, Anthropic dev-tooling). 0 LLM-вызовов.',
|
||||
'pre-commit при правке .claude/settings.json или docs/Tooling_v8_3.md.',
|
||||
'STRICT: блокирует коммит при drift. Групповые/human-имена разрешаются через tools/.l1-watcher-aliases.txt. ADR-011 spec §6.1.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 11 pre-commit' }, { name: 'ADR-011 §6.1', cond: 'C1' }],
|
||||
[],
|
||||
[{ name: 'tooling', cond: 'сверяет settings.json ↔ Tooling' }, { name: 'C2 cross-ref', cond: 'оба — нормативная консистентность' }],
|
||||
[]
|
||||
),
|
||||
lh_crossref: nd(
|
||||
'Контролёр C2 (lefthook pre-commit job 12, tools/cross-ref-checker.mjs) — детектор version drift между нормативными файлами (Tooling v2.11 collision 17.05). Сверяет версии в §0 cross-refs vs шапки целевых файлов. 0 LLM-вызовов.',
|
||||
'pre-commit при правке Pravila / Tooling / PSR_v1 / CLAUDE.md / MEMORY.md.',
|
||||
'STRICT: блокирует коммит при расхождении версии. Link-anchored детекция + scope-cut по history-маркерам (исторические «наследие»-цепочки не дают ложных срабатываний). ADR-011 spec §6.2.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 12 pre-commit' }, { name: 'ADR-011 §6.2', cond: 'C2' }],
|
||||
[],
|
||||
[{ name: 'claude_md / pravila / tooling / psr_v1', cond: 'сверяет 5 нормативных файлов' }, { name: 'C1 l1-watcher', cond: 'оба — нормативная консистентность' }],
|
||||
[]
|
||||
),
|
||||
lh_obs_obs: nd(
|
||||
'Контролёр C3 (lefthook pre-commit job 13, tools/observer-of-observer.mjs) — счётчик чтений docs/observer/ + 54-недельный self-prune. «Кто наблюдает за наблюдателями»: если evidence-loop не читается ≥54 недель — предлагает архивировать observer.',
|
||||
'pre-commit (каждый коммит) — обновляет/проверяет docs/observer/.read-counter.json.',
|
||||
'Warn-only (скрипт всегда exit 0) — не блокирует. 54 недели (≈год) — порог осознанно поднят заказчиком с 4 недель. ADR-011 spec §6.3.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 13 pre-commit' }, { name: 'ADR-011 §6.3', cond: 'C3' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'читает .read-counter.json' }],
|
||||
[]
|
||||
),
|
||||
lh_status_md: nd(
|
||||
'Контролёр C4 (lefthook post-commit job, tools/status-md-generator.mjs) — генерит docs/observer/STATUS.md (панель: C1-C5 + информационные метрики). Pure JS, Security Guidance #40 compliant.',
|
||||
'post-commit (после каждого коммита) — перегенерит STATUS.md, git add (для следующего коммита).',
|
||||
'Через `|| true` — не блокирует. Метрика «N раз использован» — информационная, не алерт (capability-readiness). ADR-011 spec §6.4.',
|
||||
[{ name: 'lefthook.yml', cond: 'post-commit job' }, { name: 'ADR-011 §6.4', cond: 'C4' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'пишет STATUS.md' }, { name: 'C1/C2/C3', cond: 'агрегирует их сигнал' }],
|
||||
[]
|
||||
),
|
||||
lh_obs_cov: nd(
|
||||
'Контролёр C5 (lefthook pre-commit job 15, tools/observer-coverage-checker.mjs) — observer factor-analysis spec §5.2. Флагует пропуски покрытия (git-активность есть, эпизодов 0) + поломки регистрации (Stop-хук снят из settings.json, post-commit не установлен).',
|
||||
'pre-commit (каждый коммит).',
|
||||
'Warn-only (скрипт всегда exit 0) — не блокирует; находки в docs/observer/STATUS.md строка C5.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 15 pre-commit' }, { name: 'observer factor-analysis §5.2', cond: 'C5' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'проверяет покрытие + регистрацию' }, { name: 'C4 status-md', cond: 'находки в STATUS.md' }],
|
||||
[]
|
||||
),
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
@@ -1847,7 +1539,7 @@ const EDGE_DETAILS = {
|
||||
// ── КОНФЛИКТЫ (8 рёбер; 3 из них имеют ту же пару from/to, что и обычные — здесь объединены под одним ключом) ─
|
||||
'sk_rls->ag_rls': { type: 'конфликт', when: 'граница задана: скил — по таблице, агент — по diff/ветке/PR', transfers: 'coverage', mandatory: 'опционально', rule: 'секции «Граница…» в SKILL.md + rls-reviewer.md (spec 2026-05-16)' },
|
||||
'hookify_plugin->hk_pre_claude': { type: 'конфликт', when: 'hookify plugin генерирует hook — двойное owner-ship vs settings.json', transfers: 'coverage', mandatory: 'опционально', rule: 'нет регламента (plugin vs settings.json)' },
|
||||
'mcp_pw->sk_parallel': { type: 'конфликт', when: 'Playwright и parallel-agents оба требуют изоляцию', transfers: 'coverage', mandatory: 'опционально', rule: 'нет регламента (изоляция worktree vs MCP)' },
|
||||
'mcp_pw->sk_parallel': { type: 'конфликт', when: 'Playwright и parallel-agents оба требуют изоляцию', transfers: 'coverage', mandatory: 'опционально', rule: 'GREEN: квирк #95 — профили per-cwd hash → worktrees не конфликтуют; same-dir parallel под Pravila §15.2 claim' },
|
||||
'ag_pest->mcp_redis': { type: 'конфликт', when: 'Pest --parallel race на Redis cache (quirk 72/77)', transfers: 'coverage', mandatory: 'опционально', rule: 'CLAUDE.md §3.3 #35 (Redis MCP) — race остаётся вне регламента' },
|
||||
'psr_v1->claude_md': { type: 'конфликт', when: 'PSR_v1 уровень 3 vs CLAUDE.md 2a — приоритет CLAUDE.md', transfers: 'контроль', mandatory: 'hard-block', rule: 'CLAUDE.md §1 (priority chain)' },
|
||||
'upm->fd_plugin': { type: 'конфликт', when: 'UPM и FD оба претендуют на UI-решения', transfers: 'coverage', mandatory: 'hard-block', rule: 'PSR_v1 R14.5 (не параллельно)' },
|
||||
@@ -1871,26 +1563,50 @@ const EDGE_DETAILS = {
|
||||
'mem_ruflo->ruflo_queen': { type: 'документирует', when: 'memory-файл хранит историю ruflo-интеграции', transfers: 'данные', mandatory: 'рекомендуется', rule: 'memory/project_ruflo_integration.md' },
|
||||
'ruflo_memory->mem_state': { type: 'конфликт', when: 'два хранилища памяти не синхронизированы; память ruflo почти пуста', transfers: 'coverage', mandatory: 'опционально', rule: 'нет регламента синхронизации (alpha-баг HNSW #1122)' },
|
||||
'ruflo_daemon->ag_pest': { type: 'конфликт', when: 'daemon worker-jitter усиливает частоту Pest-квирка 72', transfers: 'coverage', mandatory: 'опционально', rule: 'memory feedback_environment квирк #93' },
|
||||
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
'claude_md->router_procedure': { type: 'документирует', when: 'CLAUDE.md §3.6 — cross-ref на router-procedure.md v1.0', transfers: 'документация', mandatory: 'обязательно', rule: 'CLAUDE.md §3.6 (single SoT routing procedure)' },
|
||||
'tooling->router_procedure': { type: 'питает', when: 'реестр Прил. Н §4.X — вход шага 3 процедуры роутера', transfers: 'данные', mandatory: 'обязательно', rule: 'router-procedure.md §4.2 шаг 3' },
|
||||
'pravila->router_procedure': { type: 'подчиняет', when: 'hard-floor §12/§14/§15 — шаг 1 процедуры роутера', transfers: 'контроль', mandatory: 'hard-floor', rule: 'router-procedure.md §4.2 шаг 1 (Pravila §12/§14/§15)' },
|
||||
'pravila->observer_stophook': { type: 'подчиняет', when: '§16: observer + routing-тег-дисциплина', transfers: 'контроль', mandatory: 'обязательно', rule: 'Pravila §16.2/§16.7 (ADR-011)' },
|
||||
'observer_stophook->observer_evidence': { type: 'пишет', when: 'конец каждого хода (Stop-event)', transfers: 'данные (эпизод JSONL)', mandatory: 'обязательно (exit-0-safe)', rule: 'ADR-011 §5.2 (observer scope B)' },
|
||||
'pravila->sk_brain_retro': { type: 'подчиняет', when: '§16: факторный анализ раз в спринт', transfers: 'контроль', mandatory: 'по команде заказчика', rule: 'Pravila §16 + PSR_v1 R16' },
|
||||
'sk_brain_retro->observer_evidence': { type: 'читает', when: 'раз в спринт — агрегирует эпизоды', transfers: 'данные', mandatory: 'read-only', rule: 'ADR-011 §5.5 (/brain-retro — читатель)' },
|
||||
'lh_l1watcher->tooling': { type: 'проверяет', when: 'pre-commit при правке settings.json / Tooling', transfers: 'проверка', mandatory: 'STRICT (блокирует)', rule: 'ADR-011 §6.1 (C1) + lefthook.yml job 11' },
|
||||
'lh_crossref->claude_md': { type: 'проверяет', when: 'pre-commit при правке любого из 5 нормативных файлов', transfers: 'проверка', mandatory: 'STRICT (блокирует)', rule: 'ADR-011 §6.2 (C2) + lefthook.yml job 12' },
|
||||
'lh_obs_obs->observer_evidence': { type: 'проверяет', when: 'pre-commit (каждый коммит) — счётчик чтений', transfers: 'проверка', mandatory: 'warn-only', rule: 'ADR-011 §6.3 (C3) + lefthook.yml job 13' },
|
||||
'lh_status_md->observer_evidence': { type: 'пишет', when: 'post-commit — перегенерит STATUS.md', transfers: 'данные', mandatory: 'не блокирует (|| true)', rule: 'ADR-011 §6.4 (C4) + lefthook.yml post-commit' },
|
||||
'lh_obs_cov->observer_evidence': { type: 'проверяет', when: 'pre-commit (каждый коммит) — покрытие + регистрация', transfers: 'проверка', mandatory: 'warn-only', rule: 'observer factor-analysis §5.2 (C5) + lefthook.yml job 15' },
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3.6: NODE META (iter6 — даты, использование, дубли)
|
||||
// SECTION 3.6: NODE META (iter6 → iter8 — даты, использование, дубли)
|
||||
// ════════════════════════════════════════════════════
|
||||
// Данные — фактический снимок: даты из git/changelog/mtime, счётчик uses —
|
||||
// из разбора транскриптов сессий Claude Code за окно META_WINDOW.
|
||||
// Методика и воспроизводимость — план iter6, Приложение А.
|
||||
const META_SNAPSHOT = '16.05.2026'; // дата генерации значений
|
||||
const META_WINDOW = '09–16.05.2026'; // окно подсчёта использования (7 дней)
|
||||
//
|
||||
// iter8 (18.05.2026): окно расширено 09–16.05 → 09–18.05 (10 дней).
|
||||
// Узлы интеграционных волн 17-18.05 (A6 / D3 / C9 / A4 / A3 / A11 / C10 / discovery /
|
||||
// ADT) получают baseline 1 = факт интеграции (коммит + plan/spec/ADR + Tooling §4).
|
||||
// Реальные вызовы (за пределами интеграций) не подсчитаны — транскрипты Claude Code
|
||||
// не доступны как источник в репо. mcp_figma — uses=0, usesSrc='DEFERRED'.
|
||||
// null сохраняется только для принципиально неизмеримых: правила, superpowers,
|
||||
// hookify_plugin, ruflo_daemon, ruflo_memory, фоновые economy/skill-discipline
|
||||
// хуки (hk_self_check / skill_marker / skill_check / state_guard / postcompact /
|
||||
// verifier / ruflo_queen) и старые mem_* без активных Read-вызовов в окне.
|
||||
const META_SNAPSHOT = '20.05.2026'; // дата генерации значений
|
||||
const META_WINDOW = '09–20.05.2026'; // окно подсчёта использования (12 дней)
|
||||
|
||||
// uses: number — измеримый узел (0 = реально простаивал); null — измерить нельзя
|
||||
// (узел-правило / плагин-обёртка / автономный демон / пассивное хранилище) → «нет данных».
|
||||
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | '—'
|
||||
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | 'интеграция' | 'DEFERRED' | '—'
|
||||
const NODE_META = {
|
||||
// ── ПРАВИЛА (4) — узлы-правила, напрямую не вызываются ──
|
||||
pravila: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_md: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
psr_v1: { since: '09.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
tooling: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
pravila: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_md: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
psr_v1: { since: '09.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
tooling: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
|
||||
// ── ПЛАГИНЫ (5) ──
|
||||
superpowers: { since: '09.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
@@ -1978,36 +1694,40 @@ const NODE_META = {
|
||||
mem_github: { since: '07.05.2026', changed: '15.05.2026', uses: 33, usesSrc: 'memory-чтение' },
|
||||
|
||||
// ── RUFLO ОРКЕСТРАТОР (9) — все внедрены big-bang'ом 15.05.2026 ──
|
||||
ruflo_queen: { since: '15.05.2026', changed: '16.05.2026', uses: 0, usesSrc: 'инспекция' },
|
||||
ruflo_plugins: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция' },
|
||||
ruflo_workers: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция' },
|
||||
ruflo_agents_catalog: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция',
|
||||
// 🔇 ИЗОЛИРОВАН 18.05.2026 (Rec2 SYSTEM-аудита): hooks сняты из settings.json,
|
||||
// MCP удалён из .mcp.json, PM2 daemon stopped+saved-empty. См. Pravila §14.9 /
|
||||
// Tooling §4.10 / memory feedback_ruflo_isolated.md. uses=0 — реальные вызовы 0.
|
||||
ruflo_queen: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'инспекция', isolated: true },
|
||||
ruflo_plugins: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'инспекция', isolated: true },
|
||||
ruflo_workers: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'инспекция', isolated: true },
|
||||
ruflo_agents_catalog: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'инспекция', isolated: true,
|
||||
dupNote: '100 определений агентов дублируют реестр агентов; каталог буквально содержит 2 проектных агента' },
|
||||
ruflo_commands: { since: '15.05.2026', changed: '—', uses: 0, usesSrc: 'инспекция',
|
||||
ruflo_commands: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'инспекция', isolated: true,
|
||||
dupNote: '88 slash-команд дублируют роль скилов — именованные вызываемые процедуры; команды инертны' },
|
||||
ruflo_daemon: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
ruflo_memory: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—',
|
||||
dupNote: 'дублирует роль 16 memory-файлов проекта — постоянная память между сессиями; уже ⚫-конфликт с project_state' },
|
||||
ruflo_mcp: { since: '15.05.2026', changed: '—', uses: 36, usesSrc: 'MCP' },
|
||||
ruflo_recall_hook: { since: '15.05.2026', changed: '—', uses: 220, usesSrc: 'хук' },
|
||||
ruflo_daemon: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'pm2 stopped+deleted', isolated: true },
|
||||
ruflo_memory: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'не читается', isolated: true,
|
||||
dupNote: 'дублирует роль 16 memory-файлов проекта — постоянная память между сессиями; ⚫-конфликт с project_state снят изоляцией' },
|
||||
ruflo_mcp: { since: '15.05.2026', changed: '18.05.2026', uses: 36, usesSrc: 'MCP (был активен 15-17.05; снят 18.05)', isolated: true },
|
||||
ruflo_recall_hook: { since: '15.05.2026', changed: '18.05.2026', uses: 220, usesSrc: 'хук (был активен 15-17.05; снят 18.05)', isolated: true },
|
||||
|
||||
// ── MEMORY +1 (артефакт ruflo big-bang) ──
|
||||
mem_ruflo: { since: '15.05.2026', changed: '16.05.2026', uses: 18, usesSrc: 'memory-чтение' },
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — узлы добавлены по полному аудиту карты ──
|
||||
// uses новых узлов по транскриптам не измерялись (null = нет данных).
|
||||
skill_creator: { since: '11.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_setup: { since: '11.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
plugin_dev: { since: '—', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
context7: { since: '—', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 + iter8 18.05.2026 ──
|
||||
// ADT (18.05): baseline 1 = факт формализации в Tooling §4.31–4.35 + интеграционный коммит 515acb6.
|
||||
skill_creator: { since: '11.05.2026', changed: '18.05.2026', uses: 1, usesSrc: 'интеграция' },
|
||||
claude_setup: { since: '11.05.2026', changed: '18.05.2026', uses: 1, usesSrc: 'интеграция' },
|
||||
plugin_dev: { since: '—', changed: '18.05.2026', uses: 1, usesSrc: 'интеграция' },
|
||||
context7: { since: '—', changed: '18.05.2026', uses: 1, usesSrc: 'интеграция' },
|
||||
// Фоновые economy/skill-discipline хуки — измерение требует доступа к user-level логам, не репо.
|
||||
hk_self_check: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_skill_marker: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_skill_check: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_state_guard: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_postcompact: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_verifier: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_ruflo_queen: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
sk_regression: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
hk_ruflo_queen: { since: '15.05.2026', changed: '18.05.2026', uses: 0, usesSrc: 'снят 18.05', isolated: true }, // 🔇 ИЗОЛИРОВАН (см. ruflo блок выше)
|
||||
sk_regression: { since: '15.05.2026', changed: '—', uses: 2, usesSrc: 'скил' }, // verification в Sprint 1-6
|
||||
mem_audit_b: { since: '08.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mem_audit_c: { since: '07.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mem_suppliercrm: { since: '10.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
@@ -2017,43 +1737,58 @@ const NODE_META = {
|
||||
mem_sprint2: { since: '15.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mem_sprint3: { since: '16.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
|
||||
// ── A6 ARCHITECTURE-TOOLING 17.05.2026 ──
|
||||
adr_kit: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
arch_patterns: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mermaid_skill: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
deptrac: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
// ── A6 ARCHITECTURE-TOOLING 17.05.2026 (iter8: baseline 1 = факт интеграции) ──
|
||||
adr_kit: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
arch_patterns: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
mermaid_skill: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
deptrac: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
|
||||
// ── D3 AUDIT-SECURITY 17.05.2026 ──
|
||||
tob_skills: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
sec_guidance: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'хук' },
|
||||
sk_security_review: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
sk_audit_portal: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
// ── D3 AUDIT-SECURITY 17.05.2026 (iter8: baseline 1) ──
|
||||
tob_skills: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
sec_guidance: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'хук' },
|
||||
sk_security_review: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
sk_audit_portal: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
|
||||
// ── C9 PROJECT-MANAGEMENT-TOOLING 17.05.2026 ──
|
||||
ccpm: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
product_mgmt: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'плагин' },
|
||||
// ── C9 PROJECT-MANAGEMENT-TOOLING 17.05.2026 (iter8: baseline 1) ──
|
||||
ccpm: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
product_mgmt: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
|
||||
// ── A4 DESIGN-TOOLING 17.05.2026 ──
|
||||
mcp_figma: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mcp_icons: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'MCP' },
|
||||
design_plugin:{ since: '17.05.2026', changed: '—', uses: null, usesSrc: 'плагин' },
|
||||
// ── A4 DESIGN-TOOLING 17.05.2026 (iter8: baseline 1, mcp_figma=0 DEFERRED) ──
|
||||
mcp_figma: { since: '17.05.2026', changed: '—', uses: 0, usesSrc: 'DEFERRED' },
|
||||
mcp_icons: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'MCP' },
|
||||
design_plugin:{ since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
|
||||
// ── A3 INTEGRATION-TOOLING (17.05.2026) ──
|
||||
ag_apidocs: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
mcp_openapi: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
// ── A3 INTEGRATION-TOOLING (17.05.2026, iter8: baseline 1) ──
|
||||
ag_apidocs: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
mcp_openapi: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
|
||||
// ── A11 ML-AI-TOOLING (17.05.2026) ──
|
||||
claude_api: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
promptfoo: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'CLI' },
|
||||
data_scientist: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
// ── A11 ML-AI-TOOLING (17.05.2026, iter8: baseline 1) ──
|
||||
claude_api: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
promptfoo: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'CLI' },
|
||||
data_scientist: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
|
||||
// ── C10 BUSINESS-PROCESS (17.05.2026) ──
|
||||
ops_plugin: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'плагин' },
|
||||
process_modeling: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
process_analysis: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
// ── C10 BUSINESS-PROCESS (17.05.2026, iter8: baseline 1) ──
|
||||
ops_plugin: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
process_modeling: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
process_analysis: { since: '17.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
|
||||
// ── DISCOVERY-TOOLING (18.05.2026) ──
|
||||
discovery_interview: { since: '18.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
|
||||
// ── DISCOVERY-TOOLING (18.05.2026, iter8: factual в сессии) ──
|
||||
// snapshot 2026-05-18-system-audit-brain.md (утро) + это интервью (вечер) + последующие вызовы
|
||||
discovery_interview: { since: '18.05.2026', changed: '—', uses: 3, usesSrc: 'скил, factual' },
|
||||
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
// uses: observer_stophook=31 эпизодов; lh_obs_obs/status_md/obs_cov=112 коммитов с 19.05
|
||||
// (glob-less, каждый коммит); lh_l1watcher=10, lh_crossref=13 (коммиты по glob с 19.05);
|
||||
// observer_evidence=0 (.read-counter.json — 0 чтений); router_procedure=null (rule-like).
|
||||
router_procedure: { since: '19.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
observer_stophook: { since: '19.05.2026', changed: '—', uses: 31, usesSrc: 'хук (эпизоды)' },
|
||||
sk_brain_retro: { since: '19.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
observer_evidence: { since: '19.05.2026', changed: '—', uses: 0, usesSrc: 'observer counter' },
|
||||
lh_l1watcher: { since: '19.05.2026', changed: '—', uses: 10, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_crossref: { since: '19.05.2026', changed: '—', uses: 13, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_obs_obs: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_status_md: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_obs_cov: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
};
|
||||
|
||||
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
|
||||
@@ -2087,130 +1822,10 @@ const DUP_NODE_SET = new Set(DUP_BY_NODE.keys()); // 12 узлов-членов
|
||||
// (NODE_SECTION). Часть разделов пока пустая — это бизнес-домены, под которые
|
||||
// в карте dev-автоматики ещё нет узлов. Основа будущего «мозга»: 1 раздел =
|
||||
// 1 playbook «как и что делать».
|
||||
const SECTION_BUCKETS = [
|
||||
{ id: 'A', label: 'Технические и продуктовые' },
|
||||
{ id: 'B', label: 'Коммуникации' },
|
||||
{ id: 'C', label: 'Бизнес и операции' },
|
||||
{ id: 'D', label: 'Право и комплаенс' },
|
||||
{ id: 'E', label: 'Мета и управление' },
|
||||
];
|
||||
const SECTIONS = [
|
||||
{ id: 'A1', bucket: 'A', label: 'Программирование — backend' },
|
||||
{ id: 'A2', bucket: 'A', label: 'Программирование — frontend' },
|
||||
{ id: 'A3', bucket: 'A', label: 'Программирование — интеграции (API, вебхуки)' },
|
||||
{ id: 'A4', bucket: 'A', label: 'Дизайн (UI/UX, графика, бренд)' },
|
||||
{ id: 'A5', bucket: 'A', label: 'Тестирование, QA и отладка' },
|
||||
{ id: 'A6', bucket: 'A', label: 'Архитектура систем' },
|
||||
{ id: 'A7', bucket: 'A', label: 'DevOps, инфраструктура, деплой' },
|
||||
{ id: 'A8', bucket: 'A', label: 'Информационная безопасность' },
|
||||
{ id: 'A9', bucket: 'A', label: 'Работа с данными (БД, миграции, RLS)' },
|
||||
{ id: 'A10', bucket: 'A', label: 'Аналитика и отчётность (BI)' },
|
||||
{ id: 'A11', bucket: 'A', label: 'ML / AI-разработка' },
|
||||
{ id: 'B1', bucket: 'B', label: 'Голосовое общение по телефону' },
|
||||
{ id: 'B2', bucket: 'B', label: 'Мессенджеры' },
|
||||
{ id: 'B3', bucket: 'B', label: 'Электронная почта' },
|
||||
{ id: 'B4', bucket: 'B', label: 'SMS-рассылки' },
|
||||
{ id: 'B5', bucket: 'B', label: 'Видеосвязь' },
|
||||
{ id: 'B6', bucket: 'B', label: 'Чат на сайте / онлайн-консультант' },
|
||||
{ id: 'B7', bucket: 'B', label: 'Социальные сети' },
|
||||
{ id: 'B8', bucket: 'B', label: 'Push / in-app уведомления' },
|
||||
{ id: 'C1', bucket: 'C', label: 'Маркетинг и лидогенерация' },
|
||||
{ id: 'C2', bucket: 'C', label: 'Продажи' },
|
||||
{ id: 'C3', bucket: 'C', label: 'Квалификация и обработка лидов' },
|
||||
{ id: 'C4', bucket: 'C', label: 'Работа с поставщиками лидов' },
|
||||
{ id: 'C5', bucket: 'C', label: 'Клиентский успех, поддержка, удержание' },
|
||||
{ id: 'C6', bucket: 'C', label: 'Финансы — биллинг и тарификация' },
|
||||
{ id: 'C7', bucket: 'C', label: 'Финансы — бухгалтерия и налоги' },
|
||||
{ id: 'C8', bucket: 'C', label: 'HR и управление персоналом' },
|
||||
{ id: 'C9', bucket: 'C', label: 'Управление проектами' },
|
||||
{ id: 'C10', bucket: 'C', label: 'Бизнес-процессы (общее)' },
|
||||
{ id: 'D1', bucket: 'D', label: 'Юриспруденция и договорная работа' },
|
||||
{ id: 'D2', bucket: 'D', label: 'Защита ПДн (152-ФЗ, РКН)' },
|
||||
{ id: 'D3', bucket: 'D', label: 'Аудит и управление рисками' },
|
||||
{ id: 'E1', bucket: 'E', label: 'Мета — правила и нормативка' },
|
||||
{ id: 'E2', bucket: 'E', label: 'Мета — оркестрация и автоматизация (Claude-воркфлоу)' },
|
||||
{ id: 'E3', bucket: 'E', label: 'Документация' },
|
||||
{ id: 'E4', bucket: 'E', label: 'Управление знаниями и память' },
|
||||
{ id: 'E5', bucket: 'E', label: 'Стратегия и принятие решений' },
|
||||
{ id: 'E6', bucket: 'E', label: 'Обучение и онбординг' },
|
||||
{ id: 'E7', bucket: 'E', label: 'Исследования' },
|
||||
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
|
||||
];
|
||||
// Узел -> раздел. Покрывает все 125 узлов карты.
|
||||
const NODE_SECTION = {
|
||||
// правила (4)
|
||||
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
|
||||
// плагины (5)
|
||||
superpowers: 'E2', fd_plugin: 'A4', upm: 'A4', claude_md_mgmt: 'E1', hookify_plugin: 'E2',
|
||||
// скилы superpowers (14)
|
||||
sk_brainstorm: 'E5', sk_wplans: 'E2', sk_eplans: 'E2', sk_subagent: 'E2',
|
||||
sk_tdd: 'A5', sk_verify: 'A5', sk_debug: 'A5', sk_parallel: 'E2',
|
||||
sk_worktree: 'E2', sk_pr: 'E2', sk_coderev: 'A5', sk_spreview: 'A5',
|
||||
sk_wskills: 'E2', sk_elements: 'E3',
|
||||
// скилы проекта (2)
|
||||
sk_rls: 'A9', sk_qitem: 'E3',
|
||||
// хуки (5)
|
||||
hk_session: 'E4', hk_economy: 'E2', hk_pre_claude: 'E1', hk_post_md: 'E3', hk_post_schema: 'A9',
|
||||
// агенты (11)
|
||||
ag_explore: 'E2', ag_general: 'E2', ag_plan: 'E2', ag_pest: 'A5', ag_guide: 'E6',
|
||||
ag_statusline: 'E2', ag_hookify: 'E2', ag_pcreator: 'E2', ag_pvalid: 'E2',
|
||||
ag_skreview: 'E2', ag_rls: 'A9',
|
||||
// MCP-серверы (7)
|
||||
mcp_21st: 'A4', mcp_pw: 'A5', mcp_gh: 'A7', mcp_boost: 'A1',
|
||||
mcp_redis: 'A7', mcp_sentry: 'A7', mcp_semgrep: 'A8',
|
||||
// lefthook jobs (10)
|
||||
lh_mdlint: 'E3', lh_cspell: 'E3', lh_stylelint: 'A2', lh_eslint: 'A2',
|
||||
lh_lychee: 'E3', lh_gitleaks: 'A8', lh_gitleaks2: 'A8', lh_pint: 'A1',
|
||||
lh_larastan: 'A1', lh_squawk: 'A9',
|
||||
// memory files (16)
|
||||
mem_user: 'E4', mem_comm: 'E4', mem_env: 'E4', mem_sp: 'E4', mem_plugins: 'E4',
|
||||
mem_handoff: 'E4', mem_redesign: 'E4', mem_devindices: 'E4', mem_phase1: 'E4',
|
||||
mem_state: 'E4', mem_brain: 'E4', mem_supplier: 'E4', mem_audit: 'E4',
|
||||
mem_archive: 'E4', mem_github: 'E4', mem_ruflo: 'E4',
|
||||
// ruflo (9)
|
||||
ruflo_queen: 'E2', ruflo_plugins: 'E2', ruflo_workers: 'E2', ruflo_agents_catalog: 'E2',
|
||||
ruflo_commands: 'E2', ruflo_daemon: 'E2', ruflo_memory: 'E4', ruflo_mcp: 'E2',
|
||||
ruflo_recall_hook: 'E4',
|
||||
// АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — новые узлы
|
||||
skill_creator: 'E8', claude_setup: 'E8', plugin_dev: 'E2', context7: 'E7',
|
||||
hk_self_check: 'E2', hk_skill_marker: 'E2', hk_skill_check: 'E2', hk_state_guard: 'E2',
|
||||
hk_postcompact: 'E2', hk_verifier: 'E2', hk_ruflo_queen: 'E2',
|
||||
sk_regression: 'A5',
|
||||
mem_audit_b: 'E4', mem_audit_c: 'E4', mem_suppliercrm: 'E4', mem_audit12: 'E4',
|
||||
mem_audit14: 'E4', mem_sprint1: 'E4', mem_sprint2: 'E4', mem_sprint3: 'E4',
|
||||
// A6 architecture-tooling 17.05.2026 — раздел «Архитектура систем» наполнен (+deptrac)
|
||||
adr_kit: 'A6', arch_patterns: 'A6', mermaid_skill: 'A6', deptrac: 'A6',
|
||||
// D3 audit-security 17.05.2026 — раздел «Аудит и управление рисками» наполнен
|
||||
tob_skills: 'D3', sec_guidance: 'D3', sk_security_review: 'D3', sk_audit_portal: 'D3',
|
||||
// C9 project-management-tooling 17.05.2026 — раздел «Управление проектами» наполнен
|
||||
ccpm: 'C9', product_mgmt: 'C9',
|
||||
// A4 design-tooling 17.05.2026 — раздел «Дизайн (UI/UX, графика, бренд)» расширен (3→6 узлов)
|
||||
mcp_figma: 'A4', mcp_icons: 'A4', design_plugin: 'A4',
|
||||
// A3 integration-tooling 17.05.2026 — раздел «Программирование — интеграции» наполнен
|
||||
ag_apidocs: 'A3', mcp_openapi: 'A3',
|
||||
// A11 ml-ai-tooling 17.05.2026 — раздел «ML / AI-разработка» наполнен
|
||||
claude_api: 'A11', promptfoo: 'A11', data_scientist: 'A11',
|
||||
// C10 business-process 17.05.2026 — раздел «Бизнес-процессы (общее)» наполнен
|
||||
ops_plugin: 'C10', process_modeling: 'C10', process_analysis: 'C10',
|
||||
// discovery-interview 18.05.2026 — раздел E5 «Стратегия и принятие решений» (рядом с brainstorming)
|
||||
discovery_interview: 'E5',
|
||||
};
|
||||
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
|
||||
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
|
||||
// частично кросс-реф существующих интеграционных инструментов. NODE_SECTION 1:1 не трогается.
|
||||
const NODE_SECTION_SECONDARY = {
|
||||
mcp_boost: ['A3'],
|
||||
context7: ['A3'],
|
||||
ag_pest: ['A3'],
|
||||
mcp_semgrep: ['A3'],
|
||||
mcp_sentry: ['A3'],
|
||||
// C10 business-process 17.05.2026 — кросс-реф reuse-инструментов раздела «Бизнес-процессы»
|
||||
mermaid_skill: ['C10'],
|
||||
arch_patterns: ['C10'],
|
||||
ccpm: ['C10'],
|
||||
product_mgmt: ['C10'],
|
||||
sk_wplans: ['C10'],
|
||||
};
|
||||
// SECTION_BUCKETS — moved to automation-graph-data.js
|
||||
// SECTIONS — moved to automation-graph-data.js
|
||||
// NODE_SECTION — moved to automation-graph-data.js
|
||||
// NODE_SECTION_SECONDARY — moved to automation-graph-data.js
|
||||
// Производные индексы для рендера панели и Паспорта.
|
||||
const SECTION_BY_ID = new Map(SECTIONS.map(s => [s.id, s]));
|
||||
const SECTION_NODES = new Map(SECTIONS.map(s => [s.id, []]));
|
||||
@@ -2250,18 +1865,7 @@ const WISHLIST = [
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 4: VIS INIT
|
||||
// ════════════════════════════════════════════════════
|
||||
const GROUPS = {
|
||||
rules: { color: { background: '#073642', border: '#268bd2', highlight: { border: '#93a1a1', background: '#0d4a5a' } }, font: { color: '#fdf6e3', size: 13, bold: true } },
|
||||
plugins: { color: { background: '#001a00', border: '#859900', highlight: { border: '#b8cc00', background: '#002600' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
skills_sp: { color: { background: '#1a0033', border: '#6c71c4', highlight: { border: '#9b9fea', background: '#250047' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
skills_proj: { color: { background: '#2d0020', border: '#d33682', highlight: { border: '#e869a8', background: '#3d0028' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
hooks: { color: { background: '#002233', border: '#2aa198', highlight: { border: '#4dd7ce', background: '#003344' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
agents: { color: { background: '#1a1200', border: '#b58900', highlight: { border: '#e0ad00', background: '#261a00' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
mcp: { color: { background: '#2d1200', border: '#cb4b16', highlight: { border: '#ff6b30', background: '#3d1900' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
lefthook: { color: { background: '#2d0000', border: '#dc322f', highlight: { border: '#ff5f5c', background: '#3d0000' } }, font: { color: '#fdf6e3', size: 10 } },
|
||||
memory: { color: { background: '#112233', border: '#586e75', highlight: { border: '#839496', background: '#1a2f40' } }, font: { color: '#eee8d5', size: 10 } },
|
||||
ruflo: { color: { background: '#332100', border: '#ff8800', highlight: { border: '#ffaa33', background: '#4d3300' } }, font: { color: '#fdf6e3', size: 12, bold: true } },
|
||||
};
|
||||
// GROUPS — moved to automation-graph-data.js
|
||||
|
||||
const nodesDS = new vis.DataSet(NODES);
|
||||
const edgesDS = new vis.DataSet(EDGES);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# SYSTEM-аудит «мозга» — 18.05.2026
|
||||
|
||||
Результат режима SYSTEM скила `discovery-interview`. Синтез-ориентация по состоянию
|
||||
системы автоматизации Лидерры («мозг» = карта `docs/automation-graph.html` + тулчейн).
|
||||
|
||||
## Запрос ориентации
|
||||
|
||||
Scope: **весь мозг, 125 узлов**. Заказчик попросил проверить и оптимизировать работу
|
||||
узлов по пяти осям: (1) здоровье новых узлов, (2) устранение конфликтов,
|
||||
(3) корректность выбора узла под задачу (routing), (4) связки 2+ узлов для синергии,
|
||||
(5) пересмотр правил/запретов ради эффективности — качества и скорости.
|
||||
|
||||
## Состояние
|
||||
|
||||
Карта `docs/automation-graph.html`: **125 узлов / 135 рёбер**, конфликты **🔴0 / ⚫2 / 🟢9**
|
||||
(11 конфликтных рёбер). Тулчейн — **60 формализованных позиций** (29 phase-active +
|
||||
30 off-phase + 1 historic). Последняя интеграция — #56–60 Anthropic dev-tooling (push
|
||||
`515acb6`, 18.05).
|
||||
|
||||
> **UPDATE 18.05.2026 вечер:** ⚫1 `mcp_pw ↔ sk_parallel` понижен до 🟢 после
|
||||
> верификации квирка #95 — профиль Playwright MCP хэшируется per-cwd → worktrees
|
||||
> получают разные `mcp-chrome-{hash}` директории, не конфликтуют. README playwright-mcp
|
||||
> прямо: конфликт — только для клиентов «sharing the same workspace». Same-dir parallel
|
||||
> регулируется Pravila §15.2 claim в `docs/sessions/CURRENT.md`. Эффект: ⚫3 → ⚫2,
|
||||
> 🟢8 → 🟢9. Оба оставшихся ⚫ — ruflo (после изоляции 18.05 dormant).
|
||||
|
||||
### Ось 1 — здоровье новых узлов
|
||||
|
||||
С iter7 (16.05, 83 узла) мозг вырос на ~42 узла серией интеграций A6→D3→C9→A4→A3→A11→
|
||||
C10→anthropic-dev-tooling. Каждая интеграция проходила конфликт-аудит → **0 новых
|
||||
структурных конфликтов**, узлы интегрированы чисто. Паспорт NODE_META (since / changed /
|
||||
section) синхронизирован интеграциями — покрывает все 125 узлов, **не gap**.
|
||||
|
||||
Реальные gap'ы:
|
||||
|
||||
- **Теплокарта `uses` застыла.** `META_SNAPSHOT = 16.05.2026`, `META_WINDOW = 09–16.05.2026`.
|
||||
~30 узлов волны 17–18.05 в этом окне физически не существовали → их `uses` = null/0
|
||||
не от неиспользования, а от того, что окно их старше. Режим карты «🔥 По использованию»
|
||||
на самом свежем слое вводит в заблуждение. 51 из 125 узлов имеют `uses: null`.
|
||||
- **Хвост «формализован, но не отработан».** process-modeling, process-analysis,
|
||||
discovery-interview, operations, ccpm, product-management, promptfoo, data-scientist —
|
||||
формализованы, но фактическое число вызовов неизвестно (теплокарта их не видит).
|
||||
mcp_figma — узел в статусе DEFERRED. Мозг накапливает декларированную, но не
|
||||
проверенную в бою ёмкость.
|
||||
|
||||
### Ось 2 — конфликты
|
||||
|
||||
🔴0 структурных — все закрыты правилами. 2 ⚫ (после downgrade 18.05 вечер):
|
||||
|
||||
1. ~~`mcp_pw ↔ sk_parallel`~~ — **🟢 закрыт**: квирк #95 (профили per-cwd hash → worktrees
|
||||
не конфликтуют) + Pravila §15.2 claim для same-dir parallel. Текст nd() в карте
|
||||
ссылался на «квирк #2», но memory[#2] — это taskkill, не Playwright; реальный источник
|
||||
— квирк #95 (опровергает hypothesis shared-browser).
|
||||
2. `ruflo_memory ↔ mem_state` — два хранилища памяти не синхронизированы; ruflo-память
|
||||
почти пуста (0 записей + 2 HNSW-призрака #1122). **После изоляции 18.05 — dormant.**
|
||||
3. `ruflo_daemon ↔ ag_pest` — daemon worker-jitter усиливает Pest-квирки 73/77.
|
||||
**После изоляции 18.05 — dormant** (daemon stopped, dump.pm2=[]).
|
||||
|
||||
**Системное наблюдение: оба оставшихся ⚫ — ruflo, оба dormant.** Реальное runtime-трение
|
||||
— ноль. ruflo сохранён как артефакт, queen-триггер dormant, артефакты можно реактивировать
|
||||
по плану в `feedback_ruflo_isolated.md`.
|
||||
|
||||
### Ось 3 — корректность routing (задача→узел)
|
||||
|
||||
Управляется: CLAUDE.md §3 (карта по фазам/задачам, 60 строк), PSR_v1 R1/R9/R13
|
||||
(классификация + decision matrix), per-integration конфликт-аудиты с границами
|
||||
(DI1–6, OPS1–5, TB1, AK1… — закреплены в ADR-003..010).
|
||||
|
||||
Сильно: каждая интеграция авторила границы явно — routing-дисциплина высокая,
|
||||
дрейф ловится конфликт-аудитом.
|
||||
|
||||
Слабость: **PSR_v1 R13 decision-matrix покрывает только UI/код-задачи.** 30 off-phase
|
||||
инструментов (#31–60 — половина тулчейна) живут в R10.1 как плоский 3-блочный реестр с
|
||||
прозаическим «когда инвокировать», без матрицы. Выбор между process-modeling /
|
||||
process-analysis / operations / discovery-interview / brainstorming для «процессной»
|
||||
задачи = чтение 5 прозаических описаний. Routing-знание рассыпано по CLAUDE.md §3 +
|
||||
R10.1 + ADR + конфликт-коды — единого «задача X → узел Y» для off-phase нет.
|
||||
|
||||
### Ось 4 — синергия (связки 2+ узлов)
|
||||
|
||||
Карта кодирует синергию в NODE_DETAILS (поле «С кем работает одновременно») и
|
||||
NODE_SECTION_SECONDARY (кросс-реф reuse-инструментов).
|
||||
|
||||
Рабочие цепочки: brainstorming→writing-plans→subagent-driven-development (канон эпика);
|
||||
discovery-interview FEATURE→brainstorming (хэндофф brief); process-modeling↔process-analysis
|
||||
(as-is↔to-be); mermaid рендерит для operations/adr-kit/process-modeling.
|
||||
|
||||
Недоиспользуемые связки: discovery-interview SYSTEM + audit-portal (ориентация→вердикт);
|
||||
openapi-mcp + api-docs agent + Boost (интеграционная разработка); systematic-debugging +
|
||||
redis/sentry MCP (рантайм-баги).
|
||||
|
||||
Gap: синергия размазана по 125 полям «together», сводного «рекомендованные связки» нет —
|
||||
а заказчик явно его просит.
|
||||
|
||||
### Ось 5 — правила/запреты (эффективность)
|
||||
|
||||
PSR_v1 — на момент утреннего среза 15 правил R0–R14 (R15-слот пуст после v2.0). История
|
||||
v1.0→v3.13 — свод рос реактивно, закрывая трения по мере обнаружения. (Rec5 закрытие —
|
||||
R15 «Off-phase routing» введён v3.14 на свободный слот; см. UPDATE ниже.)
|
||||
|
||||
- **Перекос в UI.** R1–R9, R11–R14 — почти целиком routing UI-фич (Superpowers vs
|
||||
Frontend Design, фазы R2, UI-генераторы UPM/21st). Off-phase тулинг (30 инструментов)
|
||||
регулируется только R10.1 + меткой «вне R6/R14». UI-аппарат огромен, off-phase-аппарат
|
||||
тонкий — при том что off-phase множество выросло 3→30.
|
||||
- **Запрет-разрастание.** CLAUDE.md §5 — 12 пунктов (§5 п.12 — tombstone «Резерв снят»);
|
||||
Pravila — §12/§14/§15 hard-rules + 15 нумерованных правил; PSR_v1 R0.6 — 10 hard-стопов.
|
||||
- **Скорость.** Gate-аппарат R0→R1→R9→R13→R2 спроектирован под UI-фичу, но текущая
|
||||
работа в основном off-phase / документация / тулинг. Режим «экономия» частично лечит,
|
||||
но мозг по-прежнему фронт-лоадит UI-feature gate на каждую задачу.
|
||||
|
||||
> **UPDATE 18.05.2026 вечер (аудит дисциплины R15):** PSR_v1 R15 «Off-phase routing»
|
||||
> (введён v3.14, Rec5) проверен против R0/R6/R10/R14 — содержательных противоречий
|
||||
> нет: R15.1 codifies «off-phase вне UI-фильтров», R15.6 разграничивает UI-пул,
|
||||
> R15.4 — hard-rules перевешивают. routing-off-phase.md прогнан на 7 задачах
|
||||
> (5 прямых + 2 граничных) — 7/7 routed cleanly, ADR-границы работают. 3 minor-находки
|
||||
> исправлены: M1 — note про UI-пул #31/#32 как делегирующие строки (routing-off-phase.md
|
||||
> v1.1); M2 — R15.1 +абзац «R15 — пост-R1 слой» (PSR_v1 in-place); M3 — +строка
|
||||
> «диагностика конверсии» → process-analysis #53. Перекос UI-аппарата (R1–R14) над
|
||||
> off-phase остаётся структурным, но R15 — корректный противовес; дальнейшее
|
||||
> выравнивание — отдельная задача, не блокер.
|
||||
|
||||
## Что открыто
|
||||
|
||||
- **iter8 не сделан** — теплокарта NODE_META не пересобиралась с 16.05 (2 интеграционные
|
||||
волны спустя).
|
||||
- **ruflo не отревизован** — keep/trim-решение по advisory-подсистеме не принято;
|
||||
2 из 3 живых конфликтов и jitter-вред Pest висят.
|
||||
- **Off-phase routing** — нет decision-аида для 30 инструментов #31–60.
|
||||
- **Связки** — нет сводной карты-панели «рекомендованные комбо».
|
||||
- **Ребаланс PSR_v1** — off-phase множество удесятерилось без своего раздела правил.
|
||||
- **WISHLIST карты:** W1 (K7-spike — починка embeddings ruflo, статус `next`),
|
||||
W2–W4 (мост claude-mem→ReasoningBank + ремонтник, `blocked` на W1) — встроенный
|
||||
backlog развития мозга, не двигался.
|
||||
|
||||
## Источники
|
||||
|
||||
- Карта — `docs/automation-graph.html` (NODE_SECTION стр. 2135, NODE_META стр. 1883,
|
||||
WISHLIST стр. 2230).
|
||||
- Правила — `docs/Plugin_stack_rules_v1.md` v3.13 (R0–R14), `CLAUDE.md` v2.15 §3/§5,
|
||||
`docs/Tooling_v8_3.md` Прил. Н v2.14, Pravila §12/§14/§15.
|
||||
- Память — `project_automation_map.md`, `project_anthropic_dev_tooling.md`,
|
||||
`feedback_plugin_paired_stack.md`.
|
||||
- ADR — `docs/adr/003..010` (границы интеграций).
|
||||
- git log — origin/main `515acb6` (anthropic-dev-tooling, 18.05).
|
||||
|
||||
## Следующий шаг
|
||||
|
||||
Пять рекомендаций, отвечающих на пять осей запроса (приоритет сверху вниз):
|
||||
|
||||
1. **iter8 — пересборка теплокарты NODE_META** (ось 1). Новое окно `META_WINDOW`,
|
||||
включить волну 17–18.05; иначе режим «🔥 По использованию» врёт.
|
||||
2. **Ревизия ruflo — keep/trim** (оси 2+5). Решение заказчика: оставить advisory как
|
||||
есть / урезать демон (снять jitter-вред Pest) / отключить. 2 из 3 ⚫-конфликтов уйдут.
|
||||
3. **Off-phase routing-матрица** (оси 3+5). Decision-матрица R13-стиля на 30 инструментов
|
||||
#31–60 либо компактный routing-аид в CLAUDE.md §3.
|
||||
4. **Панель «Связки» на карте** (ось 4). Сводные рекомендованные комбо узлов отдельным
|
||||
режимом легенды.
|
||||
5. **Ребаланс PSR_v1** (ось 5). Off-phase множеству — свой раздел-матрица; рассмотреть
|
||||
облегчение UI-gate для не-UI задач.
|
||||
@@ -0,0 +1,152 @@
|
||||
# Локаторы формы добавления rt-проекта crm.bp-gr.ru (recon 2026-05-19)
|
||||
|
||||
**Среда:** `https://crm.bp-gr.ru/admin/visit/rt`, кнопка «Добавить проект» (label `[title="Добавить проект"]`, классы `el-button deal-req-is-empty-btn el-button--default`) открывает диалог.
|
||||
|
||||
**Стек:** **смешанный** — внешний контейнер `v-dialog v-dialog--active v-dialog--persistent` (Vuetify), внутри форма `form.el-form.el-form--label-left` (Element UI).
|
||||
|
||||
**Метод записи:** Playwright MCP `browser_evaluate` querySelector + `closest('.el-form')` от `[for="srcrt"]`. 10 `.el-form-item` в форме (verified `form.querySelectorAll('.el-form-item').length === 10`).
|
||||
|
||||
## Маппинг формы → DTO
|
||||
|
||||
| # | label `for=` | UI-поле | DTO-поле | Контракт |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `tag` | Тег | `dto.tag` | el-input text |
|
||||
| 2 | `srcrt` | Источник данных | `dto.platform` ⇒ ровно 1 включённый из B1/B2/B3 | 3 el-checkbox с textContent `B1`/`B2`/`B3`. Initial — **все три checked**. Inputs **не имеют `name` атрибута**. Идентификация — по `textContent`. Состояние — `.is-checked` класс на родительском `.el-checkbox`. |
|
||||
| 3 | `name` | Название проекта | `dto.uniqueKey` | el-input text |
|
||||
| 4 | `type` | Источники сбора | `dto.signalType` | el-select **readonly input** (открывается кликом). 5 опций: `Сайты`, `Звонки`, `СМС`, `Ретро сайты`, `Ретро звонки`. **Только первые три используются**: `site → "Сайты"`, `call → "Звонки"`, `sms → "СМС"`. Initial value — `Сайты`. |
|
||||
| 5 | (нет) | Период (slider 0–24, value=«HH-HH») | **НЕТ в DTO** — поле новое, отсутствует в `SupplierProjectDto` | `.el-slider[aria-valuemin=0][aria-valuemax=24]`, aria-valuetext формата `"10-18"`. Default — `10-18`. **Tier-2 оставляет default** (DTO не несёт это поле). |
|
||||
| 6 | (нет) | switch «Включить/Исключить» — режим регионов | `dto.regionsReverse` (`true` ⇒ «Исключить») | **ИСПРАВЛЕНО live-дебагом 2026-05-19:** единственный `.el-switch` на форме — это **include/exclude регионов** (`regions_reverse`), а НЕ статус active/paused. Текст — «ВключитьИсключить» (две метки). Статус проекта (`status`) задаётся дефолтом формы (`true`); отдельного UI-switch для active/paused НЕТ. `manage-project.js` этот switch не трогает (regions skip в Tier-2 MVP). |
|
||||
| 7 | `regions` | Регион | `dto.regions[]` + `dto.regionsReverse` | el-select multiple. Опции — **имена регионов** (например, `Республика Адыгея`), не id. **Архитектурный gap: DTO несёт int[] (id), форма требует имена** — нужен mapping id→name. См. секцию «Открытые вопросы». В рамках live-теста (Task 4) tested с **пустым** `regions=[]`. |
|
||||
| 8 | `limit_off` | Разделять по проектам | **НЕТ в DTO** | el-checkbox. Initial unchecked. Tier-2 оставляет default. |
|
||||
| 9 | `content` | Список сайтов / номеров / отправителей | `dto.uniqueKey` ⇒ textarea content | el-tabs `Список` (active) / `Файл`. Нам нужна вкладка `Список` (default active). Внутри textarea. Label меняется в зависимости от `type`: для `Сайты` — `Список сайтов`, для `Звонки` — `Список номеров`, для `СМС` — `Список отправителей` (не verified — см. Открытые вопросы). |
|
||||
| 10 | `limit` | Лимит в день | `dto.limit` | `.el-input-number` ⇒ внутри `.el-input input.el-input__inner` (тип text, не number). Кнопки +/− — `.el-input-number__increase` / `.el-input-number__decrease`. Для надёжности — `fill(String(dto.limit))` в input напрямую. |
|
||||
|
||||
## Кнопки
|
||||
|
||||
| Действие | Локатор | Notes |
|
||||
|---|---|---|
|
||||
| Save | `.v-dialog--active button:has-text("Сохранить")` | `el-button el-button--default` (НЕ primary; нет цветового акцента). Сохраняет + POST на `/admin/visit/rt-project-save`. |
|
||||
| Cancel | `.v-dialog--active button:has-text("Отмена")` | Закрывает диалог. |
|
||||
|
||||
## Канонические локаторы Playwright (для Task 3 manage-project.js)
|
||||
|
||||
```javascript
|
||||
// Helper: form-item с конкретным `for=` атрибутом
|
||||
function fieldByFor(page, attrFor) {
|
||||
return page.locator(`.el-form-item:has(.el-form-item__label[for="${attrFor}"])`);
|
||||
}
|
||||
|
||||
// 1. Tag — text input
|
||||
await fieldByFor(page, 'tag').locator('input.el-input__inner').fill(dto.tag);
|
||||
|
||||
// 2. Platforms (srcrt) — sub-checkboxes B1/B2/B3 by textContent
|
||||
const platformContainer = fieldByFor(page, 'srcrt');
|
||||
for (const p of ['B1', 'B2', 'B3']) {
|
||||
const cb = platformContainer.locator('.el-checkbox', {hasText: new RegExp(`^${p}$`)});
|
||||
const wanted = (dto.platforms || []).includes(p);
|
||||
const isChecked = (await cb.getAttribute('class'))?.includes('is-checked');
|
||||
if (!!isChecked !== wanted) await cb.click();
|
||||
}
|
||||
|
||||
// 3. Name
|
||||
await fieldByFor(page, 'name').locator('input.el-input__inner').fill(dto.name);
|
||||
|
||||
// 4. Type — el-select with label match
|
||||
const typeLabel = {site: 'Сайты', call: 'Звонки', sms: 'СМС'}[dto.signal_type];
|
||||
await fieldByFor(page, 'type').locator('.el-select input.el-input__inner').click();
|
||||
// Wait for dropdown popup (rendered outside form into body)
|
||||
await page.locator('.el-select-dropdown__item', {hasText: new RegExp(`^${typeLabel}$`)}).click();
|
||||
|
||||
// 6. Switch (active) — by class .el-switch in form-item without label-for
|
||||
const switchItem = page.locator('.el-form-item').filter({has: page.locator('.el-switch span:has-text("Включить")')});
|
||||
const switchEl = switchItem.locator('.el-switch');
|
||||
const isActive = (await switchEl.getAttribute('class'))?.includes('is-checked');
|
||||
if (!!isActive !== !!dto.active) await switchEl.click();
|
||||
|
||||
// 9. Content list — текстbox in active tab "Список"
|
||||
await fieldByFor(page, 'content').locator('.el-tabs__item:has-text("Список")').click(); // ensure tab active
|
||||
await fieldByFor(page, 'content').locator('textarea.el-textarea__inner').fill(dto.domains.join('\n'));
|
||||
|
||||
// 10. Limit
|
||||
await fieldByFor(page, 'limit').locator('input.el-input__inner').fill(String(dto.limit));
|
||||
|
||||
// Save (intercept response)
|
||||
const [saveResp] = await Promise.all([
|
||||
page.waitForResponse(r => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST'),
|
||||
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
|
||||
]);
|
||||
const body = await saveResp.json();
|
||||
if (body.status !== 'OK') throw new Error(`Portal rejected save: ${body.message}`);
|
||||
const externalId = String(body.id);
|
||||
```
|
||||
|
||||
## Открытые вопросы (gaps между формой и DTO)
|
||||
|
||||
1. **`workdays` отсутствует на форме create.** DTO имеет `workdays: int[1..7]` (дни недели). На форме add-project — **только slider «Период» (часы 0-24)**, дни недели отсутствуют. Возможные стратегии для Tier-2:
|
||||
- **(a)** После `rt-project-save` сделать дополнительный AJAX-апдейт через `SupplierPortalClient::updateProject` с workdays — но это противоречит идее Tier-2 как пути отказа от Tier-1 (если Tier-1 не работает, дополнительный AJAX от Tier-2 тоже скорее всего не сработает).
|
||||
- **(b)** Принять, что Tier-2 не выставляет workdays — портал применяет default (все 7 дней?). Зафиксировать в Tier-3 manual queue payload, чтобы оператор скорректировал вручную.
|
||||
- **(c)** Workdays задаются на странице **редактирования** rt-проекта, не создания — проверить.
|
||||
- **Решение принять в Task 3 design**. Скорее всего (b) — Tier-2 — fallback, не идеальная замена.
|
||||
|
||||
2. **`regions` mapping id → name.** DTO несёт `int[]` (id регионов), форма требует имена. Mapping должен быть:
|
||||
- **(a)** В JS-bridge: жёстко зашить регионы id↔name в `manage-project.js` (на ~89 регионов, ~3 KB словарь).
|
||||
- **(b)** В PHP: `FormProjectChannel::mapDto` конвертирует id→name перед отправкой в bridge.
|
||||
- **(c)** В Tier-2 — игнорировать regions (передавать пустой массив, регионы выставлять отдельным AJAX-апдейтом).
|
||||
- **Решение в Task 3 design.** Скорее всего (c) для MVP — Tier-2 редко используется, регионы — некритичный default.
|
||||
|
||||
3. **Label вкладки «Список» меняется по типу.** Verified: для `type=Сайты` label — `Список сайтов`. Для `type=Звонки` / `Сайты` / `СМС` метки textarea-вкладки могут отличаться. Но `for="content"` на label form-item стабильно — селектор `fieldByFor(page, 'content')` достаточен независимо от type.
|
||||
|
||||
4. **«Период» (slider 10-24).** Default `10-18` (часы активности). DTO не несёт, оставляем default. Если в будущем понадобится — расширять DTO + добавить slider-control в bridge.
|
||||
|
||||
5. **«Разделять по проектам» (`limit_off`).** Семантика не verified — оставляем unchecked (default).
|
||||
|
||||
6. **«Ретро сайты» / «Ретро звонки» type'ы.** Не в DTO (мы используем только site/call/sms). Зафиксировать как **не поддерживается** в FormProjectChannel — выкинуть `InvalidArgumentException` если DTO.signalType не в `{site,call,sms}`.
|
||||
|
||||
## Снимки страницы
|
||||
|
||||
Все Playwright snapshots в `.playwright-mcp/page-2026-05-19T13-2*.yml` (untracked, gitignored).
|
||||
|
||||
## Live-smoke (Task 4) — 2026-05-19
|
||||
|
||||
`_smoke_form_channel.php` (DTO platform B1 / site / limit 10): create через Tier-2
|
||||
(`FormProjectChannel` → `manage-project.js`) → `external_id=12731690` → delete через
|
||||
Tier-1 AJAX → **OK**. Form-канал доказан end-to-end против живого портала.
|
||||
|
||||
### Находки live-дебага
|
||||
|
||||
1. **Портал валидирует формат домена.** `content` (домены для site-проекта)
|
||||
должен быть валидным хостом — **lowercase, дефисы, без underscore, без
|
||||
uppercase**. Невалидный (`lidpotok-smoke-LIDERRA_FORM_SMOKE_NNN.example`) →
|
||||
`rt-project-save` отвечает `{status:"Error",message:"Введите домены"}` (HTTP 200).
|
||||
Для site-проектов `SupplierProjectDto::uniqueKey` обязан быть валидным доменом.
|
||||
|
||||
2. **Multi-source save создаёт N rt-проектов.** Если в форме включено несколько
|
||||
`srcrt`/`srcbl`/`srcmt` (B1/B2/B3), один `rt-project-save` создаёт по проекту
|
||||
на каждый источник; `id` в ответе — последний. `manage-project.js` снимает
|
||||
лишние чекбоксы под `dto.platform` (single) → ровно 1 проект. При работе
|
||||
напрямую с `SupplierPortalClient::saveProject` помнить: дефолт формы — все 3
|
||||
источника включены.
|
||||
|
||||
3. **Единственный `.el-switch` на форме — `regions_reverse`** (include/exclude
|
||||
регионов, текст «Включить/Исключить»), НЕ статус active/paused. Статус проекта
|
||||
(`status`) задаётся дефолтом формы (`true`), отдельного UI-switch нет. Recon
|
||||
row 6 (выше) скорректирован: switch ≠ status.
|
||||
|
||||
4. **type-select / клик вкладки ремоунтят content tab-pane.** Element UI: re-click
|
||||
уже-активного значения select'а / вкладки пере-рендерит pane → textarea
|
||||
детачится. `manage-project.js` кликает type/вкладку только при реальной смене
|
||||
значения (commit `b9791c5`).
|
||||
|
||||
## 3-tier failover live-smoke (Task 5b) — 2026-05-19
|
||||
|
||||
`_smoke_failover_3tier.php` против живого портала:
|
||||
|
||||
| Прогон | Сценарий | Результат |
|
||||
|---|---|---|
|
||||
| 1 | Tier-1 live (`AjaxProjectChannel`) | OK — `external_id=12732078`, удалён |
|
||||
| 2 | force-fail tier-1 (DI-стаб) → Tier-2 form (`FormProjectChannel`) | OK — `external_id=12732091`, удалён |
|
||||
| 3 | force-fail tier-1+2 → Tier-3 (`escalateToTier3`) | `TierEscalatedException` + `SupplierManualSyncQueue` row (`reason=form_save_error`) + alert mail; queue row удалён |
|
||||
|
||||
`FailoverProjectChannel` эскалация доказана end-to-end. Полный Supplier-suite
|
||||
(`tests/Feature/Supplier` + `tests/Unit/Supplier` + `tests/Feature/Integration`)
|
||||
— **156/156 passed**, 0 регрессий. Лог: `app/storage/logs/smoke-failover-2026-05-19.log`.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Discovery-brief: переделка миграции проектов + распределения лидов
|
||||
|
||||
**Дата:** 2026-05-20 · **Режим:** FEATURE (discovery-interview) · **Статус:** зафиксировано заказчиком, реализация НЕ начата.
|
||||
|
||||
## Проблема
|
||||
|
||||
Два связанных изменения в логике создания/миграции проектов:
|
||||
|
||||
1. Экспорт проекта Лидерра → портал поставщика crm.bp-gr.ru сейчас неполный и отложенный (каркас при создании, параметры — только ночью).
|
||||
2. Алгоритм распределения входящих лидов между клиентами не имеет потолка получателей — один номер может уйти 20 клиентам, владелец номера «сходит с ума».
|
||||
|
||||
## Архитектура (как есть)
|
||||
|
||||
- Клиент (tenant) создаёт/правит проект в ЛК → `ProjectService` запускает `SyncSupplierProjectJob` (очередь) — ставит на портал **каркас** (лимит 0, дни — вся неделя, регионы пусто).
|
||||
- Ночной `SyncSupplierProjectsJob` (крон 20:30 МСК, `app/routes/console.php:52`) сверяет квоты/дни/регионы и дописывает на портал через `FailoverProjectChannel` (ярус1 AJAX → ярус2 форма → ярус3 ручная очередь).
|
||||
- Входящий лид → `RouteSupplierLeadJob` → `LeadRouter::matchEligibleProjects` → Deal-копия каждому eligible клиенту.
|
||||
|
||||
## Зафиксированные требования
|
||||
|
||||
### Канал экспорта — два режима
|
||||
|
||||
- **R1. Режим «Онлайн».** Создание/изменение проекта в ЛК → перенос поставщику сразу, с полными параметрами (лимит/дни/регионы), не каркасом.
|
||||
- **R2. Режим «Пакетный»** (текущий ночной) — оставить, но время **20:30 → 18:00 МСК**. Снижает нагрузку при многократных правках одного проекта за день.
|
||||
- **R3.** Выбор режима — переключатель в админке.
|
||||
- Мотив: онлайн нужен для быстрой отработки/тестирования миграции; пакетный — для прод-нагрузки.
|
||||
|
||||
### Маппинг формы проекта (подтверждено живым тестом на портале)
|
||||
|
||||
- **R5.** Слать **один** `save` с тремя флагами `srcrt+srcbl+srcmt` — портал сам создаёт 3 проекта (B1/B2/B3). Сейчас код шлёт 3 раздельных save. Меньше нагрузки.
|
||||
- **R6.** Лимит делит **сам портал поровну** (проверено: лимит 15 → проекты по 5). Убрать наш ручной split в `SupplierQuotaAllocator::distributeForPlatform`.
|
||||
- **R7.** `tag` = **название региона** клиента (не `_lidpotok`). При 2+ регионах — **отдельный save на каждый регион** (1 регион → 3 проекта, 2 → 6). Тег региона приходит обратно в лиде (`raw_payload['tag']`) → **протянуть в `deals`** (поле тег = регион, для дальнейшей работы со сделками).
|
||||
|
||||
### Алгоритм распределения лидов (полностью пересмотрен — группировка ВЫКИНУТА)
|
||||
|
||||
Решения, принятые в диалоге (нюансы заказчика: заказ ≠ поставка; платим за фактически поступившие лиды; лимит — жёсткий потолок, недобор допустим):
|
||||
|
||||
- **Заказ у поставщика** = `max( наибольший_лимит , ceil(Σ всех лимитов / 3) )`.
|
||||
- `ceil(Σ/3)` — ёмкость шаринга (один лид продаётся максимум 3 раза).
|
||||
- `наибольший_лимит` — крупнейший клиент должен иметь достаточно разных лидов, чтобы добрать.
|
||||
- Заказ = потолок запроса; придёт ≤; платим за фактически поступившие.
|
||||
- **Распределение лида** = 3 случайным клиентам из тех, у кого остаток лимита > 0 (`получено_сегодня < лимит`).
|
||||
- cap=3 — защита владельца номера;
|
||||
- выбор только из недобравших → лимит-потолок не превышается;
|
||||
- недобор допустим (поставщик шлёт сколько хочет).
|
||||
- **Группировка клиентов НЕ нужна** — рандом из недобравших сам обеспечивает cap=3 + соблюдение лимита + максимизацию шаринга.
|
||||
|
||||
### Примеры расчёта заказа (verified в диалоге)
|
||||
|
||||
| Клиенты | Σ | наиб. лимит | ceil(Σ/3) | Заказ |
|
||||
|---|---|---|---|---|
|
||||
| 5, 5, 10, 20 | 40 | 20 | 14 | **20** |
|
||||
| 15×5 + 10 (16 клиентов) | 85 | 10 | 29 | **29** |
|
||||
| 3×15 | 45 | 15 | 15 | **15** |
|
||||
| 3×15 + 30 | 75 | 30 | 25 | **30** |
|
||||
| 4×10 | 40 | 10 | 14 | **14** |
|
||||
|
||||
## Что НЕ так в текущей реализации (пины)
|
||||
|
||||
- **Заказ:** `SupplierQuotaAllocator::allocate` (`app/app/Services/Supplier/SupplierQuotaAllocator.php:55`) суммирует `Σ daily_limit` + делит на B1/B2/B3 (`:73`). Надо: формула `max(наиб, ceil(Σ/3))`, split убрать (портал делит сам).
|
||||
- **Распределение/cap:** `LeadRouter::matchEligibleProjects` (`app/app/Services/LeadRouter.php:46`) возвращает всех eligible; `RouteSupplierLeadJob` (`app/app/Jobs/RouteSupplierLeadJob.php:115`) создаёт копию каждому — нет cap=3, нет рандома.
|
||||
- **Время крона:** `app/routes/console.php:52` — 20:30, надо 18:00.
|
||||
- **Экспорт по одному флагу:** `SupplierPortalClient::toPayload` (`app/app/Services/Supplier/SupplierPortalClient.php:422`) шлёт один src-флаг; `SyncSupplierProjectJob` (`app/app/Jobs/SyncSupplierProjectJob.php:64`) — раздельные save по платформам.
|
||||
|
||||
## Открытые под-вопросы (для brainstorming перед реализацией)
|
||||
|
||||
1. Scope переключателя режима — глобально (SaaS) или per-tenant?
|
||||
2. Поведение онлайн-режима при недоступном портале — эскалация в ярус-3 очередь, как сейчас?
|
||||
3. Тег при «вся РФ» (регион не выбран) — пустой?
|
||||
4. Имя «Конкурент 1» на портал не уходит (в name едет номер донора) — нужно ли тянуть человекочитаемое имя?
|
||||
5. Ключ конкуренции клиентов за поток (источник+регион+день) — как именно сопоставляется регион.
|
||||
|
||||
## Следующий шаг
|
||||
|
||||
Эпик (новые режимы экспорта + переписка квот/маршрутизации + админка). Реализацию начинать через `brainstorming` (закрыть под-вопросы 1–5) → `writing-plans` → TDD.
|
||||
|
||||
## Прочее (сессия 2026-05-20)
|
||||
|
||||
- Webhook-канал чинили (secret <32 после re-seed → 404; восстановлен).
|
||||
- CSV reconcile здоров.
|
||||
- Тестовые проекты на портале от живого теста R5: id `12742042/12742043/12742044` (`*_LIDERRA_TEST_DELETE_ME`) — заказчик удалит сам.
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"last_read_at": "2026-05-19T00:00:00+03:00",
|
||||
"read_count_last_period": 0,
|
||||
"period_start": "2026-05-19T00:00:00+03:00"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Observer infrastructure
|
||||
|
||||
Passive evidence-loop for the Лидерра «brain» per ADR-011.
|
||||
|
||||
## Files
|
||||
|
||||
- `episodes-YYYY-MM.jsonl` — append-only JSONL, one line per Stop-event. Schema **v2** (`schema_version: 2`): the 5 mandatory fields + `decision_provenance` (who chose the node), `environment` (economy_level / model / post_compaction / session_turn / parallel_session), `task_size`, `task_ref`, `prompt_signal`, and an `outcome` that is `unknown` at write time (refined by `/brain-retro`). On an internal hook failure a minimal `observer_error` marker line is written instead of a silent skip. Written by `tools/observer-stop-hook.mjs` via `tools/observer-transcript-parser.mjs`.
|
||||
- `notes/YYYY-MM-DD-<slug>.md` — optional MD notes for sessions with qualitative history.
|
||||
- `STATUS.md` — auto-generated dashboard. Regenerated per-commit by `tools/status-md-generator.mjs`.
|
||||
- `.read-counter.json` — C3 observer-of-observer counter. Updated on Read of observer files.
|
||||
- `dashboard.html` + `dashboard.js` + `dashboard-core.js` — Brain Dashboard: visualises the episode log over the automation-graph topology (4 views — Карта / Разбор / Лента / Агрегат). Run `npm run brain:dashboard`, open the printed localhost URL. `dashboard-core.js` is pure logic, unit-tested in `tools/brain-dashboard-core.test.mjs`.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. **Write**: every Stop-event appends one JSONL line, parsed from the session transcript (Stop-hook).
|
||||
2. **Aggregate**: `/brain-retro` skill reads JSONL each sprint, proposes regulatory candidates.
|
||||
3. **Surface**: `STATUS.md` shows controllers + monthly stats.
|
||||
4. **Self-prune**: C3 warns if 54 weeks pass without any read of observer files.
|
||||
|
||||
## Routing-tag discipline
|
||||
|
||||
When the user dictates a specific method/node (e.g. «запусти discovery-interview»), Claude must emit one line in its response:
|
||||
|
||||
```
|
||||
<!-- routing: provenance=user_directed_method node=<chosen> counterfactual=<node Claude would have chosen autonomously> -->
|
||||
```
|
||||
|
||||
The Stop-hook routing-gate (`tools/observer-routing-detector.mjs` + `routingGateDecision`) detects a dictated method; if the tag is missing it returns `decision: block`, so the turn cannot end without the tag. The gate fires at most once per turn (`stop_hook_active` guard). This makes `decision_provenance` reliable — factor analysis can separate a router error from a user-dictated one.
|
||||
|
||||
## Privacy
|
||||
|
||||
PII filter (phone numbers, emails, tokens) is applied **before** every write — see `tools/observer-pii-filter.mjs`. gitleaks pre-push also scans observer files as part of full-history sweep.
|
||||
|
||||
## Don't
|
||||
|
||||
- Don't edit `episodes-*.jsonl` manually — it's append-only.
|
||||
- Don't write outside `docs/observer/notes/` for hand-curated notes.
|
||||
- Don't change `.read-counter.json` manually — it's maintained by hooks.
|
||||
|
||||
## HK1 pre-check (Pravila ADR-010) — verified 2026-05-19
|
||||
|
||||
Before registering `tools/observer-stop-hook.mjs` on Stop event (Task B5), verified collision against 6-component economy/skill-discipline architecture:
|
||||
|
||||
- **User-level** `~/.claude/settings.json` already has Stop hook: **agent-type** Sonnet-4.6 economy compliance verifier (analyzes transcript for claim-without-evidence violations).
|
||||
- **Project-level** `.claude/settings.json` — Stop slot empty.
|
||||
|
||||
**Result**: no overwrite. observer-stop-hook will be added as **command-type entry in project-level Stop array**. Project + user scopes are independent slots in Claude Code 2.x — both run on the same Stop event without conflict. The agent verifier (user scope) and the JSONL appender (project scope) have non-overlapping responsibilities.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-19T12:44:43.305Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
| C1 L1-watcher | ✅ | [l1-watcher] OK — 0 drift |
|
||||
| C2 Cross-ref consistency | ✅ | [cross-ref-checker] OK — 0 drift in 4 files |
|
||||
| C3 Observer-of-observer | ✅ | [observer-of-observer] OK — last read 0 week(s) ago |
|
||||
| C4 Сигнальный статус | ✅ | This file (self-reference) |
|
||||
| C5 Observer-coverage | ✅ | 17 episode(s), 988 recent commit(s) · Stop-hook + post-commit OK |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 17 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
✅ — норма ・ ⚠️ — внимание ・ 🔴 — действие требуется ・ ⚪ — не запускалось
|
||||
@@ -0,0 +1,218 @@
|
||||
// Pure logic for the Brain Dashboard. Browser-safe ES module (no node: APIs)
|
||||
// so it loads both in the browser and under Vitest's node environment.
|
||||
|
||||
export function normalizeEpisode(raw) {
|
||||
const v2 = raw.schema_version === 2;
|
||||
const pr = raw.primary_rationale || {};
|
||||
const events = Array.isArray(raw.events) ? raw.events : [];
|
||||
const tools = {};
|
||||
for (const ev of events) {
|
||||
if (ev.kind === 'tool_summary' && ev.counts) {
|
||||
for (const [k, n] of Object.entries(ev.counts)) tools[k] = (tools[k] || 0) + n;
|
||||
}
|
||||
}
|
||||
const started = raw.timestamps?.started_at || null;
|
||||
const ended = raw.timestamps?.ended_at || null;
|
||||
return {
|
||||
schemaVersion: v2 ? 2 : 1,
|
||||
taskId: raw.task_id || null,
|
||||
taskRef: raw.task_ref || raw.task_id || null,
|
||||
startedAt: started,
|
||||
endedAt: ended,
|
||||
durationMs: started && ended ? Date.parse(ended) - Date.parse(started) : null,
|
||||
pathType: raw.path_type || null,
|
||||
outcome: raw.outcome || 'unknown',
|
||||
promptSignal: v2 ? raw.prompt_signal || null : null,
|
||||
decisionProvenance: v2 ? raw.decision_provenance || null : null,
|
||||
environment: v2 ? raw.environment || null : null,
|
||||
taskSize: v2 ? raw.task_size || null : null,
|
||||
taskClassification: pr.task_classification || null,
|
||||
nodeChosen: pr.node_chosen || null,
|
||||
hardFloor: pr.hard_floor || { invoked: false, rules: [] },
|
||||
skills: events.filter((e) => e.kind === 'skill_invoked').map((e) => e.skill),
|
||||
tools,
|
||||
errorCount: events.filter((e) => e.kind === 'error').length,
|
||||
retryCount: events.filter((e) => e.kind === 'retry').length,
|
||||
interruptCount: events.filter((e) => e.kind === 'interrupt').length,
|
||||
events,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
// episode skill name → automation-graph node id (see tools/observer-known-nodes.txt
|
||||
// for the routable vocabulary; only skills that have a graph node are listed).
|
||||
export const SKILL_TO_NODE = {
|
||||
brainstorming: 'sk_brainstorm',
|
||||
'writing-plans': 'sk_wplans',
|
||||
'executing-plans': 'sk_eplans',
|
||||
'subagent-driven-development': 'sk_subagent',
|
||||
'test-driven-development': 'sk_tdd',
|
||||
'systematic-debugging': 'sk_debug',
|
||||
'verification-before-completion': 'sk_verify',
|
||||
'requesting-code-review': 'sk_coderev',
|
||||
'using-git-worktrees': 'sk_worktree',
|
||||
'finishing-a-development-branch': 'sk_pr',
|
||||
'writing-skills': 'sk_wskills',
|
||||
'discovery-interview': 'discovery_interview',
|
||||
'audit-portal': 'sk_audit_portal',
|
||||
regression: 'sk_regression',
|
||||
'process-modeling': 'process_modeling',
|
||||
'process-analysis': 'process_analysis',
|
||||
ccpm: 'ccpm',
|
||||
'security-review': 'sk_security_review',
|
||||
'claude-md-management': 'claude_md_mgmt',
|
||||
};
|
||||
|
||||
// mcp__<server>__<tool> → automation-graph node id.
|
||||
export const MCP_SERVER_TO_NODE = {
|
||||
github: 'mcp_gh',
|
||||
playwright: 'mcp_pw',
|
||||
'laravel-boost': 'mcp_boost',
|
||||
redis: 'mcp_redis',
|
||||
sentry: 'mcp_sentry',
|
||||
semgrep: 'mcp_semgrep',
|
||||
openapi: 'mcp_openapi',
|
||||
magic: 'mcp_21st',
|
||||
'universal-icons': 'mcp_icons',
|
||||
};
|
||||
|
||||
// "superpowers:systematic-debugging" → "systematic-debugging"
|
||||
function skillBase(name) {
|
||||
const s = String(name || '');
|
||||
return s.includes(':') ? s.split(':').pop() : s;
|
||||
}
|
||||
|
||||
// Returns { nodeIds: string[], signals: number, attributed: number }.
|
||||
// A "signal" is an episode datum that names a routable node (a skill id or an
|
||||
// mcp__ tool). Builtin Claude tools are not signals.
|
||||
export function attributeNodes(episode) {
|
||||
const ids = new Set();
|
||||
let signals = 0;
|
||||
let attributed = 0;
|
||||
const consider = (nodeId) => {
|
||||
signals++;
|
||||
if (nodeId) {
|
||||
ids.add(nodeId);
|
||||
attributed++;
|
||||
}
|
||||
};
|
||||
if (episode.nodeChosen && episode.nodeChosen !== 'direct') {
|
||||
consider(SKILL_TO_NODE[skillBase(episode.nodeChosen)]);
|
||||
}
|
||||
for (const s of episode.skills) consider(SKILL_TO_NODE[skillBase(s)]);
|
||||
for (const toolName of Object.keys(episode.tools)) {
|
||||
const m = /^mcp__(.+?)__/.exec(toolName);
|
||||
if (m) consider(MCP_SERVER_TO_NODE[m[1]]);
|
||||
}
|
||||
return { nodeIds: [...ids], signals, attributed };
|
||||
}
|
||||
|
||||
// Groups episodes by taskRef. Each group's episodes are sorted newest-first;
|
||||
// groups are ordered by their newest episode, newest group first.
|
||||
export function groupBySession(episodes) {
|
||||
const byRef = new Map();
|
||||
for (const e of episodes) {
|
||||
const key = e.taskRef || e.taskId || 'unknown';
|
||||
if (!byRef.has(key)) byRef.set(key, []);
|
||||
byRef.get(key).push(e);
|
||||
}
|
||||
const groups = [...byRef.entries()].map(([taskRef, eps]) => {
|
||||
eps.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
|
||||
return { taskRef, episodes: eps, newest: eps[0]?.startedAt || '' };
|
||||
});
|
||||
groups.sort((a, b) => String(b.newest).localeCompare(String(a.newest)));
|
||||
return groups;
|
||||
}
|
||||
|
||||
// filter: { classification?, outcome?, pathType?, withErrors?, dateFrom?, dateTo? }
|
||||
export function filterEpisodes(episodes, filter = {}) {
|
||||
return episodes.filter((e) => {
|
||||
if (filter.classification && e.taskClassification !== filter.classification) return false;
|
||||
if (filter.outcome && e.outcome !== filter.outcome) return false;
|
||||
if (filter.pathType && e.pathType !== filter.pathType) return false;
|
||||
if (filter.withErrors && e.errorCount === 0 && e.retryCount === 0) return false;
|
||||
if (filter.dateFrom && String(e.startedAt) < filter.dateFrom) return false;
|
||||
if (filter.dateTo && String(e.startedAt) > filter.dateTo) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Three honest layers (spec §6):
|
||||
// design — the dashed conflict edges (fact, from topology)
|
||||
// friction — node id → count of errored/retried episodes attributed to it
|
||||
// correlation — errored episodes that span both ends of a design-conflict edge
|
||||
export function inferConflicts(episodes, edges) {
|
||||
const design = edges.filter((e) => e.dashes === true);
|
||||
const friction = {};
|
||||
const correlation = [];
|
||||
for (const e of episodes) {
|
||||
if (e.errorCount === 0 && e.retryCount === 0) continue;
|
||||
const ids = attributeNodes(e).nodeIds;
|
||||
for (const id of ids) friction[id] = (friction[id] || 0) + 1;
|
||||
if (e.errorCount > 0) {
|
||||
for (const edge of design) {
|
||||
if (ids.includes(edge.from) && ids.includes(edge.to)) {
|
||||
correlation.push({ episode: e.taskId, pair: [edge.from, edge.to], conflict: edge.title || '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { design, friction, correlation };
|
||||
}
|
||||
|
||||
// Aggregates a list of episodes into dashboard metrics.
|
||||
export function aggregate(episodes) {
|
||||
const nodeHeat = {};
|
||||
const pathType = {};
|
||||
const outcome = {};
|
||||
const classification = {};
|
||||
const economy = {};
|
||||
let totalErrors = 0;
|
||||
let totalRetries = 0;
|
||||
let redirects = 0;
|
||||
for (const e of episodes) {
|
||||
for (const id of attributeNodes(e).nodeIds) nodeHeat[id] = (nodeHeat[id] || 0) + 1;
|
||||
if (e.pathType) pathType[e.pathType] = (pathType[e.pathType] || 0) + 1;
|
||||
outcome[e.outcome] = (outcome[e.outcome] || 0) + 1;
|
||||
if (e.taskClassification) classification[e.taskClassification] = (classification[e.taskClassification] || 0) + 1;
|
||||
const lvl = e.environment ? e.environment.economy_level : null;
|
||||
const key = lvl == null ? 'n/a' : String(lvl);
|
||||
economy[key] = (economy[key] || 0) + 1;
|
||||
totalErrors += e.errorCount;
|
||||
totalRetries += e.retryCount;
|
||||
if (e.decisionProvenance && e.decisionProvenance.kind === 'user_directed_method') redirects++;
|
||||
}
|
||||
return {
|
||||
nodeHeat,
|
||||
pathType,
|
||||
outcome,
|
||||
classification,
|
||||
economy,
|
||||
totalErrors,
|
||||
totalRetries,
|
||||
redirectRate: episodes.length ? redirects / episodes.length : 0,
|
||||
count: episodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEpisodes(text) {
|
||||
const episodes = [];
|
||||
let skipped = 0;
|
||||
for (const line of String(text).split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(trimmed);
|
||||
} catch {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || raw.observer_error) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
episodes.push(normalizeEpisode(raw));
|
||||
}
|
||||
return { episodes, skipped };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Дашборд мозга — Лидерра</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #F6F3EC; --ink: #012019; --teal: #0F6E56;
|
||||
--panel: #ffffff; --line: #d8d2c4;
|
||||
--mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
--sans: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
body { margin:0; height:100vh; display:flex; flex-direction:column; background:var(--bg); color:var(--ink); font-family:var(--sans); overflow:hidden; }
|
||||
#tabbar { background:var(--panel); border-bottom:1px solid var(--line); padding:8px 12px; display:flex; align-items:center; gap:10px; flex-shrink:0; }
|
||||
#tabbar button { background:var(--panel); border:1px solid var(--line); color:var(--ink); border-radius:5px; padding:6px 14px; font-size:13px; cursor:pointer; font-family:var(--sans); }
|
||||
#tabbar button.active { background:var(--teal); color:#ffffff; border-color:var(--teal); }
|
||||
#tabbar button:hover { background:rgba(15,110,86,0.08); }
|
||||
#status { margin-left:auto; font-size:12px; color:var(--ink); font-family:var(--mono); opacity:0.7; }
|
||||
#graph { height:40vh; background:#1e1e2e; flex-shrink:0; border-bottom:1px solid var(--line); }
|
||||
#network { background:#1e1e2e; }
|
||||
#workarea { flex:1; overflow:auto; padding:16px; }
|
||||
.view { display:none; }
|
||||
.view.active { display:block; }
|
||||
h3, h4 { color:var(--teal); margin:8px 0; }
|
||||
#agg-tiles { display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:12px; }
|
||||
.tile { background:var(--panel); border:1px solid var(--line); border-radius:6px; padding:12px; }
|
||||
.tile h4 { margin:0 0 6px; font-size:11px; text-transform:uppercase; letter-spacing:0.06em; }
|
||||
.tile p { margin:0; font-family:var(--mono); font-size:13px; }
|
||||
.feed-group { margin-bottom:16px; }
|
||||
.feed-card { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:8px 10px; margin-bottom:6px; font-family:var(--mono); font-size:12px; }
|
||||
#replay-list { float:left; width:40%; padding-right:12px; box-sizing:border-box; }
|
||||
#replay-detail { float:left; width:60%; }
|
||||
#replay-episodes { list-style:none; padding:0; max-height:50vh; overflow:auto; }
|
||||
#replay-episodes li { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:6px 10px; margin-bottom:4px; cursor:pointer; font-family:var(--mono); font-size:11px; }
|
||||
#replay-episodes li:hover { background:rgba(15,110,86,0.06); }
|
||||
#agg-conflicts { margin-top:16px; }
|
||||
#agg-conflicts p { font-family:var(--mono); font-size:12px; }
|
||||
#feed-pause { background:var(--panel); border:1px solid var(--line); color:var(--ink); border-radius:5px; padding:4px 10px; cursor:pointer; font-family:var(--sans); }
|
||||
#feed-poll-state { margin-left:8px; font-family:var(--mono); font-size:11px; color:var(--ink); opacity:0.7; }
|
||||
#map-conflicts { font-family:var(--mono); font-size:12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header id="tabbar">
|
||||
<button data-view="map">Карта</button>
|
||||
<button data-view="replay">Разбор</button>
|
||||
<button data-view="feed">Лента</button>
|
||||
<button data-view="aggregate">Агрегат</button>
|
||||
<span id="status"></span>
|
||||
</header>
|
||||
<section id="graph">
|
||||
<div id="network" style="width:100%;height:100%"></div>
|
||||
</section>
|
||||
<section id="workarea">
|
||||
<div class="view" id="view-map">
|
||||
<p>Топология мозга: 124 узла, рёбра, 11 размеченных дизайн-конфликтов. Это нулевое состояние холста — без оверлеев.</p>
|
||||
<ul id="map-conflicts"></ul>
|
||||
</div>
|
||||
<div class="view" id="view-replay">
|
||||
<div id="replay-list">
|
||||
<select id="f-classification"><option value="">все</option><option value="bugfix">bugfix</option><option value="feature">feature</option><option value="refactor">refactor</option><option value="docs">docs</option><option value="question">question</option><option value="other">other</option></select>
|
||||
<select id="f-outcome"><option value="">все</option><option value="success">success</option><option value="unknown">unknown</option><option value="failure">failure</option></select>
|
||||
<label><input type="checkbox" id="f-errors"> только с ошибками</label>
|
||||
<ul id="replay-episodes"></ul>
|
||||
</div>
|
||||
<div id="replay-detail"></div>
|
||||
</div>
|
||||
<div class="view" id="view-feed">
|
||||
<button id="feed-pause">Пауза</button>
|
||||
<span id="feed-poll-state"></span>
|
||||
<div id="feed-stream"></div>
|
||||
</div>
|
||||
<div class="view" id="view-aggregate">
|
||||
<div id="agg-tiles"></div>
|
||||
<div id="agg-conflicts"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
||||
<script src="../automation-graph-data.js"></script>
|
||||
<script type="module" src="dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user