Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,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,45 @@
|
||||
<?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;
|
||||
|
||||
/**
|
||||
* Очередь яруса 3 резерва канала миграции проектов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
|
||||
*/
|
||||
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);
|
||||
}
|
||||
@@ -244,23 +353,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');
|
||||
}
|
||||
};
|
||||
@@ -1059,7 +1059,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 +1077,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 +1497,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
|
||||
|
||||
-
|
||||
@@ -1943,3 +1943,21 @@ parameters:
|
||||
identifier: argument.type
|
||||
count: 3
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.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 Illuminate\\Contracts\\Cache\\Repository\:\:lock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/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.
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const TIMEOUT_MS = 90_000;
|
||||
|
||||
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]'),
|
||||
]);
|
||||
}
|
||||
|
||||
async function fillForm(page, dto) {
|
||||
const activeChecked = await page.locator('input[name=active]').isChecked();
|
||||
if (activeChecked !== !!dto.active) await page.locator('input[name=active]').click();
|
||||
|
||||
if (dto.tag) await page.fill('input[name=tag]', dto.tag);
|
||||
|
||||
for (const p of ['B1', 'B2', 'B3']) {
|
||||
const wanted = (dto.platforms || []).includes(p);
|
||||
const sel = `input[name="platform[]"][value="${p}"]`;
|
||||
const checked = await page.locator(sel).isChecked();
|
||||
if (checked !== wanted) await page.locator(sel).click();
|
||||
}
|
||||
|
||||
await page.fill('input[name=name]', dto.name);
|
||||
|
||||
const signalLabel = { site: 'Сайты', call: 'Звонки', sms: 'СМС' }[dto.signal_type] || 'Сайты';
|
||||
await page.selectOption('select[name=signal_type]', { label: signalLabel });
|
||||
|
||||
if (dto.region_mode === 'exclude') {
|
||||
await page.locator('input[name=region_mode][value=exclude]').click();
|
||||
}
|
||||
|
||||
if (dto.domains && dto.domains.length) {
|
||||
await page.fill('textarea[name=domains]', dto.domains.join('\n'));
|
||||
}
|
||||
|
||||
await page.fill('input[name=limit]', String(dto.limit));
|
||||
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const wanted = (dto.workdays || [1, 2, 3, 4, 5, 6, 7]).includes(d);
|
||||
const sel = `input[name="workdays[]"][value="${d}"]`;
|
||||
const checked = await page.locator(sel).isChecked();
|
||||
if (checked !== wanted) await page.locator(sel).click();
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
await page.click('button:has-text("Добавить проект")');
|
||||
await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
|
||||
}
|
||||
|
||||
await fillForm(page, args.dto);
|
||||
const beforeRows = await page.locator('#projects-table tbody tr').count();
|
||||
await page.click('#save-btn');
|
||||
await page.waitForFunction(
|
||||
(before) => document.querySelectorAll('#projects-table tbody tr').length > before,
|
||||
beforeRows,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
);
|
||||
|
||||
const newRow = page.locator('#projects-table tbody tr').last();
|
||||
const externalId = await newRow.getAttribute('data-id');
|
||||
|
||||
return { external_id: externalId };
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const row = page.locator(`#projects-table tbody tr[data-id="${args.externalId}"]`);
|
||||
await row.locator('button.edit').click();
|
||||
await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
|
||||
await fillForm(page, args.dto);
|
||||
await page.click('#save-btn');
|
||||
await page.waitForSelector('#add-project-modal', { state: 'hidden', timeout: TIMEOUT_MS });
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
const rows = await page.locator('#projects-table tbody tr').evaluateAll((nodes) =>
|
||||
nodes.map((n) => ({
|
||||
id: parseInt(n.dataset.id, 10),
|
||||
name: n.querySelector('td:nth-child(2)') ? n.querySelector('td:nth-child(2)').textContent : null,
|
||||
})),
|
||||
);
|
||||
|
||||
return { projects: rows };
|
||||
}
|
||||
|
||||
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,65 @@
|
||||
/**
|
||||
* Фикстурный тест manage-project.js — против локального HTML, без живого портала.
|
||||
*
|
||||
* Runner: встроенный node:test (проект не использует @playwright/test —
|
||||
* в app/playwright только playwright core). Запуск: `node --test manage-project.test.js`.
|
||||
*/
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { execFile } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, 'manage-project.js');
|
||||
const FIXTURE_URL = 'file://' + path.resolve(__dirname, '../tests/fixtures/supplier-portal/rt-add-project-form.html');
|
||||
|
||||
function runScript(input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile('node', [SCRIPT], { timeout: 60000 }, (err, stdout, stderr) => {
|
||||
if (err && err.code !== undefined && typeof err.code !== 'number') {
|
||||
return reject(err);
|
||||
}
|
||||
resolve({ stdout: stdout.toString(), stderr: stderr.toString() });
|
||||
});
|
||||
child.stdin.write(JSON.stringify(input));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('createProject fills form and returns row id', async () => {
|
||||
const result = await runScript({
|
||||
operation: 'create',
|
||||
login: 'fixture-noop',
|
||||
password: 'fixture-noop',
|
||||
url: FIXTURE_URL,
|
||||
skipLogin: true,
|
||||
dto: {
|
||||
tag: 'TEST',
|
||||
name: 'Test Project',
|
||||
platforms: ['B1', 'B2'],
|
||||
signal_type: 'site',
|
||||
limit: 25,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
regions: [],
|
||||
region_mode: 'include',
|
||||
domains: ['example.com'],
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
const out = JSON.parse(result.stdout);
|
||||
assert.ok(out.external_id, 'external_id should be truthy');
|
||||
assert.match(out.external_id, /^\d+$/, 'external_id should be numeric string');
|
||||
});
|
||||
|
||||
test('listProjects returns array', async () => {
|
||||
const result = await runScript({
|
||||
operation: 'list',
|
||||
login: 'fixture-noop',
|
||||
password: 'fixture-noop',
|
||||
url: FIXTURE_URL,
|
||||
skipLogin: true,
|
||||
});
|
||||
|
||||
const out = JSON.parse(result.stdout);
|
||||
assert.ok(Array.isArray(out.projects), 'projects should be an array');
|
||||
});
|
||||
@@ -27,9 +27,9 @@ 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);
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,50 @@ 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
|
||||
нодов
|
||||
креды
|
||||
Апи
|
||||
имплементациями
|
||||
алёрт
|
||||
инжектят
|
||||
инжектим
|
||||
фикстурный
|
||||
роута
|
||||
|
||||
+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`
|
||||
+82
-67
@@ -201,7 +201,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>
|
||||
@@ -228,10 +228,10 @@ function pos(ring, angleDeg) {
|
||||
|
||||
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) },
|
||||
{ id: 'pravila', label: 'Pravila v1.29', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.16', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.14', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.15', 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) },
|
||||
@@ -305,7 +305,7 @@ const NODES = [
|
||||
{ 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) },
|
||||
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: 'ruflo', 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) },
|
||||
@@ -578,8 +578,8 @@ const EDGES = [
|
||||
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
|
||||
// ══════════════════════════════════════════════════
|
||||
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('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'),
|
||||
@@ -621,7 +621,7 @@ const CATEGORY_LABELS = {
|
||||
rules: 'Правило', plugins: 'Плагин', skills_sp: 'Скил Superpowers',
|
||||
skills_proj: 'Скил проекта', hooks: 'Хук .claude', agents: 'Агент',
|
||||
mcp: 'MCP-сервер', lefthook: 'Lefthook job', memory: 'Memory-файл',
|
||||
ruflo: 'ruflo (оркестратор)'
|
||||
ruflo: 'ruflo (изолирован)'
|
||||
};
|
||||
|
||||
function nd(desc, when, limits, reportsTo, manages, together, conflicts) {
|
||||
@@ -663,7 +663,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 +1000,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 +1231,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.',
|
||||
@@ -1847,7 +1847,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 (не параллельно)' },
|
||||
@@ -1874,17 +1874,27 @@ const EDGE_DETAILS = {
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// 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 = '18.05.2026'; // дата генерации значений
|
||||
const META_WINDOW = '09–18.05.2026'; // окно подсчёта использования (10 дней)
|
||||
|
||||
// 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: '—' },
|
||||
@@ -1978,36 +1988,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 +2031,44 @@ 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' },
|
||||
};
|
||||
|
||||
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
|
||||
@@ -2260,7 +2275,7 @@ const GROUPS = {
|
||||
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 } },
|
||||
ruflo: { color: { background: '#262626', border: '#555555', highlight: { border: '#777777', background: '#333333' } }, font: { color: '#8a8a8a', size: 12, bold: true }, shapeProperties: { borderDashes: [4, 4] } },
|
||||
};
|
||||
|
||||
const nodesDS = new vis.DataSet(NODES);
|
||||
|
||||
@@ -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,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,46 @@
|
||||
# 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.
|
||||
|
||||
## 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-19T08:47:41.763Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
| 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 | ✅ | 18 episode(s), 954 recent commit(s) · Stop-hook + post-commit OK |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 18 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,72 @@
|
||||
# Router procedure v1.0
|
||||
|
||||
**Status:** active (introduced 2026-05-19, spec dd5bded, ADR-011)
|
||||
|
||||
**Owner:** Claude Code automatic at session start.
|
||||
|
||||
## Purpose
|
||||
|
||||
Single source of truth for «task → node(s)» routing. Replaces implicit routing scattered across Pravila §12/§14/§15, PSR_v1 R0–R15, Tooling §3, and routing-off-phase.md by explicitly listing the procedure executed every turn.
|
||||
|
||||
## Inputs
|
||||
|
||||
1. Active task from user prompt.
|
||||
2. Node registry: [`docs/Tooling_v8_3.md`](Tooling_v8_3.md) Прил. Н §4.X (9 obligatory attributes per row — see §4.1 spec).
|
||||
3. Off-phase routing table: [`docs/routing-off-phase.md`](routing-off-phase.md).
|
||||
4. Hard-floor rules: [Pravila §12 / §14 / §15](Pravila_raboty_Claude_v1_1.md).
|
||||
5. ADR boundaries: `docs/adr/*.md`.
|
||||
|
||||
## Procedure (5 steps, executed per turn)
|
||||
|
||||
### Step 1 — Hard-floor check (Pravila §12 / §14 / §15)
|
||||
|
||||
- Does the prompt contain `queen` / `королева`? → Pravila §14 (currently dormant per §14.9).
|
||||
- Is the task in Pravila §12.2 map (TDD / debug / brainstorm / writing-plans / verification / discovery / migration / commit / review / UI-feature / arch-decision / refactor / parallel-sessions / worktree / writing-skills)? → invoke skill FIRST (hard-rule).
|
||||
- Will any of the 8 normative files be edited? → §15.2 pre-flight sync MANDATORY.
|
||||
- Subagent + git tasks? → §15.1 Sonnet/Opus only (NEVER Haiku).
|
||||
|
||||
If any hard-floor rule applies and is skipped — this is a violation, regardless of subsequent steps.
|
||||
|
||||
### Step 2 — Classification
|
||||
|
||||
- Phase-active (0/1/2/3) or off-phase?
|
||||
- Type: TDD / debug / brainstorm / writing-plan / verification / discovery / migration / commit / review / UI-feature / arch-decision / refactor / docs / sync / other.
|
||||
- Identify task triggers (keywords, file types touched, output requested).
|
||||
|
||||
### Step 3 — Trigger-based node selection
|
||||
|
||||
- Scan Tooling Прил. Н §4.X for rows whose `triggers` attribute matches the classified task.
|
||||
- If ≥2 nodes match — apply ADR boundaries (the `boundaries` attribute points to the relevant ADR-NNN).
|
||||
- If conflict remains — apply PSR_v1 R15.3 (specificity priority).
|
||||
|
||||
### Step 4 — Canonical chain check (if applicable)
|
||||
|
||||
- If the matched node-set corresponds to one of L1–L12 chains in `routing-off-phase.md` §4 — invoke the chain.
|
||||
- If no chain matches — execute as ad-hoc combination. Observer will record `path_type: improvised`.
|
||||
|
||||
### Step 5 — Execution
|
||||
|
||||
- Invoke skill (if §12 applies) or apply selected node(s) by trigger.
|
||||
- Document boundary decisions in inline comments OR in the observer log.
|
||||
|
||||
## What this procedure does NOT consult
|
||||
|
||||
- **No cache of «verified chains»** (history-based records). Such a cache was explicitly rejected in brainstorming turn 8 (2026-05-19).
|
||||
- **No `last-used-on-real-task` attribute** on registry rows. Unused-status is not used to choose or skip a node (capability-readiness — see `memory/feedback_brain_unused_tools_not_problem.md`).
|
||||
- **No forced-choice gate**. Nodes that don't match triggers are silently skipped.
|
||||
|
||||
## When this procedure is consulted
|
||||
|
||||
Every turn — implicitly by Claude at session start, explicitly when routing is ambiguous.
|
||||
|
||||
## Relationship to other documents
|
||||
|
||||
- Pravila §12/§14/§15 — hard-floor; this procedure step 1 enforces them.
|
||||
- PSR_v1 R0–R14 — UI-stack apparatus; consulted in step 3 when task touches UI.
|
||||
- PSR_v1 R15 — off-phase routing extension; consulted in step 3 for off-phase nodes.
|
||||
- Tooling §3 / §4.X — node registry; the input to step 3.
|
||||
- routing-off-phase.md — chains L1–L12; consulted in step 4.
|
||||
|
||||
## Changelog
|
||||
|
||||
- **v1.0 (2026-05-19)** — initial fixation. Replaces implicit-scattered routing. ADR-011.
|
||||
@@ -0,0 +1,129 @@
|
||||
# Routing-аид: задача → off-phase узел
|
||||
|
||||
> **Назначение.** Quick-reference: триггер задачи → какой off-phase узел тулчейна
|
||||
> взять (Tooling §4.11–§4.35). Закрывает пробел SYSTEM-аудита 18.05.2026 (Rec3):
|
||||
> 30 off-phase инструментов регулировались плоским 3-блочным реестром PSR_v1 R10.1
|
||||
> без матрицы «задача → узел».
|
||||
>
|
||||
> **Scope.** Только off-phase (#31–#60 + ruflo, infrastructure). Активные фазовые
|
||||
> инструменты (#1–#29 + #30 Frontend Design) — карта CLAUDE.md §3.1/§3.2/§3.4.
|
||||
> Superpowers-skills и hard-rules — Pravila §12.2 (не дублируется здесь).
|
||||
>
|
||||
> **Источник истины.** Tooling §4.X (детальное описание каждого узла), Pravila §13.2
|
||||
> (категоризация off-phase), PSR_v1 R10.1 (3-блочный реестр ролей).
|
||||
>
|
||||
> **Версия.** 1.1 (18.05.2026 вечер — аудит дисциплины R15: +строка «диагностика
|
||||
> конверсии» → process-analysis #53 (M3); +note про UI-пул #31/#32 как делегирующие
|
||||
> строки, не R15-routed (M1). v1.0 — Rec3 SYSTEM-аудита). Триггеры — формулировки
|
||||
> заказчика или явные ключевые слова в промпте.
|
||||
|
||||
---
|
||||
|
||||
## Таблица routing
|
||||
|
||||
| Триггер задачи | Узел | # | Категория | Гейт |
|
||||
|---|---|---|---|---|
|
||||
| Архитектурное решение, ADR, обоснование выбора | **adr-kit** | #36 | architecture-tooling | `adr-judge` в lefthook job 9 |
|
||||
| C4 / контекст / контейнер / компонент-диаграмма | **mermaid-skill** | #37 | architecture-tooling | вендорен; рендера не нужно |
|
||||
| Справка по архитектурному паттерну (Clean/Hex/DDD/CQRS…) | **architecture-patterns** | #38 | architecture-tooling | knowledge-only |
|
||||
| Контроль направления зависимостей / границ слоёв `App\` | **deptrac** | #43 | architecture-tooling | lefthook pre-commit job 10 |
|
||||
| Security-аудит diff/PR, supply-chain риск, вариант-анализ | **Trail of Bits Skills** (8 плагинов) | #39 | audit-security | on-demand кампания |
|
||||
| Inline-предупреждения уязвимостей при правке кода | **Security Guidance** (PreToolUse-хук) | #40 | audit-security | блокирующий `sys.exit 2` |
|
||||
| SAST-сканер всего кода | **Semgrep MCP** | #25 (фаза 3) | — | npm run sast |
|
||||
| Полный security-review текущей ветки | `/security-review` (slash-команда) | — | audit-security | customized FP-фильтр |
|
||||
| Полный портальный аудит | **audit-portal** (project-скил) | — | audit-security | 14-фазный |
|
||||
| PRD → эпик → GitHub-issues → параллельные агенты → код | **CCPM** (vendored skill) | #41 | project-management | `.claude/prds/` + `.claude/epics/` |
|
||||
| PRD / roadmap-update / metrics-review / sprint-planning | **product-management** (Anthropic-плагин) | #42 | project-management | 9 slash-команд |
|
||||
| GitHub-issues операции (просмотр/создание) | **GitHub MCP** | #3 (фаза 0) | — | через `mcp__github__*` |
|
||||
| Извлечь дизайн-токены из Figma | **Figma MCP** | #44 | design-tooling | **DEFERRED** — нет Figma-аккаунта |
|
||||
| Вставить SVG-иконку из 10 коллекций (не Lucide) | **Universal Icons MCP** | #45 | design-tooling | ADR-006 D4: Lucide через `lucide-vue-next` |
|
||||
| Дизайн-критика / UX-копи / a11y-уровня дизайна / research synthesis | **Design plugin** | #46 | design-tooling | pre-code; Pa11y остаётся технический SoT |
|
||||
| Introspection OpenAPI/REST API чужой/своей | **openapi-mcp-server** | #47 | integration-tooling | READ-ONLY |
|
||||
| Генерация OpenAPI-спеки своего API | **api-docs agent** (claude-flow) | — | integration-tooling | без Tooling-номера |
|
||||
| Eval LLM-промпта / red-team / регрессия на промпт | **promptfoo** (npm CLI) | #48 | ml-ai-tooling | вручную/CI, **никогда в хук** (ML1) |
|
||||
| Классический ML-воркфлоу: алгоритм / feature eng / оценка | **Data Scientist skill** | #49 | ml-ai-tooling | knowledge-only |
|
||||
| Исполняемый ML-ноутбук с обучением | **Jupyter MCP** | #50 | ml-ai-tooling | **DEFERRED** — нет Python ML-окружения |
|
||||
| Документировать/оптимизировать/change-management бизнес-процесс | **operations** (9 скилов) | #51 | business-process | Mermaid-рендер делегирует #37 |
|
||||
| BPMN 2.0 to-be модель процесса, RACI, state-машина | **process-modeling** (project-скил) | #52 | business-process | как process-discovery from-head |
|
||||
| As-is discovery процесса из кода Laravel + audit-логов, узкие места, KPI | **process-analysis** (project-скил) | #53 | business-process | from-code; ≠ discovery-interview (from-head) |
|
||||
| Диагностика просадки метрики/конверсии (почему падает B2, где теряем в воронке) | **process-analysis** (project-скил) | #53 | business-process | from-code + audit-данные; discovery-interview SKIP-кейс |
|
||||
| n8n workflow-движок | **n8n-mcp** | #54 | business-process | **DEFERRED** — n8n не в стеке |
|
||||
| Интервью-discovery перед фичей (FEATURE) / ориентация по проекту (SYSTEM) | **discovery-interview** (project-скил) | #55 | discovery-tooling | разрез по слою-источнику с #53 (ADR-009) |
|
||||
| Brainstorm: проблема не очерчена, нужно вскрыть | `superpowers:brainstorming` | — | (Superpowers, §12.2) | не off-phase, но связан |
|
||||
| Создать новый скил из ≥3 повторений workflow | **skill-creator** | #56 | authoring-tooling | политика триггеров ADR-010 |
|
||||
| Создать новый Claude Code plugin | **plugin-dev** | #57 | authoring-tooling | knowledge for plugin authoring |
|
||||
| Создать хук на повторяющуюся ошибку | **hookify** | #58 | authoring-tooling | **HK1 pre-check** на коллизию economy/skill-discipline |
|
||||
| Подсказки настроек Claude Code для проекта | **claude-code-setup** | #59 | dev-support | recommender |
|
||||
| Текущая документация библиотеки/SDK/CLI | **context7** | #60 | dev-support | вместо WebSearch для библиотек |
|
||||
| Отладка production runtime errors через self-hosted Sentry | **Sentry MCP** | #34 | debug-runtime | READ-ONLY, pending Б-1 deployment |
|
||||
| Отладка Redis/Memurai очередей / кэша / Pest-квирков 73/77 | **Redis MCP** | #35 | debug-runtime | READ-ONLY обязательно |
|
||||
| Правки `CLAUDE.md` | **claude-md-management** | #33 | infrastructure | §5 п.10 — единственный канал |
|
||||
| UI-резерв (50+ стилей / 161 палитра / 99 UX-гайдлайнов / 25 чартов) | **UI UX Pro Max** | #31 | UI-пул † | PSR_v1 R14.3 pipeline; R6.0+R6.1 фильтр |
|
||||
| UI стартовый шаблон / иконка-логотип бренда | **21st Magic MCP** | #32 | UI-пул † | PSR_v1 R14.4 pipeline; R6.0+R6.1 фильтр |
|
||||
| Оркестрация роя / queen / королева | **ruflo** | — | orchestration | **ИЗОЛИРОВАН 18.05.2026** (Pravila §14.9 dormant) |
|
||||
|
||||
> **† UI-пул (#31 UPM / #32 21st) — делегирующие строки.** R15.6 явно: к UI-пулу R15
|
||||
> не применяется — это UI-задачи по природе, их ведёт R14 pipeline. Строки выше —
|
||||
> не off-phase routing, а указатель «иди в R14», включены для полноты единого
|
||||
> reference. Фактический регламент UPM/21st — PSR_v1 R14.3/R14.4, не эта таблица.
|
||||
|
||||
---
|
||||
|
||||
## Канонические связки 2+ узлов
|
||||
|
||||
> Закрывает Rec4 SYSTEM-аудита 18.05.2026 («сводного списка рекомендованных комбо
|
||||
> нет — синергия размазана по 125 полям `together` в NODE_DETAILS карты»).
|
||||
> Здесь — 12 цепочек, где совместная работа узлов даёт эффект сильнее суммы. UI-рендер
|
||||
> панели «🔗 Связки» на карте — отдельный future iter; данные ниже — основа для него.
|
||||
|
||||
| # | Цепочка | Зачем |
|
||||
|---|---|---|
|
||||
| L1 | `discovery-interview` (FEATURE) → `brainstorming` → `writing-plans` → `subagent-driven-development` | Полный цикл от боли заказчика до атомарных коммитов. Передаёт discovery-brief в brainstorming без re-asking; план потом исполняется параллельными субагентами. |
|
||||
| L2 | `discovery-interview` (SYSTEM) + `audit-portal` | Ориентация по состоянию (где мы) + вердикт здоровья портала. Snapshot 18.05 — пример L2 в действии. |
|
||||
| L3 | `process-analysis` (#53) ↔ `process-modeling` (#52) | As-is из кода ↔ to-be BPMN. Парная работа: #53 вскрывает узкое место, #52 моделирует целевую схему. Разрез по слою-источнику — ADR-009. |
|
||||
| L4 | `mermaid-skill` (#37) ← `adr-kit` (#36) / `process-modeling` (#52) / `operations` (#51) | Mermaid рендерит C4 / BPMN / process-doc для трёх потребителей. Single source of truth для диаграмм. |
|
||||
| L5 | `adr-kit` (#36) + `architecture-patterns` (#38) + `deptrac` (#43) | Архитектурный треугольник: решение → паттерн-обоснование → fitness-контроль (статический enforcement через lefthook job 10). |
|
||||
| L6 | `Trail of Bits` (#39) + `Semgrep MCP` (#25) + `Security Guidance` (#40) + `/security-review` | Слоистый security: блокирующий PreToolUse-хук на правке → inline SAST → on-demand глубокий аудит → review всего бренча. TB1 / SG1 — границы. |
|
||||
| L7 | `openapi-mcp-server` (#47) + `api-docs agent` + `Boost MCP` (#10) | Интеграционная разработка: спека-introspection + генератор спеки + Laravel API. Раздел A3 карты. |
|
||||
| L8 | `systematic-debugging` + `Sentry MCP` (#34) + `Redis MCP` (#35) | Runtime-баги: ≥3 гипотезы → факты из production (Sentry) + factual из очередей/кэша (Redis). Snapshot указал как недоиспользуемую связку. |
|
||||
| L9 | `CCPM` (#41) + `product-management` (#42) + `GitHub MCP` (#3) | PRD → эпики → GitHub issues. CCPM делает трассируемость; product-management — продуктовые церемонии; GitHub MCP — issue/PR-операции. |
|
||||
| L10 | `promptfoo` (#48) + `Data Scientist skill` (#49) + `claude-api skill` (Sonnet 4.6 SDK) | LLM-фича: eval LLM-промптов + ML-воркфлоу + Anthropic SDK. ML1 — promptfoo только вручную/CI. |
|
||||
| L11 | `skill-creator` (#56) + `hookify` (#58) + `plugin-dev` (#57) | Расширение Claude-инфраструктуры: ≥3 повторений workflow → новый скил / ошибка повторяется → новый хук (HK1 pre-check) / задача требует плагина → plugin-dev. |
|
||||
| L12 | `claude-md-management` (#33) + `revise-claude-md` skill | Захват session-learnings → CLAUDE.md update. Единственный канал §5 п.10. |
|
||||
|
||||
**Anti-pattern связок** (не комбинировать в одной задаче):
|
||||
|
||||
- **UPM (#31) ↔ Frontend Design (#30) ↔ 21st (#32)** — PSR_v1 R14.5 запрещает параллельное использование UI-генераторов (один pipeline, один решатель).
|
||||
- **ruflo Queen ↔ subagent-driven-development напрямую** — ruflo сейчас изолирован (Pravila §14.9 dormant). При реактивации связь снова работоспособна.
|
||||
- **`mcp_figma` (DEFERRED) → Frontend Design code-gen** — ADR-006 FM1: extract-only для Figma; code-gen дублировал бы FD.
|
||||
- **`Data Scientist skill` (#49) → решатель в коде** — knowledge-only, не пишет код за вас (R10.2 PSR_v1).
|
||||
|
||||
---
|
||||
|
||||
## Дисциплина выбора
|
||||
|
||||
1. **Не подменяй фазовые инструменты off-phase.** Если задача попадает под фазу
|
||||
0/1/2/3 (Tooling §2–§5) — берём фазовый узел. Off-phase — резерв и специализация.
|
||||
2. **DEFERRED-узлы (#44 Figma / #50 Jupyter / #54 n8n)** — не использовать без явного
|
||||
возобновления (нет аккаунта / нет Python ML / нет n8n в стеке). Запрос «через Figma»
|
||||
при текущем состоянии = эскалация заказчику.
|
||||
3. **Изолированные узлы (ruflo на 18.05.2026)** — не маршрутизировать. Запрос
|
||||
с `queen`/`королева` сейчас выполняется напрямую (§14 dormant). При запросе
|
||||
реактивации — план в memory `feedback_ruflo_isolated.md`.
|
||||
4. **UI-пул (#31 UPM / #32 21st)** — только через R14 pipeline. Никогда не решатель,
|
||||
всегда материал; обязательны R6.0 фильтр + R6.1 hard-override Forest + FD адаптация.
|
||||
5. **Hard-rules (Pravila §12 / §14 / §15)** перевешивают этот routing-аид при коллизии.
|
||||
6. **Граничные случаи между похожими узлами** — кросс-ссылки в Tooling §4.X (например
|
||||
#53 process-analysis ↔ #55 discovery-interview через слой-источник ADR-009;
|
||||
#39 ToB ↔ #25 Semgrep MCP через scope TB1).
|
||||
|
||||
---
|
||||
|
||||
## Связано
|
||||
|
||||
- [docs/Tooling_v8_3.md](Tooling_v8_3.md) §4.11–§4.35 — детальные описания узлов.
|
||||
- [docs/Pravila_raboty_Claude_v1_1.md](Pravila_raboty_Claude_v1_1.md) §13.2 — категоризация off-phase.
|
||||
- [docs/Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) R10.1 — 3-блочный реестр ролей; R14 — UI-пул pipeline.
|
||||
- `CLAUDE.md` §3.3 — оперативная карта с командами установки.
|
||||
- `docs/discovery/2026-05-18-system-audit-brain.md` Rec3 — источник решения.
|
||||
- `docs/adr/*.md` — границы между категориями (ADR-003/004/005/006/007/008/009/010).
|
||||
@@ -3,6 +3,36 @@
|
||||
> Формат и жизненный цикл записей: [docs/sessions/README.md](README.md).
|
||||
> Pravila §15.2 — описание правила в нормативке.
|
||||
|
||||
## session: 2026-05-19-supplier-project-channel-failover
|
||||
|
||||
- branch: feat/supplier-project-failover
|
||||
- worktree: c:/моя/проекты/портал crm/Документация/.claude/worktrees/supplier-project-failover
|
||||
- started: 2026-05-19T11:30+03:00
|
||||
- scope-files:
|
||||
- app/app/Contracts/Supplier/SupplierProjectChannel.php (new)
|
||||
- app/app/Services/Supplier/SupplierPortalClient.php (docblock + listProjects idempotency)
|
||||
- app/app/Services/Supplier/AjaxProjectChannel.php (new)
|
||||
- app/app/Services/Supplier/FailoverProjectChannel.php (new)
|
||||
- app/app/Services/Supplier/FormProjectChannel.php (new)
|
||||
- app/app/Exceptions/Supplier/WindowDeferredException.php (new)
|
||||
- app/app/Exceptions/Supplier/TierEscalatedException.php (new)
|
||||
- app/app/Jobs/SyncSupplierProjectJob.php (wire FailoverProjectChannel)
|
||||
- app/app/Jobs/Supplier/SyncSupplierProjectsJob.php (wire FailoverProjectChannel)
|
||||
- app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php (+manualQueueIndex/Resolve)
|
||||
- app/app/Providers/AppServiceProvider.php (DI binding)
|
||||
- app/database/migrations/2026_05_19_supplier_manual_sync_queue.php (new)
|
||||
- app/playwright/manage-project.js (new)
|
||||
- app/resources/js/views/admin/AdminSupplierIntegrationView.vue (+worklist section)
|
||||
- app/routes/console.php (cron retime 20:30→18:00, 20:15→17:45)
|
||||
- db/schema.sql (§15.2 — +supplier_manual_sync_queue table)
|
||||
- db/CHANGELOG_schema.md (§15.2 — entry)
|
||||
- version-claims:
|
||||
- db/schema.sql: v8.24 → v8.25 (T3 +supplier_manual_sync_queue)
|
||||
- status: closed
|
||||
- closes: docs/superpowers/plans/2026-05-19-supplier-project-channel-failover.md
|
||||
|
||||
---
|
||||
|
||||
## session: 2026-05-18-parallel-sessions-coordination
|
||||
|
||||
- branch: feat/parallel-sessions-coordination
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Компакция «мозга»: findings 2/3/6/7 — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans (выбрано inline-исполнение в текущей сессии). Steps — checkbox `- [ ]`.
|
||||
|
||||
**Goal:** Сжать CLAUDE.md до оперативной карты — убрать дублирование реестра инструментов с Tooling, привести счётчики к одному источнику, свести ruflo к dormant-стабу, освежить устаревшую memory-запись.
|
||||
|
||||
**Architecture:** §3.3 CLAUDE.md (60 строк-абзацев) → компактный индекс с пином на Tooling Прил. Н. Счётчики инструментов и метрики схемы → single-source (Tooling Прил. Н §0 / `db/schema.sql` header); CLAUDE.md / Pravila / PSR_v1 пинуют, не дублируют числа. ruflo §3.5 → короткий dormant-стаб + пин. Pravila §14 → dormant-метка в заголовке. `reference_archive.md` → освежить под текущий HEAD.
|
||||
|
||||
**Tech Stack:** Markdown; плагин `claude-md-management` для CLAUDE.md (§5 п.10); прямой Edit для Pravila / Tooling / PSR_v1 / memory; markdownlint + lychee для верификации.
|
||||
|
||||
**Scope guard:** Findings 1 (раздувание шапки / §0 «наследие»-цепочек) и 5 (баг парсера хука экономии) — заказчиком НЕ выбраны, вне scope. Версионные «наследие»-клаузы шапки не трогаем сверх обычного bump v2.16→v2.17.
|
||||
|
||||
**Pre-flight (§15.2) — выполнен:** origin/main `a8e0cc9` ⊆ local HEAD `3b59bd4`; CLAUDE.md / Pravila / Tooling / PSR_v1 в working tree чисты. Входящих изменений нет. В дереве несвязанная uncommitted-работа (`db/schema.sql`, `db/CHANGELOG_schema.md`, `docs/automation-graph.html`, supplier-миграция/тест) — НЕ трогать, НЕ коммитить (только явный `git add` своих файлов, не `-A`).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Tooling: зафиксировать §0 как канон счётчиков
|
||||
|
||||
**Files:** Modify `docs/Tooling_v8_3.md` (Прил. Н §0, §13 changelog, шапка версии)
|
||||
|
||||
- [ ] Re-read Tooling Прил. Н §0 + §4.10 + §13.
|
||||
- [ ] В Прил. Н §0 добавить строку-якорь: «**КАНОН СЧЁТЧИКОВ.** Числа инструментов / off-phase подкатегорий — здесь; CLAUDE.md / Pravila / PSR_v1 ссылаются сюда, не дублируют.»
|
||||
- [ ] Bump шапка Прил. Н v2.15→v2.16; §13 changelog +entry.
|
||||
- [ ] Verify: `npm run lint:md` на файле — full output.
|
||||
|
||||
## Task 2 — CLAUDE.md: компакция §3.3 + счётчики + ruflo-стаб (через `claude-md-management`)
|
||||
|
||||
**Files:** Modify `CLAUDE.md` (шапка, §0, §1 row 2b, §2 БД, §3 title, §3.3, §3.5, §8, §9)
|
||||
|
||||
- [ ] Invoke `/claude-md-management:claude-md-improver`.
|
||||
- [ ] §3.3: строки #31–#60 → одна строка каждая, формат «задача кратко | инструмент | off-phase, <категория> — Tooling §4.NN». Строки #19–#24 и #30 (phase-active) — оставить как есть.
|
||||
- [ ] §3.3 footer (большой парентетикал «Нумерация… Total… 60») → один пин: «полный реестр, счётчики, нумерация — Tooling Прил. Н §0; off-phase routing — §3.7».
|
||||
- [ ] §3 title «Карта 60 инструментов + ruflo advisory-подсистема — …» → «Карта инструментов — «когда что использовать»».
|
||||
- [ ] §1 priority-chain row 2b «детальный реестр 60 инструментов» → «детальный реестр инструментов».
|
||||
- [ ] §2 строка БД + §8 self-review строка `db/schema.sql`: inline schema-метрики («63 базовые таблицы…», «75 dev-actual…») → пин «метрики схемы — header `db/schema.sql` + `db/CHANGELOG_schema.md`».
|
||||
- [ ] §3.5: сжать bold STATUS-блок до ~3 строк; удалить исторические абзацы (ruflo npm-описание / Роль в рантайме / Категория / Cost-budget / Runtime state 2026-05-15 / Queen trigger / Подробности); концовка — пин «история активного периода и план реактивации — Pravila §14.9, Tooling §4.10, memory `feedback_ruflo_isolated.md` + `project_ruflo_integration.md`».
|
||||
- [ ] §0 cross-refs: bump Pravila v1.29→v1.30, PSR_v1 v3.14→v3.15, Tooling Прил.Н v2.15→v2.16 (только номера версий; «наследие»-цепочки §0 не трогаем — finding 1 вне scope).
|
||||
- [ ] Шапка version v2.16→v2.17; §9 +entry.
|
||||
- [ ] Verify: `npm run lint:md` + `npm run links` — full output.
|
||||
|
||||
## Task 3 — Pravila: §14 dormant-метка + §13.2 пин счётчиков
|
||||
|
||||
**Files:** Modify `docs/Pravila_raboty_Claude_v1_1.md` (§14 заголовок + §14.1, §13.2, §10 changelog, шапка)
|
||||
|
||||
- [ ] Re-read Pravila §13.2 + §10 changelog + шапка.
|
||||
- [ ] §14 заголовок → «## 14. Ruflo Queen routing — hard rule (СТАТУС: dormant с 18.05.2026, см. §14.9)»; в §14.1 первой фразой врезать «**(Правило сейчас dormant — §14.9; queen-промпт исполняется напрямую.)**».
|
||||
- [ ] §13.2: числовые счётчики off-phase подкатегорий/инструментов → пин «счётчики — Tooling Прил. Н §0» (текст абзацев не реструктурировать, заменить только хард-числа).
|
||||
- [ ] Bump шапка v1.29→v1.30; §10 +changelog entry.
|
||||
- [ ] Verify: `npm run lint:md` — full output.
|
||||
|
||||
## Task 4 — PSR_v1: R10.1 пин счётчиков
|
||||
|
||||
**Files:** Modify `docs/Plugin_stack_rules_v1.md` (R10.1, changelog, шапка)
|
||||
|
||||
- [ ] Re-read PSR_v1 R10.1 + История версий + шапка.
|
||||
- [ ] R10.1: числовые счётчики позиций → пин «реестр и счётчики — Tooling Прил. Н §0».
|
||||
- [ ] Bump шапка v3.14→v3.15; История версий +entry.
|
||||
- [ ] Verify: `npm run lint:md` — full output.
|
||||
|
||||
## Task 5 — memory: освежить `reference_archive.md`
|
||||
|
||||
**Files:** Modify `memory/reference_archive.md` + `memory/MEMORY.md` (строка индекса)
|
||||
|
||||
- [ ] Re-read `reference_archive.md` полностью.
|
||||
- [ ] Обновить тело под текущий HEAD / schema-версию ИЛИ урезать до актуальных пинов; снять NB «тело файла — снапшот, устарело».
|
||||
- [ ] Обновить строку индекса в `MEMORY.md` под новое содержание.
|
||||
|
||||
## Task 6 — Верификация + cross-ref self-review
|
||||
|
||||
- [ ] `npm run lint:md` + `npm run links` на всех изменённых `.md` — full output, failure'ы выписать с file:line.
|
||||
- [ ] Cross-ref self-review: версии в §0 CLAUDE.md ↔ шапки Pravila / Tooling / PSR_v1 согласованы; 0 битых §-ссылок; каждый пин §3.3 «Tooling §4.NN» существует в Tooling.
|
||||
|
||||
## Task 7 — Коммиты (после явного OK заказчика)
|
||||
|
||||
- [ ] Показать заказчику сводку изменений; спросить «коммичу?» (commit — отдельное действие, без явного «да» не коммитить).
|
||||
- [ ] При OK: атомарные коммиты per логический блок — (a) Tooling, (b) Pravila, (c) PSR_v1, (d) CLAUDE.md, (e) план-документ. `git add` только своих файлов.
|
||||
- [ ] НЕ коммитить `db/schema.sql` / `db/CHANGELOG_schema.md` / `docs/automation-graph.html` / supplier-* (чужая uncommitted-работа). memory-файлы вне git-репо — не коммитятся.
|
||||
@@ -0,0 +1,885 @@
|
||||
# Deals drawer + project source edit — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Привести drawer-«легенду» сделки и карточку проекта к запросу заказчика 18.05.2026 — статус-picker, корректные параметры (Тип/Источник), selected-driven видимость drawer/bulk-полосы, редактирование источника проекта.
|
||||
|
||||
**Architecture:** 5 атомарных задач (1 коммит = 1 task). Frontend-only задачи 1-4 (Vue/TS). Задача 5 расширяет backend (UpdateProjectRequest + ProjectController) + UI ProjectDetailsDrawer. TDD per task: failing test → minimal impl → vitest/pest → commit.
|
||||
|
||||
**Tech Stack:** Vue 3 + Vuetify 3 + Pinia, Laravel 13 + Pest 4, axios + ApiClient.
|
||||
|
||||
**Источник истины** для решений: AskUserQuestion ответы 18.05.2026:
|
||||
|
||||
- п.1: «при выборе 1 сделки она не нужна, нужна только легенда справа»
|
||||
- п.2: «при выборе 2-х и более легенда не нужна а полоса нужна»
|
||||
- п.3: статус в drawer кликабельный, dropdown статусов
|
||||
- п.4: убрать «Менеджер»/«Не назначен»
|
||||
- п.5: B-префикс уже убран (commit `36ea9cd`)
|
||||
- п.6: формат «отправитель + (ключевое слово как в карточке создания)» = `signal_identifier` для site/call; для sms — `sms_senders[0]` + `(${sms_keyword})` если есть
|
||||
- п.7: «Тип» (Сайт/Звонок/СМС) вместо «Менеджер»
|
||||
- п.8: подпись «Источник» над полями на 3 табах NewProjectDialog
|
||||
- п.9: редактировать источник **только в карточке проекта** (ProjectDetailsDrawer на /projects); в drawer сделки источник read-only
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Что делает |
|
||||
|---|---|
|
||||
| `app/resources/js/views/DealsView.vue` | Selected-driven: drawer hidden при ≥2 selected; auto-open при selected=1 |
|
||||
| `app/resources/js/components/deals/DealDetailHero.vue` | StatusPill → inline statuspicker (`v-menu` со списком статусов) |
|
||||
| `app/resources/js/components/deals/DealDetailBody.vue` | Убрать «Менеджер», добавить «Тип» + «Источник» (read-only) |
|
||||
| `app/resources/js/composables/mockDeals.ts` | +поля projectSignalType / projectSignalIdentifier / projectSmsSenders / projectSmsKeyword |
|
||||
| `app/resources/js/composables/dealsApiMapper.ts` | Маппинг новых API-полей |
|
||||
| `app/resources/js/api/deals.ts` | Расширить ApiDeal интерфейс новыми полями |
|
||||
| `app/app/Http/Controllers/Api/DealController.php` | Eager-load + отдавать новые поля проекта в payload |
|
||||
| `app/resources/js/views/projects/NewProjectDialog.vue` | Подпись «Источник» над полями на 3 табах |
|
||||
| `app/resources/js/components/projects/ProjectDetailsDrawer.vue` | Добавить редактирование signal_identifier (site/call) + sms_senders/keyword (sms) |
|
||||
| `app/app/Http/Requests/UpdateProjectRequest.php` | +правила валидации signal_identifier по signal_type проекта |
|
||||
| `app/app/Http/Controllers/Api/ProjectController.php` | update() — пропустить signal_identifier в Project::update |
|
||||
| `app/resources/js/stores/projectsStore.ts` | Project type — поле signal_identifier ОК; проверить только |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Selected-driven drawer visibility (пп. 1+2)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/DealsView.vue` (полная логика panelOpen ↔ selected.length)
|
||||
- Test: `app/tests/Frontend/DealsView.spec.ts` (расширить существующий)
|
||||
|
||||
**Логика:**
|
||||
|
||||
- `selected.length === 0` → row-click открывает drawer (как сейчас)
|
||||
- `selected.length === 1` → drawer **авто-открыт для этой сделки**, bulk-полоса **скрыта**
|
||||
- `selected.length >= 2` → drawer **закрыт**, bulk-полоса видна
|
||||
|
||||
DealsBulkBar уже показывается только при `selectedCount > 0` (нужно перепроверить — возможно показать при `>= 2` only).
|
||||
|
||||
- [ ] **Step 1: Failing test для авто-открытия при selected=1**
|
||||
|
||||
В `app/tests/Frontend/DealsView.spec.ts` добавить:
|
||||
|
||||
```ts
|
||||
it('при selected=1 drawer авто-открывается на выбранной сделке, bulk-полоса скрыта', async () => {
|
||||
const w = mount(DealsView, { global: { plugins: [vuetify, createPinia()] } });
|
||||
await flushPromises();
|
||||
w.vm.dealsState.push({ id: 42, name: 'X', phone: '+79991234567', statusSlug: 'new', project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 0 } as never);
|
||||
w.vm.selected = [42];
|
||||
await nextTick();
|
||||
expect(w.vm.panelOpen).toBe(true);
|
||||
expect(w.vm.selectedDeal?.id).toBe(42);
|
||||
});
|
||||
|
||||
it('при selected>=2 drawer закрывается', async () => {
|
||||
const w = mount(DealsView, { global: { plugins: [vuetify, createPinia()] } });
|
||||
await flushPromises();
|
||||
w.vm.dealsState.push({ id: 42, name: 'X', phone: '+1', statusSlug: 'new', project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 0 } as never);
|
||||
w.vm.dealsState.push({ id: 43, name: 'Y', phone: '+2', statusSlug: 'new', project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 0 } as never);
|
||||
w.vm.panelOpen = true;
|
||||
w.vm.selectedDeal = w.vm.dealsState[0];
|
||||
w.vm.selected = [42, 43];
|
||||
await nextTick();
|
||||
expect(w.vm.panelOpen).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест — должен FAIL**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealsView.spec.ts -t "при selected" --reporter=verbose`
|
||||
|
||||
- [ ] **Step 3: Добавить watcher в DealsView.vue**
|
||||
|
||||
Найти `watch([filterStatus, filterProject, receivedFrom, receivedTo, perPage], …)` (~строка 108) и **после** добавить:
|
||||
|
||||
```ts
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
И **скрыть bulk-полосу при selected=1** — изменить отображение DealsBulkBar:
|
||||
|
||||
```vue
|
||||
<DealsBulkBar
|
||||
v-if="selected.length >= 2"
|
||||
v-model:status-menu-open="statusMenuOpen"
|
||||
:selected-count="selected.length"
|
||||
:lead-statuses="leadStatuses"
|
||||
@apply-status="applyBulkStatus"
|
||||
@clear-selected="selected = []"
|
||||
/>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vitest пройти GREEN**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealsView.spec.ts --reporter=default`
|
||||
Expected: все passes, в т.ч. 2 новых.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/DealsView.vue app/tests/Frontend/DealsView.spec.ts
|
||||
git commit -m "feat(deals): drawer виден при selected≤1, bulk-полоса только при ≥2"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: API: project source fields в drawer сделки (пп. 4+6+7)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/DealController.php` (eager-load + payload)
|
||||
- Modify: `app/resources/js/api/deals.ts` (ApiDeal +4 поля)
|
||||
- Modify: `app/resources/js/composables/mockDeals.ts` (MockDeal +4 поля)
|
||||
- Modify: `app/resources/js/composables/dealsApiMapper.ts` (маппер +4 поля)
|
||||
- Modify: `app/resources/js/components/deals/DealDetailBody.vue` (UI: убрать Менеджер, +Тип, +Источник)
|
||||
- Test: `app/tests/Feature/Deals/DealShowEndpointTest.php` или подобный для controller; `app/tests/Frontend/DealDetailBody.spec.ts` если есть, иначе расширить DealDetailDrawer.spec.ts
|
||||
|
||||
- [ ] **Step 1: Pest failing для API payload**
|
||||
|
||||
В существующем тесте `app/tests/Feature/Deals/*.php` для GET /api/deals/{id} добавить assertion:
|
||||
|
||||
```php
|
||||
it('returns project signal_identifier/sms_keyword/sms_senders in deal payload', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => 'MTS',
|
||||
'sms_senders' => ['MTS', 'BEELINE'],
|
||||
'sms_keyword' => 'КРЕДИТ',
|
||||
]);
|
||||
$deal = Deal::factory()->create(['tenant_id' => $tenant->id, 'project_id' => $project->id]);
|
||||
|
||||
actingAsTenant($tenant);
|
||||
$resp = $this->getJson("/api/deals/{$deal->id}?tenant_id={$tenant->id}");
|
||||
|
||||
$resp->assertOk()->assertJsonPath('deal.project_signal_identifier', 'MTS');
|
||||
$resp->assertJsonPath('deal.project_sms_keyword', 'КРЕДИТ');
|
||||
$resp->assertJsonPath('deal.project_sms_senders.0', 'MTS');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — FAIL**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --filter "returns project signal_identifier"
|
||||
```
|
||||
|
||||
Expected: FAIL (поля отсутствуют в payload).
|
||||
|
||||
- [ ] **Step 3: Расширить DealController eager-load + transformer**
|
||||
|
||||
В `app/app/Http/Controllers/Api/DealController.php`:
|
||||
|
||||
- Строка 112 (`->with(['project:id,name,signal_type', ...])`) → расширить:
|
||||
`->with(['project:id,name,signal_type,signal_identifier,sms_keyword,sms_senders', 'manager:id,email,first_name,last_name']);`
|
||||
- Строка 215 (`'project_signal_type' => …`) → добавить ниже:
|
||||
|
||||
```php
|
||||
'project_signal_identifier' => $d->project?->signal_identifier,
|
||||
'project_sms_keyword' => $d->project?->sms_keyword,
|
||||
'project_sms_senders' => $d->project?->sms_senders,
|
||||
```
|
||||
|
||||
Найти аналогичные места в `show()` методе (если есть) и добавить там же.
|
||||
|
||||
- [ ] **Step 4: Pest passes**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --filter "returns project signal_identifier" -v
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Расширить TypeScript интерфейсы**
|
||||
|
||||
В `app/resources/js/api/deals.ts` интерфейс `ApiDeal` (строка 153) — добавить:
|
||||
|
||||
```ts
|
||||
project_signal_identifier: string | null;
|
||||
project_sms_keyword: string | null;
|
||||
project_sms_senders: string[] | null;
|
||||
```
|
||||
|
||||
В `app/resources/js/composables/mockDeals.ts` интерфейс `MockDeal` (строка 10) — добавить:
|
||||
|
||||
```ts
|
||||
projectSignalType?: 'site' | 'call' | 'sms' | null;
|
||||
projectSignalIdentifier?: string | null;
|
||||
projectSmsKeyword?: string | null;
|
||||
projectSmsSenders?: string[] | null;
|
||||
```
|
||||
|
||||
В `app/resources/js/composables/dealsApiMapper.ts` функция `mapApiDeal` — добавить маппинг новых полей.
|
||||
**ПРИМЕЧАНИЕ:** прочитать актуальный файл перед правкой, см. поля `signalType: d.project_signal_type as MockDeal['signalType']` — добавить аналогично:
|
||||
|
||||
```ts
|
||||
projectSignalType: d.project_signal_type as MockDeal['projectSignalType'],
|
||||
projectSignalIdentifier: d.project_signal_identifier,
|
||||
projectSmsKeyword: d.project_sms_keyword,
|
||||
projectSmsSenders: d.project_sms_senders,
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Failing Vitest test для UI Drawer**
|
||||
|
||||
Создать `app/tests/Frontend/DealDetailBody.spec.ts` (или расширить, если есть):
|
||||
|
||||
```ts
|
||||
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();
|
||||
setActivePinia(createPinia());
|
||||
|
||||
function makeDeal(overrides: Partial<MockDeal> = {}): MockDeal {
|
||||
return {
|
||||
id: 1, name: 'A', phone: '+79991234567', statusSlug: 'new',
|
||||
project: 'p', manager: { initials: 'AD', name: 'A' }, cost: 0,
|
||||
receivedMinutesAgo: 1,
|
||||
projectSignalType: 'site', projectSignalIdentifier: 'krk-finance.ru',
|
||||
projectSmsKeyword: null, projectSmsSenders: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('DealDetailBody — Тип и Источник (18.05.2026)', () => {
|
||||
it('показывает Тип «Сайт» и Источник = signal_identifier для site', () => {
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal() },
|
||||
global: { plugins: [vuetify, createPinia()] },
|
||||
});
|
||||
expect(w.text()).toContain('Сайт');
|
||||
expect(w.text()).toContain('krk-finance.ru');
|
||||
});
|
||||
|
||||
it('для sms показывает sender + (keyword)', () => {
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal({
|
||||
projectSignalType: 'sms',
|
||||
projectSignalIdentifier: null,
|
||||
projectSmsSenders: ['MTS', 'BEELINE'],
|
||||
projectSmsKeyword: 'КРЕДИТ',
|
||||
}) },
|
||||
global: { plugins: [vuetify, createPinia()] },
|
||||
});
|
||||
expect(w.text()).toContain('СМС');
|
||||
expect(w.text()).toContain('MTS (КРЕДИТ)');
|
||||
});
|
||||
|
||||
it('для sms без keyword показывает только sender', () => {
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal({
|
||||
projectSignalType: 'sms',
|
||||
projectSignalIdentifier: null,
|
||||
projectSmsSenders: ['MTS'],
|
||||
projectSmsKeyword: null,
|
||||
}) },
|
||||
global: { plugins: [vuetify, createPinia()] },
|
||||
});
|
||||
expect(w.text()).toContain('СМС');
|
||||
expect(w.text()).toContain('MTS');
|
||||
expect(w.text()).not.toMatch(/\([^)]*\)/);
|
||||
});
|
||||
|
||||
it('не отображает «Менеджер» секцию', () => {
|
||||
const w = mount(DealDetailBody, {
|
||||
props: { deal: makeDeal() },
|
||||
global: { plugins: [vuetify, createPinia()] },
|
||||
});
|
||||
expect(w.text()).not.toContain('Менеджер');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Run — FAIL**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealDetailBody.spec.ts --reporter=verbose`
|
||||
|
||||
Expected: 4 fails.
|
||||
|
||||
- [ ] **Step 8: Реализация DealDetailBody.vue**
|
||||
|
||||
В `app/resources/js/components/deals/DealDetailBody.vue`:
|
||||
|
||||
1. Добавить helpers в `<script setup>`:
|
||||
|
||||
```ts
|
||||
const TYPE_LABELS: Record<string, string> = { site: 'Сайт', call: 'Звонок', sms: 'СМС' };
|
||||
const projectTypeLabel = computed((): string =>
|
||||
props.deal?.projectSignalType ? (TYPE_LABELS[props.deal.projectSignalType] ?? '—') : '—',
|
||||
);
|
||||
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 '—';
|
||||
});
|
||||
```
|
||||
|
||||
1. В `<template>` найти блок `<div class="param">` для «Менеджер» (около строки 171-180) — **удалить целиком**.
|
||||
2. Между «Стоимость лида» и (где был Менеджер) добавить:
|
||||
|
||||
```vue
|
||||
<div class="param">
|
||||
<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">{{ projectSourceLabel }}</dd>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Vitest GREEN**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealDetailBody.spec.ts --reporter=default`
|
||||
Expected: 4 passes.
|
||||
|
||||
- [ ] **Step 10: Full Vitest перед коммитом**
|
||||
|
||||
`cd app && npx vitest run --reporter=default --maxWorkers=2`
|
||||
|
||||
Expected: 0 failed.
|
||||
|
||||
- [ ] **Step 11: Pest full перед коммитом**
|
||||
|
||||
`cd app && ./vendor/bin/pest --parallel 2>&1 | tail -10`
|
||||
|
||||
Expected: 0 failed (новый Deal-show-payload тест passes).
|
||||
|
||||
- [ ] **Step 12: Build**
|
||||
|
||||
`cd app && npm run build 2>&1 | tail -5`
|
||||
|
||||
Expected: `built in …s`.
|
||||
|
||||
- [ ] **Step 13: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Controllers/Api/DealController.php \
|
||||
app/resources/js/api/deals.ts \
|
||||
app/resources/js/composables/mockDeals.ts \
|
||||
app/resources/js/composables/dealsApiMapper.ts \
|
||||
app/resources/js/components/deals/DealDetailBody.vue \
|
||||
app/tests/Frontend/DealDetailBody.spec.ts \
|
||||
app/tests/Feature/Deals/*.php
|
||||
git commit -m "feat(deals/drawer): убрать «Менеджер», добавить «Тип» + «Источник» read-only"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Inline status picker в drawer (п. 3)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/deals/DealDetailHero.vue`
|
||||
- Test: `app/tests/Frontend/DealDetailHero.spec.ts` (создать или расширить)
|
||||
|
||||
**Логика:** клик по статус-чипу → `v-menu` с `v-list` всех статусов → выбор отправляет `PATCH /api/deals/{id} { status: <slug> }`, optimistic UI.
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import DealDetailHero from '../../resources/js/components/deals/DealDetailHero.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
const statuses = [
|
||||
{ slug: 'new', nameRu: 'Новая', colorHex: '#0F6E56' },
|
||||
{ slug: 'in_progress', nameRu: 'В работе', colorHex: '#0066CC' },
|
||||
{ slug: 'won', nameRu: 'Куплено', colorHex: '#00A36C' },
|
||||
];
|
||||
|
||||
describe('DealDetailHero — inline status picker', () => {
|
||||
it('клик по статус-чипу открывает меню статусов', async () => {
|
||||
const w = mount(DealDetailHero, {
|
||||
props: {
|
||||
deal: { id: 1, name: 'A', phone: '+1', statusSlug: 'new', project: 'p',
|
||||
manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 1 },
|
||||
status: statuses[0],
|
||||
allStatuses: statuses,
|
||||
},
|
||||
global: { plugins: [vuetify], stubs: { teleport: true } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.find('[data-testid="status-chip-trigger"]').trigger('click');
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(document.body.textContent).toContain('В работе');
|
||||
expect(document.body.textContent).toContain('Куплено');
|
||||
w.unmount();
|
||||
});
|
||||
|
||||
it('выбор статуса эмитит change-status с новым slug', async () => {
|
||||
const w = mount(DealDetailHero, {
|
||||
props: {
|
||||
deal: { id: 1, name: 'A', phone: '+1', statusSlug: 'new', project: 'p',
|
||||
manager: { initials: 'A', name: 'A' }, cost: 0, receivedMinutesAgo: 1 },
|
||||
status: statuses[0],
|
||||
allStatuses: statuses,
|
||||
},
|
||||
global: { plugins: [vuetify], stubs: { teleport: true } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await w.find('[data-testid="status-chip-trigger"]').trigger('click');
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const items = [...document.body.querySelectorAll('[data-testid^="status-option-"]')];
|
||||
const won = items.find(el => el.textContent?.includes('Куплено')) as HTMLElement | undefined;
|
||||
won?.click();
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(w.emitted('change-status')?.[0]?.[0]).toBe('won');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealDetailHero.spec.ts -v`
|
||||
|
||||
- [ ] **Step 3: Расширить DealDetailHero.vue**
|
||||
|
||||
В `<script setup>` props:
|
||||
|
||||
```ts
|
||||
import type { LeadStatus } from '../../composables/leadStatuses';
|
||||
defineProps<{
|
||||
deal: MockDeal;
|
||||
status: LeadStatus | null;
|
||||
allStatuses: LeadStatus[];
|
||||
}>();
|
||||
defineEmits<{
|
||||
close: [];
|
||||
'change-status': [slug: string];
|
||||
}>();
|
||||
```
|
||||
|
||||
В `<template>` блок `<div v-if="status" class="status-row mt-3">` (строки 43-48) — заменить на:
|
||||
|
||||
```vue
|
||||
<div v-if="status" class="status-row mt-3">
|
||||
<v-menu>
|
||||
<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: 'pointer' }">
|
||||
<span class="status-dot" :style="{ background: status.colorHex }" />
|
||||
{{ status.nameRu }}
|
||||
<v-icon 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>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Расширить parent DealDetailBody.vue**
|
||||
|
||||
Передавать allStatuses и обрабатывать change-status. В `DealDetailBody.vue`:
|
||||
|
||||
```vue
|
||||
<DealDetailHero :deal="deal" :status="status" :all-statuses="leadStatusesStore.statuses"
|
||||
@close="emit('close')" @change-status="onStatusChange" />
|
||||
```
|
||||
|
||||
Добавить handler:
|
||||
|
||||
```ts
|
||||
async function onStatusChange(slug: string): Promise<void> {
|
||||
if (!props.deal || !props.tenantId) return;
|
||||
const prev = props.deal.statusSlug;
|
||||
props.deal.statusSlug = slug as MockDeal['statusSlug'];
|
||||
try {
|
||||
await dealsApi.updateDeal(props.deal.id, { tenant_id: props.tenantId, status: slug });
|
||||
} catch {
|
||||
props.deal.statusSlug = prev;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Vitest GREEN**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/DealDetailHero.spec.ts -v`
|
||||
|
||||
- [ ] **Step 6: Full Vitest**
|
||||
|
||||
`cd app && npx vitest run --reporter=default --maxWorkers=2 2>&1 | tail -10`
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/deals/DealDetailHero.vue \
|
||||
app/resources/js/components/deals/DealDetailBody.vue \
|
||||
app/tests/Frontend/DealDetailHero.spec.ts
|
||||
git commit -m "feat(deals/drawer): inline status picker в карточке сделки"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Подпись «Источник» в NewProjectDialog (п. 8)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/projects/NewProjectDialog.vue`
|
||||
- Test: `app/tests/Frontend/NewProjectDialog.spec.ts` (создать или расширить если есть)
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import NewProjectDialog from '../../resources/js/views/projects/NewProjectDialog.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
describe('NewProjectDialog — подпись «Источник» (18.05.2026)', () => {
|
||||
it('на табе Сайт перед полем «Домен» есть подпись «Источник»', async () => {
|
||||
const w = mount(NewProjectDialog, {
|
||||
props: { modelValue: true, mode: 'create' },
|
||||
global: { plugins: [vuetify], stubs: { teleport: true } },
|
||||
attachTo: document.body,
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
expect(document.body.textContent).toContain('Источник');
|
||||
w.unmount();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/NewProjectDialog.spec.ts -v`
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
В `app/resources/js/views/projects/NewProjectDialog.vue` — внутри `<v-tabs-window v-model="form.signal_type" class="mt-4">` (строка 19) — перед каждым `<v-tabs-window-item>` контентом добавить заголовок секции:
|
||||
|
||||
Для **site** (после `<v-tabs-window-item value="site">`):
|
||||
|
||||
```vue
|
||||
<div class="text-caption text-medium-emphasis mb-1">Источник — домен сайта-«донора», с которого приходят лиды</div>
|
||||
```
|
||||
|
||||
Для **call**:
|
||||
|
||||
```vue
|
||||
<div class="text-caption text-medium-emphasis mb-1">Источник — телефонный номер «донора», на который звонят клиенты</div>
|
||||
```
|
||||
|
||||
Для **sms**:
|
||||
|
||||
```vue
|
||||
<div class="text-caption text-medium-emphasis mb-1">Источник — отправитель SMS и (опционально) ключевое слово в тексте</div>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Vitest GREEN**
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/NewProjectDialog.spec.ts -v`
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/projects/NewProjectDialog.vue \
|
||||
app/tests/Frontend/NewProjectDialog.spec.ts
|
||||
git commit -m "feat(projects/new-dialog): подпись «Источник» над полями на 3 табах"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Редактирование источника в ProjectDetailsDrawer (п. 9)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Requests/UpdateProjectRequest.php` (+signal_identifier rules)
|
||||
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (update — пропускать signal_identifier)
|
||||
- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue` (UI поля)
|
||||
- Test: `app/tests/Feature/Projects/UpdateProjectTest.php` (signal_identifier обновляется)
|
||||
|
||||
**ВАЖНО (риски):**
|
||||
|
||||
- signal_type **не редактируется** — менять signal_type у активного проекта = смена природы; не предусмотрено.
|
||||
- При смене signal_identifier у существующего проекта **прошлые сделки** уже привязаны к проекту, source-label в их карточке изменится автоматически (это поведение и хотел заказчик: «изменится у всех сделок этого проекта»).
|
||||
- Соблюсти validation: site = regex домена, call = regex 7\d{10}, sms = signal_identifier = sms_senders[0] (или просто пропускать через sms_senders/sms_keyword, как сейчас в UpdateProjectRequest).
|
||||
|
||||
- [ ] **Step 1: Pest failing**
|
||||
|
||||
Создать или расширить `app/tests/Feature/Projects/UpdateProjectTest.php`:
|
||||
|
||||
```php
|
||||
it('updates signal_identifier for site project', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'old.ru',
|
||||
]);
|
||||
actingAsTenant($tenant);
|
||||
|
||||
$resp = $this->patchJson("/api/projects/{$project->id}", [
|
||||
'signal_identifier' => 'new-source.ru',
|
||||
]);
|
||||
|
||||
$resp->assertOk();
|
||||
expect($project->fresh()->signal_identifier)->toBe('new-source.ru');
|
||||
});
|
||||
|
||||
it('updates signal_identifier for call project (phone regex)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'call',
|
||||
'signal_identifier' => '79991111111',
|
||||
]);
|
||||
actingAsTenant($tenant);
|
||||
|
||||
$resp = $this->patchJson("/api/projects/{$project->id}", [
|
||||
'signal_identifier' => '79992222222',
|
||||
]);
|
||||
$resp->assertOk();
|
||||
expect($project->fresh()->signal_identifier)->toBe('79992222222');
|
||||
});
|
||||
|
||||
it('rejects invalid signal_identifier (site regex)', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'ok.ru',
|
||||
]);
|
||||
actingAsTenant($tenant);
|
||||
|
||||
$this->patchJson("/api/projects/{$project->id}", ['signal_identifier' => 'not-a-domain'])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['signal_identifier']);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run — FAIL**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --filter "updates signal_identifier\|rejects invalid"
|
||||
```
|
||||
|
||||
Expected: все FAIL (правило не разрешает signal_identifier).
|
||||
|
||||
- [ ] **Step 3: Расширить UpdateProjectRequest**
|
||||
|
||||
В `app/app/Http/Requests/UpdateProjectRequest.php` в массиве правил (около строки 27) — добавить condition-based валидацию:
|
||||
|
||||
```php
|
||||
$rules = [
|
||||
// ... existing rules name/daily_limit/regions/delivery_days_mask/sms_*
|
||||
];
|
||||
|
||||
// Условно на тип проекта — signal_identifier валидируется по signal_type
|
||||
$project = $this->route('id') ? \App\Models\Project::find($this->route('id')) : null;
|
||||
if ($project) {
|
||||
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[0] в ProjectController (или через UpdateProjectAction)
|
||||
}
|
||||
|
||||
return $rules;
|
||||
```
|
||||
|
||||
(Адаптировать под существующую структуру файла — прочитать его сначала.)
|
||||
|
||||
- [ ] **Step 4: Расширить ProjectController::update**
|
||||
|
||||
В `app/app/Http/Controllers/Api/ProjectController.php` метод `update()` (около строки 96) — пропустить signal_identifier через assign:
|
||||
|
||||
```php
|
||||
$validated = $request->validated();
|
||||
$project = Project::where('tenant_id', $tenantId)->findOrFail($id);
|
||||
|
||||
// signal_type не меняем — explicitly NOT в $fillable update
|
||||
$updates = collect($validated)->only([
|
||||
'name', 'daily_limit_target', 'regions', 'delivery_days_mask',
|
||||
'sms_senders', 'sms_keyword', 'signal_identifier',
|
||||
])->toArray();
|
||||
|
||||
$project->update($updates);
|
||||
return response()->json(['project' => $project->fresh()]);
|
||||
```
|
||||
|
||||
(Адаптировать под существующую логику.)
|
||||
|
||||
- [ ] **Step 5: Pest GREEN**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --filter "updates signal_identifier\|rejects invalid"
|
||||
```
|
||||
|
||||
Expected: 3 pass.
|
||||
|
||||
- [ ] **Step 6: Расширить ProjectDetailsDrawer.vue**
|
||||
|
||||
В `app/resources/js/components/projects/ProjectDetailsDrawer.vue` интерфейс `FormState` (около строки 12) — добавить:
|
||||
|
||||
```ts
|
||||
signal_identifier: string;
|
||||
```
|
||||
|
||||
И в `reseedFromProject` (около строки 32) — добавить:
|
||||
|
||||
```ts
|
||||
form.signal_identifier = p.signal_identifier ?? '';
|
||||
```
|
||||
|
||||
В `<template>` после `<label class="pdd-field">` блока «Название» (строки 124-128) — добавить блок «Источник», условно по signal_type:
|
||||
|
||||
```vue
|
||||
<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">{{ 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">{{ errors.signal_identifier[0] }}</div>
|
||||
</label>
|
||||
<!-- sms: signal_identifier подтягивается из sms_senders, отдельное поле не нужно -->
|
||||
<label v-else-if="project?.signal_type === 'sms'" class="pdd-field">
|
||||
<span class="pdd-label">Отправители SMS (до 11 символов каждый)</span>
|
||||
<v-combobox v-model="form.sms_senders" multiple chips clearable
|
||||
data-testid="pdd-sms-senders" hide-details />
|
||||
</label>
|
||||
<label v-if="project?.signal_type === 'sms'" class="pdd-field">
|
||||
<span class="pdd-label">Ключевое слово (опционально)</span>
|
||||
<input v-model="form.sms_keyword" data-testid="pdd-sms-keyword" class="pdd-input" />
|
||||
</label>
|
||||
```
|
||||
|
||||
В `onSave()` — добавить signal_identifier в payload:
|
||||
|
||||
```ts
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Vitest для ProjectDetailsDrawer**
|
||||
|
||||
Создать `app/tests/Frontend/ProjectDetailsDrawer.spec.ts` (если нет) с тестом:
|
||||
|
||||
```ts
|
||||
it('показывает поле редактирования signal_identifier для site-проекта', () => {
|
||||
const w = mount(ProjectDetailsDrawer, {
|
||||
props: { project: { id: 1, tenant_id: 1, name: 'P', signal_type: 'site',
|
||||
signal_identifier: 'old.ru', daily_limit_target: 50,
|
||||
regions: [], delivery_days_mask: 127, is_active: true } as never },
|
||||
global: { plugins: [vuetify, createPinia()] },
|
||||
});
|
||||
const input = w.find('[data-testid="pdd-signal-identifier"]');
|
||||
expect(input.exists()).toBe(true);
|
||||
expect((input.element as HTMLInputElement).value).toBe('old.ru');
|
||||
});
|
||||
```
|
||||
|
||||
`cd app && npx vitest run tests/Frontend/ProjectDetailsDrawer.spec.ts -v`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Full Pest + Vitest + Build**
|
||||
|
||||
```
|
||||
cd app && ./vendor/bin/pest --parallel 2>&1 | tail -10
|
||||
cd app && npx vitest run --reporter=default --maxWorkers=2 2>&1 | tail -10
|
||||
cd app && npm run build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: всё GREEN.
|
||||
|
||||
- [ ] **Step 9: Manual smoke на /projects**
|
||||
|
||||
Открыть `http://127.0.0.1:8000/projects` → клик на любой site-проект → drawer справа → поле «Источник» видно с текущим доменом → меняем → «Сохранить» → перезагрузка карточки → новое значение применилось → перейти на `/deals` → drawer сделки этого проекта → «Источник» = новое значение.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add app/app/Http/Requests/UpdateProjectRequest.php \
|
||||
app/app/Http/Controllers/Api/ProjectController.php \
|
||||
app/resources/js/components/projects/ProjectDetailsDrawer.vue \
|
||||
app/tests/Frontend/ProjectDetailsDrawer.spec.ts \
|
||||
app/tests/Feature/Projects/UpdateProjectTest.php
|
||||
git commit -m "feat(projects/drawer): редактирование источника (site/call/sms) в карточке проекта"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Финал: атомарный push
|
||||
|
||||
После Task 5 — push всех 5 коммитов одним:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git log HEAD..origin/main --oneline # должно быть пусто
|
||||
git push origin feat/parallel-sessions-coordination:main
|
||||
```
|
||||
|
||||
Эталон обновить: §1 HEAD, §6 «Текущие нити» добавить запись «18.05 — деали-drawer + edit-источник проекта».
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
- п.1+2 ✅ Task 1
|
||||
- п.3 ✅ Task 3
|
||||
- п.4 ✅ Task 2 (Step 8 удаляет блок «Менеджер»)
|
||||
- п.5 ✅ уже сделано в `36ea9cd`
|
||||
- п.6 ✅ Task 2 (computed projectSourceLabel)
|
||||
- п.7 ✅ Task 2 (computed projectTypeLabel)
|
||||
- п.8 ✅ Task 4
|
||||
- п.9 ✅ Task 5
|
||||
|
||||
**Placeholder scan:** в плане НЕТ «TBD»/«TODO», все code-блоки полные.
|
||||
|
||||
**Type consistency:** `projectSignalType`/`projectSignalIdentifier`/`projectSmsKeyword`/`projectSmsSenders` consistent через Task 2 (api/mockDeals/mapper/UI). `allStatuses` consistent в Task 3 (Hero ← Body).
|
||||
|
||||
**Риски:**
|
||||
|
||||
- Task 5 затрагивает критическое поле `signal_identifier`; нет ограничения «нельзя менять у проекта с уже накопленными сделками». При желании заказчика добавить — отдельная задача (warn-dialog типа Task 5.5). Не делаем в MVP — заказчик прямо сказал «изменится у всех сделок этого проекта» (он понимает scope).
|
||||
- Pest тесты предполагают наличие `actingAsTenant()` helper'а в `app/tests/Pest.php`. Если такого нет — использовать существующий паттерн авторизации в тестах.
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user