Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0ce510155 | |||
| 76d13d699a | |||
| be9571353a | |||
| 147200ff8e | |||
| 492a4fc969 | |||
| 5742c92449 | |||
| e846de6012 | |||
| a007295abe | |||
| 5d3e29669b | |||
| ef4cc825bf | |||
| f54c82d682 | |||
| 884169e847 | |||
| f8b32a7d3a | |||
| ffaeb8f37b | |||
| c0e3e901d0 | |||
| 0663479bb8 | |||
| 52728dfc12 | |||
| dbe2252421 | |||
| 8e5eaecf6a | |||
| 47c03a9e18 | |||
| 752ff8b9a9 | |||
| c7197a263c | |||
| 9729909c31 | |||
| 2bab9a61b9 | |||
| 082968ea1c | |||
| 2d7201f063 | |||
| 96f4a6601d | |||
| 48b0e35cd1 | |||
| c89895e039 | |||
| 3cf8fbdfb9 | |||
| d6364dcde1 | |||
| d631646167 | |||
| 2706166f55 | |||
| b584ce43dd | |||
| 6b7f0035ef | |||
| 3e16c1e656 | |||
| e6d6babb38 | |||
| 2476dd3c1b | |||
| 3ec638cbd2 | |||
| c5ec9a0875 | |||
| 3b7e549e02 | |||
| 7fe9f89574 | |||
| c5def50e31 | |||
| c386361881 | |||
| 94f831f7d1 | |||
| 1ba8b6e590 | |||
| 030bdc65ab | |||
| 148262a78e | |||
| 787c38ad82 | |||
| 79d3f2ef3d | |||
| 82c0aeef41 | |||
| 5f17ca51ac | |||
| fdd8247527 | |||
| d1ddd28250 | |||
| 34458df474 | |||
| 467f1cdbf2 | |||
| cd2353b57d | |||
| 17e34a6d5e | |||
| 063436670a | |||
| 2f9f0a0900 | |||
| c44394ea0c | |||
| 3177072e1d | |||
| 71022ad3f1 | |||
| 6d9c1d2464 | |||
| de11da2b06 | |||
| d984165af1 | |||
| 7df4786499 | |||
| 162fe010fe | |||
| 426983ffaa | |||
| 87c5eb6323 | |||
| cb864b18a5 | |||
| 4b4c8d94b9 | |||
| dd0a9ffea6 | |||
| 353b1599b6 | |||
| 97388cf840 | |||
| 8f5a399a25 | |||
| efd3e73aa2 | |||
| 0f1b604554 | |||
| 48d7303963 | |||
| b9e72e6231 | |||
| 80c5f6289a | |||
| 895975482d | |||
| e81cd8ed2c | |||
| bff5faf02b | |||
| 8df5a3fe00 | |||
| 83295a25f3 | |||
| 0fad4305d4 | |||
| 2f60910b09 | |||
| f48d5115ce | |||
| 774763c21c | |||
| c1b690edd3 | |||
| e34b11aca5 | |||
| b4f4f441b5 | |||
| 475e233c2a | |||
| 3e289479f0 | |||
| 0cee520f0d | |||
| c3392bef13 | |||
| 7fed5bc18b | |||
| 43028228c8 | |||
| f1092772fb | |||
| 702c2ff7b5 | |||
| b75f9e3d21 | |||
| 2e26edbb3a | |||
| 643e1a5dcf |
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: billing-audit
|
||||
description: Аудит денежной корректности биллинг-кода Лидерры — money-инварианты при правке/ревью списаний, тарифов и баланса. Используй при «проверь списание», «аудит биллинга», «не теряются ли копейки», «идемпотентно ли списание», «корректна ли тарифная ступень», «что значит дрейф CsvReconcile», «провенанс charge_source». НЕ для моделирования процесса (process-modeling), поиска узких мест (process-analysis), security-аудита (D3), РСБУ/налогов (ru-tax-accounting), метрик выручки (product-management).
|
||||
---
|
||||
|
||||
# Billing Audit — аудит денежной корректности биллинга Лидерры
|
||||
|
||||
Проектный скил раздела C6 карты «Финансы — биллинг и тарификация». Проверяет
|
||||
**денежные инварианты** биллинг-подсистемы при правке или ревью кода. Объект —
|
||||
корректность *начисления* (не процесс, не безопасность, не учёт/налоги).
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- Правка/ревью кода в `app/app/Services/Billing/**`, `app/app/Jobs/Supplier/CsvReconcileJob.php`,
|
||||
моделей `PricingTier`/`LeadCharge`, контроллеров биллинга.
|
||||
- Вопрос «безопасно ли это денежно?» по списанию, тарифу, балансу, сверке.
|
||||
|
||||
## Процедура аудита (5 инвариантов)
|
||||
|
||||
Полный чек-лист с проверками и ссылками на файлы — `references/invariants.md`.
|
||||
|
||||
1. **Сохранение суммы** — все денежные операции через `bcmath` (bcadd/bcsub/bcmul/bcdiv,
|
||||
scale фиксирован), никаких float; prepaid→₽ конвертация без потери копеек.
|
||||
2. **Идемпотентность списания** — один лид = одно списание; повтор/ретрай джоба
|
||||
не дублирует начисление (проверить уникальный ключ / advisory-lock / upsert).
|
||||
3. **Корректность тарифной ступени** — `PricingTierResolver` выбирает верную из 7
|
||||
ступеней по объёму; границы ступеней (включительно/исключительно) однозначны.
|
||||
4. **Дрейф сверки** — `CsvReconcileJob` порог >5%: что сравнивается, что значит дрейф,
|
||||
куда смотреть (рассинхрон поставки vs ошибка тарифа).
|
||||
5. **Провенанс charge_source** — каждое списание имеет прослеживаемый источник
|
||||
(`charge_source`); ручные/авто/CSV-восстановленные различимы.
|
||||
|
||||
## Границы
|
||||
|
||||
- ≠ `process-modeling` #52 / `process-analysis` #53 — те про *поток/процесс*; billing-audit про *деньги в коде*.
|
||||
- ≠ D3 audit-security (#39/#40) — те про *безопасность*; billing-audit про *денежную корректность*.
|
||||
- ≠ `ru-tax-accounting` #63 — тот про *учёт/налоги* (выход биллинга → налоговая база); billing-audit про *начисление*.
|
||||
- ≠ `product-management:metrics-review` #42 — тот про *метрики выручки*; billing-audit про *корректность*.
|
||||
|
||||
## Связано
|
||||
|
||||
- Reuse: Boost #10 (модели), Pest #18 (тесты инвариантов), Larastan #12 (bcmath/без float), Sentry #34 / Redis #35 (runtime/очередь).
|
||||
- ADR-012 (граница finance-tooling C6/C7).
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"skill": "billing-audit",
|
||||
"positive": [
|
||||
"проверь корректность списания за лид",
|
||||
"аудит денежной логики биллинга",
|
||||
"не теряются ли копейки в prepaid→рублёвом балансе",
|
||||
"идемпотентно ли списание при ретрае",
|
||||
"правильно ли резолвится тарифная ступень",
|
||||
"что значит дрейф >5% в CsvReconcile",
|
||||
"проверь провенанс charge_source",
|
||||
"ревью PricingTierResolver на ошибки округления",
|
||||
"ledger двойной баланс — где может утечь сумма",
|
||||
"audit charge invariants before merge"
|
||||
],
|
||||
"near_miss": [
|
||||
{"prompt": "смоделируй BPMN процесса списания", "expect": "process-modeling #52"},
|
||||
{"prompt": "где узкое место в воронке оплат", "expect": "process-analysis #53"},
|
||||
{"prompt": "security-аудит платёжного эндпоинта", "expect": "D3 audit-security / Semgrep"},
|
||||
{"prompt": "посчитай РСБУ-проводки по выручке", "expect": "ru-tax-accounting #63"},
|
||||
{"prompt": "метрика MRR за месяц", "expect": "product-management metrics-review #42"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Денежные инварианты биллинга Лидерры — чек-лист аудита
|
||||
|
||||
Объект-файлы (на момент 20.05.2026):
|
||||
|
||||
- `app/app/Services/Billing/PricingTierResolver.php` — резолюция 7 ступеней (pure).
|
||||
- `app/app/Services/Billing/LedgerService.php` — двойной баланс prepaid→₽ (bcmath).
|
||||
- `app/app/Services/Billing/BillingTopupService.php` — пополнение.
|
||||
- `app/app/Services/Billing/ChargeResult.php` — DTO результата списания.
|
||||
- `app/app/Models/PricingTier.php`, `app/app/Models/LeadCharge.php`.
|
||||
- `app/app/Repositories/PricingTierRepository.php`.
|
||||
- `app/app/Jobs/Supplier/CsvReconcileJob.php` — hourly сверка, алерт дрейфа >5%.
|
||||
- `app/app/Http/Controllers/Api/{AdminPricingTiersController,AdminBillingController,BillingController,TenantChargesController}.php`.
|
||||
|
||||
## I1. Сохранение суммы (bcmath, без float)
|
||||
|
||||
- [ ] Все арифметические операции с деньгами — `bcadd`/`bcsub`/`bcmul`/`bcdiv`/`bccomp` с явным `scale`.
|
||||
- [ ] Нет `+`/`-`/`*`/`/` над денежными значениями (Larastan/grep на float-арифметику в Billing).
|
||||
- [ ] prepaid→₽: конвертация округляет детерминированно (TRUNC/округление вниз в пользу tenant — свериться с кодом), сумма prepaid + ₽ не «исчезает».
|
||||
- [ ] Денежные колонки — целочисленные копейки или DECIMAL, не float/double.
|
||||
|
||||
## I2. Идемпотентность списания
|
||||
|
||||
- [ ] Один лид → одно списание: уникальность по (lead_id) или advisory-lock в `LedgerService`.
|
||||
- [ ] Ретрай `ImportLeadsJob`/`CsvReconcileJob` не создаёт дубль `lead_charges`.
|
||||
- [ ] Транзакция + `lockForUpdate` на балансе при мутации (TOCTOU — см. Sprint 3 lockForUpdate).
|
||||
|
||||
## I3. Корректность тарифной ступени
|
||||
|
||||
- [ ] `PricingTierResolver` выбирает ступень по объёму `delivered_in_month` верно на границах.
|
||||
- [ ] Границы ступеней непрерывны (нет дыр/перекрытий между 7 ступенями).
|
||||
- [ ] Pest покрывает граничные значения (ступень N → N+1).
|
||||
|
||||
## I4. Дрейф сверки CsvReconcile
|
||||
|
||||
- [ ] Порог >5% — что сравнивается (поставка поставщика vs начислено) → `supplier_csv_reconcile_log`.
|
||||
- [ ] Дрейф = рассинхрон поставки (норм) ИЛИ ошибка тарифа (баг) — различить по `charge_source`.
|
||||
|
||||
## I5. Провенанс charge_source
|
||||
|
||||
- [ ] Каждое `lead_charges.charge_source` заполнено и прослеживаемо.
|
||||
- [ ] Авто/ручное/CSV-восстановленное (`recovered_from_csv_at`) различимы.
|
||||
|
||||
## Reuse-инструменты
|
||||
|
||||
Boost #10 (Eloquent-introspection), Pest #18 + pest-parallel-debugger (тесты + race),
|
||||
Larastan #12 (статанализ bcmath), Sentry MCP #34 (runtime списаний), Redis MCP #35 (очередь сверки), context7 #60 (доки bcmath).
|
||||
@@ -24,11 +24,12 @@ Aggregator over observer evidence. Reads JSONL + optional MD notes, surfaces can
|
||||
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.)
|
||||
4. **Update read-counter**: run `node tools/observer-of-observer.mjs record`. This atomically bumps `docs/observer/.read-counter.json` `last_read_at` to now and increments `read_count_last_period`. (Side-effect — used by C3 observer-of-observer for 54-week self-prune detection.)
|
||||
5. **Run the deterministic analyzer**: `node tools/brain-retro-analyzer.mjs docs/observer/episodes-YYYY-MM.jsonl` (pass every monthly file in the period). It returns JSON with `episodeCount`, `observerErrorCount`, `tasks` (episodes grouped into tasks), `causalChains` (error→fix candidates) and `factorMatrix` (outcome distribution per factor). The analyzer deduplicates the routing-gate double-write and infers the true `outcome` of each episode from the next episode's `prompt_signal` — never trust the stored `outcome` (it is `unknown` at write time).
|
||||
6. **Aggregate** per `references/aggregation-template.md` — fill the Factor analysis matrix from the analyzer's `factorMatrix`, the task groups from `tasks`, the causal-chain candidates from `causalChains`.
|
||||
7. **Propose candidates** — clearly separated section «Candidates for owner review». Each candidate has rationale + suggested edit + rejection-option.
|
||||
8. **Save retro note**: `docs/observer/notes/YYYY-MM-DD-brain-retro.md` with full aggregation.
|
||||
8a. **Refresh STATUS.md**: `node tools/status-md-generator.mjs` — auto-rebuild dashboard so it reflects the just-finished retro (`Last /brain-retro: 0 day(s) ago`, current episode count, refreshed C1–C5 controller statuses). Without this, STATUS.md only updates on the next git commit.
|
||||
9. **Report to user**: high-signal summary.
|
||||
|
||||
## Output anatomy
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: ru-tax-accounting
|
||||
description: Контекст РСБУ и налогов РФ (НК РФ, НДС/УСН) применительно к SaaS-выручке Лидерры за лиды. Используй при «учёт выручки по РСБУ», «НДС или УСН», «налоговая база по выручке», «налогооблагаемое событие», «выгрузка для бухгалтера», «проводки РСБУ». НЕ для денежной корректности кода (billing-audit), US-GAAP-отчётности (finance plugin), договоров (D1 право), ПДн (D2), сверки с банком (finance reconciliation).
|
||||
---
|
||||
|
||||
# RU Tax & Accounting — РСБУ/НК РФ контекст для выручки Лидерры
|
||||
|
||||
Проектный скил раздела C7 карты «Финансы — бухгалтерия и налоги». Переводит
|
||||
billing-выручку (выход C6) в **российский учётно-налоговый контекст** (РСБУ + НК РФ).
|
||||
Закрывает gap, который US-GAAP-плагин `finance` (#61) не покрывает.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- Вопрос «как это учесть/обложить по РФ-правилам?» по выручке/пополнениям/возвратам.
|
||||
- Подготовка выгрузок/пояснений для бухгалтера из billing-данных.
|
||||
- Определение налогооблагаемого события и налоговой базы.
|
||||
|
||||
## Содержание (см. references/ru-tax-context.md)
|
||||
|
||||
1. **Налоговые режимы РФ** — НДС (ОСНО) vs УСН (доходы / доходы-расходы); что применимо к SaaS за лиды.
|
||||
2. **Налогооблагаемое событие** — пополнение баланса (аванс) vs списание за лид (реализация) vs возврат.
|
||||
3. **Маппинг billing→база** — `lead_charges`/`LedgerService` → выручка → налоговая база; роль `charge_source`.
|
||||
4. **РСБУ vs управленческий** — отличие от US-GAAP-отчётов плагина finance; первичка/документы.
|
||||
5. **Выгрузки для бухгалтера** — какие данные и в каком разрезе извлечь (Boost/Pest как инструменты выгрузки).
|
||||
|
||||
## Границы
|
||||
|
||||
- ≠ `billing-audit` #62 — тот про *корректность начисления в коде*; ru-tax про *учёт/налог результата*.
|
||||
- ≠ `finance` plugin #61 — тот US-GAAP-механика (проводки/отчёты/сверка); ru-tax — РФ-специфика РСБУ/НК.
|
||||
- ≠ D1 «Юриспруденция/договорная» — там договоры/право; ru-tax — налоги.
|
||||
- ≠ D2 «Защита ПДн (152-ФЗ)» — там персональные данные; ru-tax — налоги.
|
||||
|
||||
## Ограничение
|
||||
|
||||
Бухгалтерия компании ведётся вне dev-репо (1С/аутсорс). Скил даёт **контекст и выгрузки**,
|
||||
не заменяет бухгалтера и не является налоговой консультацией. Реальный платёжный
|
||||
провайдер — DEFERRED (Б-1).
|
||||
|
||||
## Связано
|
||||
|
||||
- Вход: выручка из C6 (`lead_charges`, `LedgerService`).
|
||||
- Reuse: data-scientist #49 (финмодели), Boost #10 / Pest #18 (выгрузка), finance plugin #61 (US-механика).
|
||||
- ADR-012 (граница finance-tooling C6/C7).
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"skill": "ru-tax-accounting",
|
||||
"positive": [
|
||||
"как учесть выручку за лиды по РСБУ",
|
||||
"НДС или УСН для SaaS-выручки",
|
||||
"переведи billing-выручку в налоговую базу",
|
||||
"какое налогооблагаемое событие при пополнении баланса",
|
||||
"выгрузка lead_charges для бухгалтера",
|
||||
"проводки по РСБУ за списания",
|
||||
"налоговый режим для подписочной выручки портала",
|
||||
"что с НДС при возврате на баланс tenant",
|
||||
"налоговая база УСН доходы по выручке за лиды"
|
||||
],
|
||||
"near_miss": [
|
||||
{"prompt": "проверь идемпотентность списания", "expect": "billing-audit #62"},
|
||||
{"prompt": "US-GAAP financial statement", "expect": "finance plugin #61 financial-statements"},
|
||||
{"prompt": "договор с поставщиком лидов", "expect": "D1 юриспруденция"},
|
||||
{"prompt": "обработка ПДн при выгрузке", "expect": "D2 ПДн 152-ФЗ"},
|
||||
{"prompt": "сверка ledger с банком", "expect": "finance plugin #61 reconciliation"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# РСБУ / НК РФ — контекст для выручки Лидерры за лиды
|
||||
|
||||
> Не налоговая консультация. Контекст для подготовки данных бухгалтеру.
|
||||
|
||||
## 1. Налоговые режимы РФ
|
||||
|
||||
- **ОСНО + НДС (НК РФ гл. 21)** — НДС 20% на реализацию услуг РФ. Электронные/рекламные
|
||||
услуги — проверить место реализации и применимые льготы.
|
||||
- **УСН (НК РФ гл. 26.2)** — «доходы» (6%) или «доходы минус расходы» (15%). Без НДС
|
||||
(кроме исключений). Типичный режим для раннего SaaS.
|
||||
- Применимый режим зависит от регистрации ООО (Б-1) — до закрытия Б-1 фиксируем как параметр.
|
||||
|
||||
## 2. Налогооблагаемое событие
|
||||
|
||||
- **Пополнение баланса** = аванс (предоплата). По НДС — момент определения базы может
|
||||
возникать на аванс; по УСН-доходы — доход по поступлению (кассовый метод).
|
||||
- **Списание за лид** = реализация услуги (закрытие аванса).
|
||||
- **Возврат на баланс / с баланса** = корректировка базы.
|
||||
- Различать по `lead_charges.charge_source` и операциям `LedgerService`.
|
||||
|
||||
## 3. Маппинг billing → налоговая база
|
||||
|
||||
| Billing-сущность | Учётный смысл |
|
||||
|---|---|
|
||||
| Пополнение (`BillingTopupService`) | Аванс / поступление |
|
||||
| Списание (`LedgerService`, `lead_charges`) | Реализация (выручка) |
|
||||
| `delivered_in_month` (`tenants`) | Объём для tier — не налог напрямую |
|
||||
| Возврат | Корректировка |
|
||||
|
||||
## 4. РСБУ vs управленческий / US-GAAP
|
||||
|
||||
- РСБУ — российский план счетов, первичные документы (акт/УПД), кассовый/начисление.
|
||||
- US-GAAP-скилы плагина `finance` (#61) — иная форма (income statement / balance sheet);
|
||||
применимы для *внутренней управленки*, не для РФ-отчётности.
|
||||
|
||||
## 5. Выгрузки для бухгалтера
|
||||
|
||||
- Реестр списаний за период: `lead_charges` (period, tenant, сумма, charge_source).
|
||||
- Реестр пополнений: операции `LedgerService` / `BillingTopupService`.
|
||||
- Инструменты выгрузки: Boost #10 (Eloquent/SQL), Pest #18 (фикстуры/проверки), `BillingSummaryProvider` (готовый отчёт-провайдер).
|
||||
+4
-1
@@ -98,7 +98,10 @@ paths = [
|
||||
# Vitest-тесты с assertion на mock-данные (mock-телефоны из mockDeals)
|
||||
'''app/tests/Frontend/.*\.(spec|test)\.ts''',
|
||||
# Settings-вкладки с фиктивными mock-данными (профиль/сессии — UI-разводка)
|
||||
'''app/resources/js/views/settings/.*\.vue'''
|
||||
'''app/resources/js/views/settings/.*\.vue''',
|
||||
# Test fixtures for the observer PII filter — contains synthetic JWT / AWS /
|
||||
# Yandex tokens that the filter is supposed to redact. Not real secrets.
|
||||
'''tools/observer-pii-filter\.test\.mjs'''
|
||||
]
|
||||
regexTarget = "match"
|
||||
regexes = [
|
||||
|
||||
@@ -12,8 +12,10 @@ use App\Models\SupplierLead;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -86,6 +88,8 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
LeadDistributor $distributor,
|
||||
RegionTagResolver $tagResolver,
|
||||
): void {
|
||||
$lead = SupplierLead::findOrFail($this->supplierLeadId);
|
||||
|
||||
@@ -108,20 +112,19 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
$supplier = $resolver->resolveOrStub($platform, $signalType, $identifier);
|
||||
$lead->update(['supplier_project_id' => $supplier->id]);
|
||||
|
||||
$matched = $router->matchEligibleProjects($supplier, (string) $lead->phone);
|
||||
$matched = $router->matchEligibleProjects($supplier);
|
||||
$selected = $distributor->selectRecipients($matched); // cap=3 случайных
|
||||
|
||||
$subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
|
||||
|
||||
$createdCount = 0;
|
||||
$failures = [];
|
||||
foreach ($matched as $project) {
|
||||
foreach ($selected as $project) {
|
||||
try {
|
||||
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger)) {
|
||||
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)) {
|
||||
$createdCount++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Per-Project failure isolation (Plan 2 code-review Important).
|
||||
// Sharing-model: один сбой проекта не должен абортить routing других tenant'ов.
|
||||
// Логируем и продолжаем; final failed() callback зафиксирует общий проблемный лид
|
||||
// только если ВСЕ Projects упали (через handle() rethrow ниже).
|
||||
$failures[] = ['project_id' => $project->id, 'tenant_id' => $project->tenant_id, 'error' => $e->getMessage()];
|
||||
Log::warning('supplier_lead.per_project_routing_failed', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
@@ -132,9 +135,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
// Если ВСЕ Projects упали (а matched был непустой) — пробрасываем последнюю ошибку,
|
||||
// чтобы failed() callback сработал и проблема ушла в failed_webhook_jobs.
|
||||
if ($matched->isNotEmpty() && $createdCount === 0 && count($failures) === $matched->count()) {
|
||||
if ($selected->isNotEmpty() && $createdCount === 0 && count($failures) === $selected->count()) {
|
||||
throw new RuntimeException(
|
||||
'All eligible projects failed routing for supplier_lead='.$lead->id.
|
||||
'; last error: '.($failures[array_key_last($failures)]['error'] ?? 'unknown')
|
||||
@@ -199,9 +200,10 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
?int $subjectCode,
|
||||
): bool {
|
||||
try {
|
||||
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger): bool {
|
||||
return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode): bool {
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'");
|
||||
|
||||
/** @var Tenant $tenant */
|
||||
@@ -252,6 +254,7 @@ class RouteSupplierLeadJob implements ShouldQueue
|
||||
'phones' => $phones,
|
||||
'status' => 'new',
|
||||
'received_at' => $receivedAt,
|
||||
'subject_code' => $subjectCode,
|
||||
]);
|
||||
|
||||
$master = $duplicateDetector->findMaster(
|
||||
|
||||
@@ -14,47 +14,48 @@ 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\SupplierProjectGrouping;
|
||||
use App\Services\Supplier\SupplierQuotaAllocator;
|
||||
use App\Support\RussianRegions;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use stdClass;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Daily 20:30 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru.
|
||||
* Daily 18:00 МСК cron-job: синхронизирует supplier_projects с поставщиком crm.bp-gr.ru
|
||||
* (расписание перенесено 20:30 → 18:00, см. routes/console.php).
|
||||
*
|
||||
* Алгоритм (per spec §4.3):
|
||||
* 1. Итерация по всем активным (inactive_since IS NULL) supplier_projects.
|
||||
* 2. Для каждого:
|
||||
* a. Подтянуть активные Лидерра-projects через FK supplier_b{1,2,3}_project_id.
|
||||
* b. Адаптировать в plain stdClass с полями daily_limit/workdays/regions.
|
||||
* c. Вызвать SupplierQuotaAllocator::allocate() — pure distribution.
|
||||
* d. Сравнить с current state через SupplierProjectDto::equals(); skip if no diff.
|
||||
* e. saveProject() при supplier_external_id=null, иначе updateProject().
|
||||
* f. Записать audit row в supplier_sync_log.
|
||||
* 3. Failure-handling:
|
||||
* - SupplierAuthException → SupplierCriticalAlertMail('sticky_auth') + Sentry + throw.
|
||||
* - SupplierTransientException → log + continue. После 50 подряд → mass_transient alert + break.
|
||||
* - SupplierClientException → log + continue.
|
||||
* 4. Time budget cutoff: после 20:55 МСК прервать loop (буфер 5 мин до 21:00).
|
||||
* Алгоритм (Plan 3 Task 5 — per-subject grouping):
|
||||
* 1. Загрузить активные Лидерра-projects (is_active=true, archived_at IS NULL).
|
||||
* 2. Развернуть каждый в группы (signal_type, identifier, subject_code):
|
||||
* - subjects = project.regions (1..89); пусто → одна группа subject_code=null («Вся РФ»).
|
||||
* - identifier = buildUniqueKey() (site/call → signal_identifier; sms B2 → sender+keyword; B3 → sender).
|
||||
* - platforms = resolvePlatforms() (site/call → B1+B2+B3; sms+keyword → B2+B3; sms → B3).
|
||||
* 3. Для каждой группы:
|
||||
* - eligible-today проекты группы (workday-маска на завтра).
|
||||
* - order = computeOrder($eligibleLimits); workdays = union; tag / regions из subject.
|
||||
* - Найти существующие supplier_projects (unique_key, subject_code):
|
||||
* - Нет → saveProjectMultiFlag → 3 id → upsert supplier_projects.
|
||||
* - Есть → updateProject каждого (R6: один лимит).
|
||||
* - Pivot: для каждого Лидерра-проекта × каждого supplier_project → INSERT ... ON CONFLICT DO NOTHING.
|
||||
* 4. Failure-handling (Auth/Transient/Client/Window/TierEscalated), time-budget cutoff — сохранены.
|
||||
*
|
||||
* NOTE про connection: Job's $connection — это queue connection, не DB. Используем
|
||||
* Eloquent::on('pgsql_supplier') для cross-tenant видимости (Plan 3 Task 3 learning).
|
||||
* NOTE про connection: Eloquent::on('pgsql_supplier') для cross-tenant видимости.
|
||||
*
|
||||
* Spec:
|
||||
* - docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3-§4.4
|
||||
* - docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §4
|
||||
* - docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.3
|
||||
* - docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 5
|
||||
*/
|
||||
class SyncSupplierProjectsJob implements ShouldQueue
|
||||
{
|
||||
@@ -68,33 +69,71 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
|
||||
private SupplierProjectChannel $channel;
|
||||
|
||||
private SupplierPortalClient $client;
|
||||
|
||||
public function handle(?SupplierProjectChannel $channel = null): void
|
||||
{
|
||||
$this->channel = $channel ?? app(SupplierProjectChannel::class);
|
||||
$this->client = app(SupplierPortalClient::class);
|
||||
$consecutiveTransient = 0;
|
||||
|
||||
$projects = SupplierProject::on(self::DB_CONNECTION)
|
||||
->whereNull('inactive_since')
|
||||
// 1. Load active Лидерра-projects via pgsql_supplier
|
||||
/** @var Collection<int, Project> $projects */
|
||||
$projects = Project::on(self::DB_CONNECTION)
|
||||
->where('is_active', true)
|
||||
->whereNull('archived_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
foreach ($projects as $sp) {
|
||||
// 2. Expand into groups (signal_type, identifier, subject_code)
|
||||
// group key => [ 'signal_type', 'identifier', 'subject_code', 'platforms', 'projects' => [...] ]
|
||||
/** @var array<string, array{signal_type: string, identifier: string, subject_code: int|null, platforms: list<string>, projects: list<Project>}> $groups */
|
||||
$groups = [];
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
|
||||
if ($platforms === []) {
|
||||
continue;
|
||||
}
|
||||
// For sms, identifier depends on whether B2 is in platforms (keyword-aware)
|
||||
// We use the B2 key as identifier when B2 is present (sms+keyword), else B3 key (sender only)
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKeyAgnostic($project);
|
||||
|
||||
$subjects = SupplierProjectGrouping::subjectsOf($project);
|
||||
|
||||
foreach ($subjects as $subjectCode) {
|
||||
$key = $project->signal_type.'|'.$identifier.'|'.($subjectCode ?? 'null');
|
||||
if (! isset($groups[$key])) {
|
||||
$groups[$key] = [
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'identifier' => $identifier,
|
||||
'subject_code' => $subjectCode,
|
||||
'platforms' => $platforms,
|
||||
'projects' => [],
|
||||
];
|
||||
}
|
||||
$groups[$key]['projects'][] = $project;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync each group
|
||||
foreach ($groups as $group) {
|
||||
if (now()->timezone('Europe/Moscow')->format('H:i') >= self::TIME_BUDGET_CUTOFF) {
|
||||
Log::warning('supplier.sync.time_budget_reached', [
|
||||
'processed_until' => $sp->id,
|
||||
'group' => $group['identifier'],
|
||||
]);
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->syncOne($sp);
|
||||
$this->syncGroup($group);
|
||||
$consecutiveTransient = 0;
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectsJob: sp #{$sp->id} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
|
||||
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} escalated to manual queue #{$e->queueRowId}, reason: {$e->reason}");
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectsJob: sp #{$sp->id} deferred by portal window");
|
||||
Log::info("SyncSupplierProjectsJob: group {$group['identifier']} deferred by portal window");
|
||||
|
||||
continue;
|
||||
} catch (SupplierAuthException $e) {
|
||||
@@ -107,7 +146,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
throw $e;
|
||||
} catch (SupplierTransientException $e) {
|
||||
$consecutiveTransient++;
|
||||
$this->logSyncFailure($sp, $e);
|
||||
$this->logGroupFailure($group, $e);
|
||||
if ($consecutiveTransient >= self::MASS_FAIL_THRESHOLD) {
|
||||
Mail::to((string) config('services.supplier.alert_email'))
|
||||
->queue(new SupplierCriticalAlertMail(
|
||||
@@ -120,7 +159,7 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
|
||||
continue;
|
||||
} catch (SupplierClientException $e) {
|
||||
$this->logSyncFailure($sp, $e);
|
||||
$this->logGroupFailure($group, $e);
|
||||
report($e);
|
||||
|
||||
continue;
|
||||
@@ -128,131 +167,247 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function syncOne(SupplierProject $sp): void
|
||||
/**
|
||||
* @param array{signal_type: string, identifier: string, subject_code: int|null, platforms: list<string>, projects: list<Project>} $group
|
||||
*/
|
||||
private function syncGroup(array $group): void
|
||||
{
|
||||
$fkColumn = $this->fkColumnForPlatform($sp->platform);
|
||||
$signalType = $group['signal_type'];
|
||||
$identifier = $group['identifier'];
|
||||
$subjectCode = $group['subject_code'];
|
||||
$platforms = $group['platforms'];
|
||||
|
||||
/** @var EloquentCollection<int, Project> $liderraProjects */
|
||||
$liderraProjects = Project::on(self::DB_CONNECTION)
|
||||
->where($fkColumn, $sp->id)
|
||||
->where('is_active', true)
|
||||
/** @var list<Project> $groupProjects */
|
||||
$groupProjects = $group['projects'];
|
||||
|
||||
// Eligible-today: workday-mask for tomorrow
|
||||
$targetDate = Carbon::tomorrow('Europe/Moscow');
|
||||
$targetWeekday = $targetDate->isoWeekday();
|
||||
|
||||
/** @var list<Project> $eligible */
|
||||
$eligible = array_values(array_filter(
|
||||
$groupProjects,
|
||||
fn (Project $p) => ($p->delivery_days_mask & (1 << ($targetWeekday - 1))) !== 0
|
||||
));
|
||||
|
||||
if ($eligible === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute order and union workdays
|
||||
$eligibleLimits = array_map(fn (Project $p) => (int) $p->daily_limit_target, $eligible);
|
||||
$order = SupplierQuotaAllocator::computeOrder($eligibleLimits);
|
||||
|
||||
$workdaysUnion = [];
|
||||
foreach ($eligible as $p) {
|
||||
foreach ($this->bitmaskToList((int) $p->delivery_days_mask, 7) as $d) {
|
||||
$workdaysUnion[$d] = $d;
|
||||
}
|
||||
}
|
||||
sort($workdaysUnion);
|
||||
$workdays = $workdaysUnion;
|
||||
|
||||
// Tag and regions from subject
|
||||
$tag = $subjectCode !== null ? (RussianRegions::CODE_TO_NAME[$subjectCode] ?? (string) $subjectCode) : 'РФ';
|
||||
$regions = $subjectCode !== null ? [$subjectCode] : [];
|
||||
|
||||
// Find existing supplier_projects for this group
|
||||
$existingSps = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $identifier)
|
||||
->where('signal_type', $signalType)
|
||||
->when(
|
||||
$subjectCode !== null,
|
||||
fn ($q) => $q->where('subject_code', $subjectCode),
|
||||
fn ($q) => $q->whereNull('subject_code'),
|
||||
)
|
||||
->whereIn('platform', $platforms)
|
||||
->get();
|
||||
|
||||
if ($liderraProjects->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $regions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
$adapted = $this->adaptProjectsForAllocator($liderraProjects);
|
||||
$idMap = $this->client->saveProjectMultiFlag($dto);
|
||||
|
||||
$allocation = SupplierQuotaAllocator::allocate(
|
||||
platform: $sp->platform,
|
||||
signalType: $sp->signal_type,
|
||||
uniqueKey: $sp->unique_key,
|
||||
activeLiderraProjects: $adapted,
|
||||
targetDate: Carbon::tomorrow('Europe/Moscow'),
|
||||
);
|
||||
// Upsert supplier_projects rows (one per platform)
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($allocation === null) {
|
||||
return;
|
||||
}
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->forceCreate([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => $subjectCode,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $order,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$current = SupplierProjectDto::fromModel($sp);
|
||||
if ($allocation->equals($current)) {
|
||||
return;
|
||||
}
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'create',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$isCreate = $sp->supplier_external_id === null;
|
||||
|
||||
// NOTE: НЕ оборачиваем в DB::transaction() — HTTP-call к supplier выходит за
|
||||
// границы транзакционного контекста, атомарности всё равно нет. Два DB-write
|
||||
// (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 = $this->channel instanceof FailoverProjectChannel
|
||||
? $this->channel->createProjectForLiderra($contextProject, $allocation)
|
||||
: $this->channel->createProject($allocation);
|
||||
$sp->forceFill([
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $allocation->limit,
|
||||
'current_workdays' => $allocation->workdays,
|
||||
'current_regions' => $allocation->regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
} else {
|
||||
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);
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
} else {
|
||||
// Fix #3 (review-followup): partial-set recovery — если предыдущий run создал
|
||||
// не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via multi-flag
|
||||
// save с platforms=$missingPlatforms. Throws пропагируют в outer handle() catch
|
||||
// (SupplierAuth/Transient/Client) — full failover-counter semantics сохраняется.
|
||||
$existingPlatforms = $existingSps->pluck('platform')->all();
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $regions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
$missingIdMap = $this->client->saveProjectMultiFlag($missingDto);
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->forceCreate([
|
||||
'platform' => $platform,
|
||||
'signal_type' => $signalType,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => $subjectCode,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => $order,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'create',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #2 (review-followup): per-platform DTO в update-loop, чтобы portal получал
|
||||
// правильные srcrt/srcbl/srcmt для конкретной редактируемой строки (не first()
|
||||
// из mixed-platform existing set). R6 one shared limit/regions сохраняется.
|
||||
foreach ($existingSps as $sp) {
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
}
|
||||
$perPlatformDto = new SupplierProjectDto(
|
||||
platform: $sp->platform,
|
||||
signalType: $signalType,
|
||||
uniqueKey: $identifier,
|
||||
limit: $order,
|
||||
workdays: $workdays,
|
||||
regions: $regions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$sp->platform],
|
||||
);
|
||||
$this->channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => $order,
|
||||
'current_workdays' => $workdays,
|
||||
'current_regions' => $regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => 'update',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
$sp->forceFill([
|
||||
'current_limit' => $allocation->limit,
|
||||
'current_workdays' => $allocation->workdays,
|
||||
'current_regions' => $allocation->regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => $isCreate ? 'create' : 'update',
|
||||
'http_status' => 200,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Pivot: for each contributing Лидерра-project × each supplier_project → ON CONFLICT DO NOTHING
|
||||
foreach ($groupProjects as $lp) {
|
||||
foreach ($existingSps as $sp) {
|
||||
DB::connection(self::DB_CONNECTION)->table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $lp->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
|
||||
'subject_code' => $sp->subject_code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function logSyncFailure(SupplierProject $sp, Throwable $e): void
|
||||
/**
|
||||
* Log failure for a group (before any supplier_project is created/updated we don't have sp id,
|
||||
* so we look up existing or skip — best-effort audit).
|
||||
*
|
||||
* @param array{signal_type: string, identifier: string, subject_code: int|null, platforms: list<string>, projects: list<Project>} $group
|
||||
*/
|
||||
private function logGroupFailure(array $group, Throwable $e): void
|
||||
{
|
||||
$httpStatus = null;
|
||||
if ($e instanceof SupplierException) {
|
||||
$httpStatus = $e->httpStatus;
|
||||
}
|
||||
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => $sp->supplier_external_id === null ? 'create' : 'update',
|
||||
'http_status' => $httpStatus,
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
// Find any existing sp row for the group to link log entry
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)
|
||||
->where('unique_key', $group['identifier'])
|
||||
->where('signal_type', $group['signal_type'])
|
||||
->when(
|
||||
$group['subject_code'] !== null,
|
||||
fn ($q) => $q->where('subject_code', $group['subject_code']),
|
||||
fn ($q) => $q->whereNull('subject_code'),
|
||||
)
|
||||
->first();
|
||||
|
||||
if ($sp !== null) {
|
||||
SupplierSyncLog::on(self::DB_CONNECTION)->create([
|
||||
'supplier_project_id' => $sp->id,
|
||||
'action' => $sp->supplier_external_id === null ? 'create' : 'update',
|
||||
'http_status' => $httpStatus,
|
||||
'error_message' => substr($e->getMessage(), 0, 1000),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Адаптер Eloquent Project → stdClass с полями daily_limit/workdays/regions,
|
||||
* которые ожидает SupplierQuotaAllocator (pure function, не вяжется к Eloquent).
|
||||
*
|
||||
* Маппинг:
|
||||
* daily_limit ← daily_limit_target
|
||||
* workdays ← биты delivery_days_mask (bit 0=Пн, …, bit 6=Вс) → ISO 1..7
|
||||
* regions ← projects.regions INT[] (subject codes 1..89) direct copy
|
||||
*
|
||||
* @param EloquentCollection<int, Project> $projects
|
||||
* @return Collection<int, stdClass>
|
||||
*/
|
||||
private function adaptProjectsForAllocator(EloquentCollection $projects): Collection
|
||||
{
|
||||
return $projects->map(function (Project $p): stdClass {
|
||||
$obj = new stdClass;
|
||||
$obj->daily_limit = (int) $p->daily_limit_target;
|
||||
$obj->workdays = $this->bitmaskToList((int) $p->delivery_days_mask, 7);
|
||||
|
||||
// Plan 6: projects.regions[] напрямую копируется в supplier_projects.current_regions.
|
||||
// Empty array = "вся РФ" (паритет с supplier API semantics).
|
||||
// Legacy region_mask/region_mode игнорируются — они dual-write для PhonePrefixService,
|
||||
// outbound к supplier использует только regions[]. Cleanup в Plan 6.5.
|
||||
$obj->regions = array_values((array) $p->regions);
|
||||
|
||||
return $obj;
|
||||
})->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitmask → ordered list 1..maxBits для bits, выставленных в 1.
|
||||
* Bitmask → ordered list 1..maxBits.
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
@@ -267,14 +422,4 @@ class SyncSupplierProjectsJob implements ShouldQueue
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function fkColumnForPlatform(string $platform): string
|
||||
{
|
||||
return match ($platform) {
|
||||
'B1' => 'supplier_b1_project_id',
|
||||
'B2' => 'supplier_b2_project_id',
|
||||
'B3' => 'supplier_b3_project_id',
|
||||
default => throw new \InvalidArgumentException("Unknown supplier platform: {$platform}"),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,32 +11,45 @@ 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\SupplierExportMode;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use App\Services\Supplier\SupplierProjectGrouping;
|
||||
use App\Support\RussianRegions;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Синхронизирует Лидерра-проект с supplier_projects на B1/B2/B3
|
||||
* в зависимости от signal_type.
|
||||
* в зависимости от signal_type и текущего SupplierExportMode.
|
||||
*
|
||||
* Семантика:
|
||||
* site / call → B1 + B2 + B3
|
||||
* sms с keyword → B2 + B3
|
||||
* sms без keyword → B3
|
||||
* Режимы:
|
||||
* online → для каждой (subject × platform-set) группы проекта:
|
||||
* saveProjectMultiFlag с полными параметрами (limit, regions, tag)
|
||||
* → upsert supplier_projects + pivot project_supplier_links.
|
||||
* batch → «каркас»: создаёт supplier_projects с limit=0, без регионов
|
||||
* (старый путь); ночной SyncSupplierProjectsJob дольёт полные параметры.
|
||||
*
|
||||
* Записывает полученные 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 подберёт после ручного вмешательства).
|
||||
* Канал миграции:
|
||||
* batch mode — SupplierProjectChannel (FailoverProjectChannel: ярус 1 AJAX
|
||||
* → ярус 2 browser-form → ярус 3 manual queue) для createProject.
|
||||
* online mode — multi-flag save идёт напрямую через SupplierPortalClient
|
||||
* (tier-1 AJAX only — multi-flag нет в tier-2 form по архитектуре
|
||||
* портала). При любом transient/auth fail → log warning + skip
|
||||
* subject; Laravel retry (tries=3 backoff [15s,60s,300s]) → ночной
|
||||
* SyncSupplierProjectsJob подберёт с полным failover каналом.
|
||||
* updateProject в online остаётся через $channel (полная схема failover).
|
||||
* При эскалации на ярус 3 / переносе по окну портала — platform/subject пропускается
|
||||
* (FK/pivot остаётся пустым; ночной SyncSupplierProjectsJob восстанавливает).
|
||||
*
|
||||
* Retry: 3 попытки с backoff [15s, 60s, 300s].
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §5
|
||||
* Plan: docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 6
|
||||
*/
|
||||
class SyncSupplierProjectJob implements ShouldQueue
|
||||
{
|
||||
@@ -59,13 +72,214 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
return;
|
||||
}
|
||||
|
||||
$platforms = $this->resolvePlatforms($project);
|
||||
if (SupplierExportMode::isOnline()) {
|
||||
$this->handleOnline($project, $channel);
|
||||
} else {
|
||||
$this->handleBatch($project, $channel);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Online mode: per-subject full-param sync
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function handleOnline(Project $project, SupplierProjectChannel $channel): void
|
||||
{
|
||||
$client = app(SupplierPortalClient::class);
|
||||
|
||||
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
|
||||
if ($platforms === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$subjects = SupplierProjectGrouping::subjectsOf($project);
|
||||
|
||||
foreach ($subjects as $subject) {
|
||||
// Use first platform for key (site/call → identifier; sms → B2/B3 key)
|
||||
$identifier = SupplierProjectGrouping::buildUniqueKey($project, $platforms[0]);
|
||||
|
||||
$tag = $subject !== null
|
||||
? (RussianRegions::CODE_TO_NAME[$subject] ?? (string) $subject)
|
||||
: 'РФ';
|
||||
$regions = $subject !== null ? [$subject] : [];
|
||||
|
||||
// Idempotency: existing supplier_projects for this (identifier, subject)?
|
||||
$existingSps = SupplierProject::query()
|
||||
->where('unique_key', $identifier)
|
||||
->where('signal_type', (string) $project->signal_type)
|
||||
->when(
|
||||
$subject !== null,
|
||||
fn ($q) => $q->where('subject_code', $subject),
|
||||
fn ($q) => $q->whereNull('subject_code'),
|
||||
)
|
||||
->whereIn('platform', $platforms)
|
||||
->get();
|
||||
|
||||
if ($existingSps->isEmpty()) {
|
||||
// Create path: saveProjectMultiFlag → [platform => external_id]
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: $platforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: $regions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $platforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$idMap = $client->saveProjectMultiFlag($dto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} subject={$subject} escalated to manual queue #{$e->queueRowId}");
|
||||
|
||||
continue;
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} subject={$subject} deferred by portal window");
|
||||
|
||||
continue;
|
||||
} catch (\Throwable $e) {
|
||||
// Online multi-flag save bypasses FailoverProjectChannel (tier-1 only by design,
|
||||
// см. class docblock). При transient/auth/client/network fail — log+skip; следующий
|
||||
// tries-retry (15s, 60s, 300s) или ночной SyncSupplierProjectsJob подберёт.
|
||||
Log::warning("SyncSupplierProjectJob: online multi-flag save failed for project {$project->id} subject={$subject} (".get_class($e).'): '.$e->getMessage());
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$externalId = $idMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sp = SupplierProject::create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => $subject,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => $regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
} else {
|
||||
// Fix #3 (review-followup): partial-set recovery — если предыдущий run создал
|
||||
// не все platforms (e.g. B1+B2 OK, B3 escalated), re-attempt missing via
|
||||
// multi-flag save с platforms=$missingPlatforms (srcrt/srcbl/srcmt только missing).
|
||||
$existingPlatforms = $existingSps->pluck('platform')->all();
|
||||
$missingPlatforms = array_values(array_diff($platforms, $existingPlatforms));
|
||||
|
||||
if ($missingPlatforms !== []) {
|
||||
$missingDto = new SupplierProjectDto(
|
||||
platform: $missingPlatforms[0],
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: $regions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: $missingPlatforms,
|
||||
);
|
||||
|
||||
try {
|
||||
$missingIdMap = $client->saveProjectMultiFlag($missingDto);
|
||||
} catch (TierEscalatedException $e) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} subject={$subject} missing-platform re-attempt escalated #{$e->queueRowId}");
|
||||
$missingIdMap = [];
|
||||
} catch (WindowDeferredException) {
|
||||
Log::info("SyncSupplierProjectJob: project {$project->id} subject={$subject} missing-platform deferred by portal window");
|
||||
$missingIdMap = [];
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning("SyncSupplierProjectJob: missing-platform multi-flag failed for project {$project->id} subject={$subject}: ".$e->getMessage());
|
||||
$missingIdMap = [];
|
||||
}
|
||||
|
||||
foreach ($missingPlatforms as $platform) {
|
||||
$externalId = $missingIdMap[$platform] ?? null;
|
||||
if ($externalId === null) {
|
||||
continue;
|
||||
}
|
||||
$sp = SupplierProject::create([
|
||||
'platform' => $platform,
|
||||
'signal_type' => (string) $project->signal_type,
|
||||
'unique_key' => $identifier,
|
||||
'subject_code' => $subject,
|
||||
'supplier_external_id' => (string) $externalId,
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => $regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
]);
|
||||
$existingSps->push($sp);
|
||||
}
|
||||
}
|
||||
|
||||
// Fix #2 (review-followup): per-platform DTO в update-loop, чтобы portal
|
||||
// получал корректные srcrt/srcbl/srcmt флаги для конкретной редактируемой строки
|
||||
// (не первой из mixed-platform existing set). R6 one shared limit/regions сохраняется.
|
||||
foreach ($existingSps as $sp) {
|
||||
if ($sp->supplier_external_id === null) {
|
||||
continue;
|
||||
}
|
||||
$perPlatformDto = new SupplierProjectDto(
|
||||
platform: $sp->platform,
|
||||
signalType: (string) $project->signal_type,
|
||||
uniqueKey: $identifier,
|
||||
limit: (int) $project->daily_limit_target,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
regions: $regions,
|
||||
regionsReverse: false,
|
||||
status: 'active',
|
||||
tag: $tag,
|
||||
platforms: [$sp->platform],
|
||||
);
|
||||
$channel->updateProject((int) $sp->supplier_external_id, $perPlatformDto);
|
||||
$sp->forceFill([
|
||||
'current_limit' => (int) $project->daily_limit_target,
|
||||
'current_regions' => $regions,
|
||||
'sync_status' => 'ok',
|
||||
'last_synced_at' => now(),
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
// Pivot: project × each supplier_project → ON CONFLICT DO NOTHING
|
||||
foreach ($existingSps as $sp) {
|
||||
DB::table('project_supplier_links')->insertOrIgnore([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
// @phpstan-ignore-next-line property.notFound — subject_code in fillable/casts, IDE stubs lag
|
||||
'subject_code' => $sp->subject_code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Batch mode: каркас (limit=0, no regions) — backward-compat
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private function handleBatch(Project $project, SupplierProjectChannel $channel): void
|
||||
{
|
||||
$platforms = SupplierProjectGrouping::resolvePlatforms($project);
|
||||
|
||||
foreach ($platforms as $platform) {
|
||||
$uniqueKey = $this->buildUniqueKey($project, $platform);
|
||||
$uniqueKey = SupplierProjectGrouping::buildUniqueKey($project, $platform);
|
||||
$column = 'supplier_'.strtolower($platform).'_project_id';
|
||||
|
||||
// Идемпотентность: local supplier_projects-запись для тройки уже есть?
|
||||
// Idempotency: local supplier_projects-запись уже есть?
|
||||
$existing = SupplierProject::query()
|
||||
->where('platform', $platform)
|
||||
->where('signal_type', $project->signal_type)
|
||||
@@ -78,7 +292,16 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
$dto = $this->buildDto($project, $platform, $uniqueKey);
|
||||
$dto = 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',
|
||||
);
|
||||
|
||||
try {
|
||||
$externalId = $channel instanceof FailoverProjectChannel
|
||||
@@ -110,64 +333,4 @@ class SyncSupplierProjectJob implements ShouldQueue
|
||||
|
||||
$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'.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function resolvePlatforms(Project $project): array
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return ['B1', 'B2', 'B3'];
|
||||
}
|
||||
|
||||
if ($project->signal_type === 'sms') {
|
||||
return $project->sms_keyword ? ['B2', 'B3'] : ['B3'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Строит unique_key для пары (project, platform):
|
||||
* site/call → signal_identifier (домен / телефон)
|
||||
* sms B2 → sender + '+' + keyword
|
||||
* sms B3 → sender
|
||||
*/
|
||||
private function buildUniqueKey(Project $project, string $platform): string
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return (string) $project->signal_identifier;
|
||||
}
|
||||
|
||||
// sms
|
||||
$sender = (string) ($project->sms_senders[0] ?? '');
|
||||
|
||||
if ($platform === 'B2') {
|
||||
return $sender.'+'.($project->sms_keyword ?? '');
|
||||
}
|
||||
|
||||
// B3
|
||||
return $sender;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class Deal extends Model
|
||||
'utm_campaign',
|
||||
'utm_content',
|
||||
'region_code',
|
||||
'subject_code',
|
||||
'city',
|
||||
'time_in_form_seconds',
|
||||
'lead_score',
|
||||
@@ -72,6 +73,7 @@ class Deal extends Model
|
||||
'duplicate_of_id' => 'integer',
|
||||
'escalated_count' => 'integer',
|
||||
'time_in_form_seconds' => 'integer',
|
||||
'subject_code' => 'integer',
|
||||
'lead_score' => 'decimal:2',
|
||||
'phones' => 'array',
|
||||
'is_test' => 'boolean',
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
@@ -115,6 +116,15 @@ class Project extends Model
|
||||
return $this->belongsTo(SupplierProject::class, 'supplier_b3_project_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<SupplierProject, $this>
|
||||
*/
|
||||
public function supplierProjects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(SupplierProject::class, 'project_supplier_links')
|
||||
->withPivot(['platform', 'subject_code']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Активные проекты, у которых сегодняшний день включён в delivery_days_mask.
|
||||
*
|
||||
|
||||
@@ -7,11 +7,24 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Очередь яруса 3 резерва канала миграции проектов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md §4.5
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $project_id
|
||||
* @property string $platform
|
||||
* @property string $operation
|
||||
* @property string|null $external_id
|
||||
* @property array<string, mixed> $payload_snapshot
|
||||
* @property string $failure_reason
|
||||
* @property string $status
|
||||
* @property int|null $resolved_by_user_id
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $resolved_at
|
||||
*/
|
||||
class SupplierManualSyncQueue extends Model
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ use Database\Factories\SupplierProjectFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
/**
|
||||
* Supplier-уровневый агрегат проекта у поставщика crm.bp-gr.ru.
|
||||
@@ -40,6 +41,7 @@ class SupplierProject extends Model
|
||||
'sync_status',
|
||||
'last_synced_at',
|
||||
'inactive_since',
|
||||
'subject_code',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -50,6 +52,7 @@ class SupplierProject extends Model
|
||||
'current_limit' => 'integer',
|
||||
'last_synced_at' => 'datetime',
|
||||
'inactive_since' => 'datetime',
|
||||
'subject_code' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -81,6 +84,15 @@ class SupplierProject extends Model
|
||||
return $query->where('signal_type', $signalType)->where('unique_key', $uniqueKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsToMany<Project, $this>
|
||||
*/
|
||||
public function projects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Project::class, 'project_supplier_links')
|
||||
->withPivot(['platform', 'subject_code']);
|
||||
}
|
||||
|
||||
protected static function newFactory(): SupplierProjectFactory
|
||||
{
|
||||
return SupplierProjectFactory::new();
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Random\Randomizer;
|
||||
|
||||
/**
|
||||
* Отбор получателей входящего лида: ≤ CAP случайных из eligible (sharing cap).
|
||||
*
|
||||
* cap=3 — защита владельца номера-донора (лид продаётся максимум 3 раза).
|
||||
* Eligible уже отфильтрован LeadRouter (есть остаток лимита) → отбор лимит не
|
||||
* превышает. Рандом через инъектируемый \Random\Randomizer (тесты сидируют
|
||||
* Mt19937 для детерминизма; прод — CSPRNG по умолчанию).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.6.
|
||||
*/
|
||||
final class LeadDistributor
|
||||
{
|
||||
public const CAP = 3;
|
||||
|
||||
public function __construct(private readonly Randomizer $randomizer = new Randomizer) {}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
*
|
||||
* @param Collection<int, T> $eligible
|
||||
* @return Collection<int, T>
|
||||
*/
|
||||
public function selectRecipients(Collection $eligible): Collection
|
||||
{
|
||||
$items = $eligible->values()->all();
|
||||
|
||||
if (count($items) <= self::CAP) {
|
||||
return collect($items);
|
||||
}
|
||||
|
||||
$keys = $this->randomizer->pickArrayKeys($items, self::CAP);
|
||||
|
||||
return collect($keys)->map(fn (int $k) => $items[$k])->values();
|
||||
}
|
||||
}
|
||||
@@ -8,70 +8,45 @@ use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
|
||||
*
|
||||
* Алгоритм:
|
||||
* 1. SELECT projects WHERE supplier_b{1,2,3}_project_id = $supplier->id (по platform).
|
||||
* 2. Фильтр: is_active=true.
|
||||
* 3. Workdays: (delivery_days_mask & today_bit) <> 0, today_bit = 1 << (ISO_DOW - 1).
|
||||
* 4. delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target).
|
||||
* 5. tenants.balance_leads > 0 OR tenants.balance_rub > 0 (через WHERE EXISTS;
|
||||
* Plan 4 Task 4: dual-balance — rub-only tenant ДОЛЖЕН пройти, LedgerService
|
||||
* сам резолвит prepaid/rub и кидает InsufficientBalanceException, если оба = 0).
|
||||
* 6. Region match через PhonePrefixService::phoneMatchesRegions (в PHP, не в SQL —
|
||||
* district-bit резолвится по 3/4-значному коду в PHP-словаре).
|
||||
* 7. Сортировка: created_at ASC, id ASC (детерминированно — spec §6 step 4).
|
||||
* Eligibility — структурно через pivot project_supplier_links: проект eligible,
|
||||
* если связан с пришедшим supplier_project (= источник × субъект) + активен +
|
||||
* сегодня рабочий день + есть остаток лимита + у тенанта есть баланс.
|
||||
*
|
||||
* Plan 3 Task 3: запрос идёт через connection `pgsql_supplier` (BYPASSRLS-роль
|
||||
* crm_supplier_worker). Это закрывает WARN #2 — в sharing-flow tenant ещё не
|
||||
* определён, SELECT обходит RLS-фильтрацию и видит проекты ВСЕХ tenant'ов
|
||||
* параллельно. WHERE-фильтры (is_active, FK на supplier_project, workdays, лимиты,
|
||||
* balance) сохраняются как defense-in-depth.
|
||||
* Регион сопоставляется самим supplier_project (тег = субъект) — phone-prefix
|
||||
* фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
|
||||
* гарантирован тем, через какой supplier_project пришёл лид.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6 +
|
||||
* docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §1.
|
||||
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) — в
|
||||
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
|
||||
*/
|
||||
class LeadRouter
|
||||
{
|
||||
public function __construct(
|
||||
private readonly PhonePrefixService $phonePrefix,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject, string $phone): Collection
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
|
||||
{
|
||||
$fkColumn = match ($supplierProject->platform) {
|
||||
'B1' => 'supplier_b1_project_id',
|
||||
'B2' => 'supplier_b2_project_id',
|
||||
'B3' => 'supplier_b3_project_id',
|
||||
// Unreachable per CHECK chk_supplier_projects_platform; defensive for static analysis.
|
||||
default => throw new InvalidArgumentException(
|
||||
"Unknown supplier platform: {$supplierProject->platform}"
|
||||
),
|
||||
};
|
||||
|
||||
// МСК-aligned ISO day-of-week: Plan 2 Task 9 reset cron also runs at 00:00 МСК,
|
||||
// so workday-mask check must use same timezone to avoid off-by-one near midnight.
|
||||
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
/** @var Collection<int, Project> $candidates */
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->where($fkColumn, $supplierProject->id)
|
||||
->whereExists(function ($q) use ($supplierProject): void {
|
||||
$q->selectRaw('1')
|
||||
->from('project_supplier_links')
|
||||
->whereColumn('project_supplier_links.project_id', 'projects.id')
|
||||
->where('project_supplier_links.supplier_project_id', $supplierProject->id);
|
||||
})
|
||||
->where('is_active', true)
|
||||
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
|
||||
->whereRaw(
|
||||
'delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)'
|
||||
)
|
||||
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
|
||||
->whereExists(function ($q): void {
|
||||
// Plan 4 Task 4: dual-balance — допускаем rub-only tenant'ов.
|
||||
// LedgerService::chargeForDelivery сам выбирает prepaid (balance_leads--)
|
||||
// или rub (balance_rub -= tier_price) и кидает InsufficientBalanceException,
|
||||
// если ОБА = 0. До Plan 4 фильтр был строгий balance_leads > 0 (prepaid only).
|
||||
$q->selectRaw('1')
|
||||
->from('tenants')
|
||||
->whereColumn('tenants.id', 'projects.tenant_id')
|
||||
@@ -84,12 +59,6 @@ class LeadRouter
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return $candidates->filter(
|
||||
fn (Project $p): bool => $this->phonePrefix->phoneMatchesRegions(
|
||||
$phone,
|
||||
(int) $p->region_mask,
|
||||
(string) $p->region_mode,
|
||||
)
|
||||
)->values();
|
||||
return $candidates->values();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
|
||||
/**
|
||||
* Резолвит регион-тег поставщика (raw_payload['tag'] = имя субъекта или «РФ»)
|
||||
* в код субъекта 1..89. «РФ»/пусто/неизвестно → null (пул «Вся РФ»/неизвестно).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.4.
|
||||
*/
|
||||
final class RegionTagResolver
|
||||
{
|
||||
public function resolve(string $tag): ?int
|
||||
{
|
||||
$tag = trim($tag);
|
||||
|
||||
if ($tag === '' || $tag === 'РФ') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RussianRegions::nameToCode()[$tag] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,9 @@ final readonly class SupplierProjectDto
|
||||
array $regions,
|
||||
public bool $regionsReverse, // false = include (default), true = exclude
|
||||
public string $status, // active / paused
|
||||
public string $tag = '_lidpotok',
|
||||
/** @var array<int, string> */
|
||||
public array $platforms = [],
|
||||
) {
|
||||
// Canonical order for deterministic equals() vs PG jsonb non-deterministic order.
|
||||
// sort() reorders in-place AND re-indexes keys 0..N-1 (PHP guarantees list-semantics).
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Глобальный режим экспорта проектов поставщику (system_settings).
|
||||
* 'online' — sync сразу при create/edit с полными параметрами;
|
||||
* 'batch' — каркас сразу + полные параметры ночным SyncSupplierProjectsJob (18:00).
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.1.
|
||||
*/
|
||||
final class SupplierExportMode
|
||||
{
|
||||
public const ONLINE = 'online';
|
||||
|
||||
public const BATCH = 'batch';
|
||||
|
||||
public static function current(): string
|
||||
{
|
||||
$value = DB::table('system_settings')->where('key', 'supplier_export_mode')->value('value');
|
||||
|
||||
return $value === self::ONLINE ? self::ONLINE : self::BATCH;
|
||||
}
|
||||
|
||||
public static function isOnline(): bool
|
||||
{
|
||||
return self::current() === self::ONLINE;
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,36 @@ class SupplierPortalClient
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
|
||||
}
|
||||
|
||||
/**
|
||||
* R5: один save с флагами всех dto->platforms → портал создаёт N rt-проектов,
|
||||
* портал делит лимит сам (R6). Ответ rt-project-save отдаёт id последнего →
|
||||
* дочитываем listProjects и матчим по name+tag (R-SAVE вариант а, Task 1 finding).
|
||||
*
|
||||
* @return array<string, int> [platform => external_id]
|
||||
*/
|
||||
public function saveProjectMultiFlag(SupplierProjectDto $dto): array
|
||||
{
|
||||
$response = $this->request(
|
||||
'POST', '/admin/visit/rt-project-save',
|
||||
$this->toPayload($dto, externalId: 0), asJson: true,
|
||||
);
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
|
||||
|
||||
$srcToPlatform = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
|
||||
$out = [];
|
||||
foreach ($this->listProjects() as $p) {
|
||||
if (($p['name'] ?? null) !== $dto->uniqueKey || ($p['tag'] ?? null) !== $dto->tag) {
|
||||
continue;
|
||||
}
|
||||
$platform = $srcToPlatform[$p['src'] ?? ''] ?? null;
|
||||
if ($platform !== null && in_array($platform, $dto->platforms !== [] ? $dto->platforms : [$dto->platform], true)) {
|
||||
$out[$platform] = (int) $p['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
public function deleteProject(int $externalId): void
|
||||
{
|
||||
$response = $this->request(
|
||||
@@ -320,9 +350,43 @@ class SupplierPortalClient
|
||||
);
|
||||
}
|
||||
|
||||
// Defense-in-depth: портал отдаёт логин-страницу с HTTP 200 при истекшей
|
||||
// сессии middle-of-use (вместо 401/403). Детектим Yii2-маркер и форсим
|
||||
// refresh+retry. Verified 2026-05-19: refresh-session.js ловит #loginform-username.
|
||||
if ($this->isHtmlLoginPage($response)) {
|
||||
if ($isRetry) {
|
||||
throw new SupplierAuthException(
|
||||
"Portal returned login page after refresh on {$path}",
|
||||
httpStatus: $response->status(),
|
||||
responseBody: $response->body(),
|
||||
);
|
||||
}
|
||||
try {
|
||||
dispatch_sync(app(RefreshSupplierSessionJob::class));
|
||||
} catch (\Throwable $e) {
|
||||
throw new SupplierAuthException(
|
||||
"Session refresh failed during HTML-login retry on {$path}: {$e->getMessage()}",
|
||||
httpStatus: $response->status(),
|
||||
previous: $e,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->request($method, $path, $body, isRetry: true, asJson: $asJson);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function isHtmlLoginPage(Response $response): bool
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
if (! str_starts_with(mb_strtolower($contentType), 'text/html')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('~loginform-(username|password)~i', $response->body()) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{phpsessid: string, csrf: string, refreshed_at?: string}
|
||||
*/
|
||||
@@ -385,16 +449,17 @@ class SupplierPortalClient
|
||||
default => $dto->signalType,
|
||||
};
|
||||
|
||||
$srcrt = $dto->platform === 'B1';
|
||||
$srcbl = $dto->platform === 'B2';
|
||||
$srcmt = $dto->platform === 'B3';
|
||||
$platforms = $dto->platforms !== [] ? $dto->platforms : [$dto->platform];
|
||||
$srcrt = in_array('B1', $platforms, true);
|
||||
$srcbl = in_array('B2', $platforms, true);
|
||||
$srcmt = in_array('B3', $platforms, true);
|
||||
|
||||
// workdays: int → string (portal: ["1","2",...,"7"]).
|
||||
$workdays = array_map(static fn (int $d): string => (string) $d, $dto->workdays);
|
||||
|
||||
return [
|
||||
'id' => $externalId,
|
||||
'tag' => '_lidpotok',
|
||||
'tag' => $dto->tag,
|
||||
'name' => $dto->uniqueKey,
|
||||
'type' => $type,
|
||||
'content' => $dto->uniqueKey,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
use App\Models\Project;
|
||||
|
||||
/**
|
||||
* DRY-хелперы для группировки Лидерра-проектов по (subject × platform-set).
|
||||
*
|
||||
* Используется в:
|
||||
* - SyncSupplierProjectJob (онлайн-режим, один проект)
|
||||
* - SyncSupplierProjectsJob (ночной батч, все проекты)
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.3
|
||||
* Plan: docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md Task 6
|
||||
*/
|
||||
final class SupplierProjectGrouping
|
||||
{
|
||||
/**
|
||||
* Строит unique_key для пары (project, platform):
|
||||
* site/call → signal_identifier (домен / телефон)
|
||||
* sms B2 → sender + '+' + keyword
|
||||
* sms B3 → sender
|
||||
*
|
||||
* Для ночного батч-джоба используйте buildUniqueKeyNoplatform() — он
|
||||
* выбирает B2-ключ автоматически при наличии keyword.
|
||||
*/
|
||||
public static function buildUniqueKey(Project $project, string $platform): string
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return (string) $project->signal_identifier;
|
||||
}
|
||||
|
||||
// sms
|
||||
$sender = (string) ($project->sms_senders[0] ?? '');
|
||||
|
||||
if ($platform === 'B2') {
|
||||
return $sender.'+'.($project->sms_keyword ?? '');
|
||||
}
|
||||
|
||||
// B3
|
||||
return $sender;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unique identifier key без привязки к конкретной платформе
|
||||
* (для группировки в ночном батч-джобе):
|
||||
* site/call → signal_identifier
|
||||
* sms+keyword → sender+keyword (B2 ключ)
|
||||
* sms без keyword → sender (B3 ключ)
|
||||
*/
|
||||
public static function buildUniqueKeyAgnostic(Project $project): string
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return (string) $project->signal_identifier;
|
||||
}
|
||||
|
||||
$sender = (string) ($project->sms_senders[0] ?? '');
|
||||
if ($project->sms_keyword !== null && $project->sms_keyword !== '') {
|
||||
return $sender.'+'.$project->sms_keyword;
|
||||
}
|
||||
|
||||
return $sender;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает список uppercase platform-кодов для данного project.
|
||||
* Коды соответствуют CHECK constraint: 'B1' / 'B2' / 'B3'.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function resolvePlatforms(Project $project): array
|
||||
{
|
||||
if (in_array($project->signal_type, ['site', 'call'], true)) {
|
||||
return ['B1', 'B2', 'B3'];
|
||||
}
|
||||
|
||||
if ($project->signal_type === 'sms') {
|
||||
return ($project->sms_keyword !== null && $project->sms_keyword !== '')
|
||||
? ['B2', 'B3']
|
||||
: ['B3'];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns subjects (region codes 1..89) for a project.
|
||||
* Empty regions → [null] (one group, "Вся РФ" pool).
|
||||
*
|
||||
* @return list<int|null>
|
||||
*/
|
||||
public static function subjectsOf(Project $project): array
|
||||
{
|
||||
$regions = array_values((array) $project->regions);
|
||||
// @phpstan-ignore-next-line identical.alwaysFalse — PostgresIntArray PHPDoc non-empty, runtime can be empty
|
||||
if (count($regions) === 0) {
|
||||
return [null];
|
||||
}
|
||||
|
||||
return array_map(fn ($r) => (int) $r, $regions);
|
||||
}
|
||||
}
|
||||
@@ -9,26 +9,24 @@ use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Pure function: распределение квоты daily_limit между platform B1/B2/B3.
|
||||
* Pure function: формула заказа у поставщика на (источник × субъект).
|
||||
*
|
||||
* Используется SyncSupplierProjectsJob для агрегирования daily_limit_target
|
||||
* всех активных Лидерра-проектов на одного supplier_project и распределения
|
||||
* суммарной квоты между B1/B2/B3 платформами.
|
||||
* Эпик миграции проектов (Plan 3): platform-split B1/B2/B3 удалён — портал
|
||||
* делит лимит сам (R6). Один лимит на группу eligible-клиентов:
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §4.3-§4.4
|
||||
* order = max(наибольший_лимит, ceil(Σ_лимитов / 3))
|
||||
*
|
||||
* Distribution-формулы:
|
||||
* site/call:
|
||||
* B1 = ceil(total/3)
|
||||
* B2 = ceil((total - B1) / 2)
|
||||
* B3 = total - B1 - B2
|
||||
* sms-with-keyword (B1 не поддерживает СМС):
|
||||
* B1 = 0
|
||||
* B2 = ceil(total/2)
|
||||
* B3 = floor(total/2)
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
*
|
||||
* `allocate()` оставлен с прежней сигнатурой для временной совместимости
|
||||
* c SyncSupplierProjectsJob — внутри использует computeOrder, возвращает
|
||||
* DTO с одинаковым limit на любую platform/signalType.
|
||||
*
|
||||
* Workdays и regions — союзы (deduplicated, sorted) активных Лидерра-проектов,
|
||||
* eligible на targetDate (фильтр по weekday в Europe/Moscow).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
|
||||
*/
|
||||
final class SupplierQuotaAllocator
|
||||
{
|
||||
@@ -56,7 +54,9 @@ final class SupplierQuotaAllocator
|
||||
$workdaysUnion = self::unionInts($eligibleProjects->pluck('workdays'));
|
||||
$regionsUnion = self::unionInts($eligibleProjects->pluck('regions'));
|
||||
|
||||
$platformLimit = self::distributeForPlatform($signalType, $platform, $totalQuota);
|
||||
$platformLimit = self::computeOrder(
|
||||
$eligibleProjects->pluck('daily_limit')->map(fn ($v) => (int) $v)->all()
|
||||
);
|
||||
|
||||
return new SupplierProjectDto(
|
||||
platform: $platform,
|
||||
@@ -70,28 +70,26 @@ final class SupplierQuotaAllocator
|
||||
);
|
||||
}
|
||||
|
||||
private static function distributeForPlatform(string $signalType, string $platform, int $total): int
|
||||
/**
|
||||
* Заказ у поставщика на (источник × субъект): max(наибольший лимит, ceil(Σ/3)).
|
||||
*
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
*
|
||||
* Один лимит на группу; портал делит на B1/B2/B3 сам (R6 — наш split убран).
|
||||
*
|
||||
* @param array<int, int> $dailyLimits лимиты eligible-сегодня клиентов группы
|
||||
*/
|
||||
public static function computeOrder(array $dailyLimits): int
|
||||
{
|
||||
if ($signalType === 'sms') {
|
||||
if ($platform === 'B1') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $platform === 'B2'
|
||||
? (int) ceil($total / 2)
|
||||
: (int) floor($total / 2);
|
||||
if ($dailyLimits === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$b1 = (int) ceil($total / 3);
|
||||
$b2 = (int) ceil(($total - $b1) / 2);
|
||||
$b3 = $total - $b1 - $b2;
|
||||
$sum = array_sum($dailyLimits);
|
||||
$max = max($dailyLimits);
|
||||
|
||||
return match ($platform) {
|
||||
'B1' => $b1,
|
||||
'B2' => $b2,
|
||||
'B3' => $b3,
|
||||
default => 0,
|
||||
};
|
||||
return max($max, (int) ceil($sum / 3));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Канонический справочник субъектов РФ (1..89) — PHP-зеркало
|
||||
* resources/js/constants/regions.ts (конституционный порядок, ст. 65).
|
||||
* Sentinel 0 «Вся РФ» не входит (= NULL subject_code / пустой regions).
|
||||
*
|
||||
* ВАЖНО: при правке regions.ts синхронно править этот файл (тест RegionTagResolverTest
|
||||
* «mirrors regions.ts — exactly 89» ловит расхождение по count, но не по именам —
|
||||
* сверять имена вручную при изменениях).
|
||||
*/
|
||||
final class RussianRegions
|
||||
{
|
||||
/** @var array<int, string> code(1..89) => официальное имя субъекта */
|
||||
public const CODE_TO_NAME = [
|
||||
// 24 республики
|
||||
1 => 'Республика Адыгея',
|
||||
2 => 'Республика Алтай',
|
||||
3 => 'Республика Башкортостан',
|
||||
4 => 'Республика Бурятия',
|
||||
5 => 'Республика Дагестан',
|
||||
6 => 'Донецкая Народная Республика',
|
||||
7 => 'Республика Ингушетия',
|
||||
8 => 'Кабардино-Балкарская Республика',
|
||||
9 => 'Республика Калмыкия',
|
||||
10 => 'Карачаево-Черкесская Республика',
|
||||
11 => 'Республика Карелия',
|
||||
12 => 'Республика Коми',
|
||||
13 => 'Республика Крым',
|
||||
14 => 'Луганская Народная Республика',
|
||||
15 => 'Республика Марий Эл',
|
||||
16 => 'Республика Мордовия',
|
||||
17 => 'Республика Саха (Якутия)',
|
||||
18 => 'Республика Северная Осетия — Алания',
|
||||
19 => 'Республика Татарстан',
|
||||
20 => 'Республика Тыва',
|
||||
21 => 'Удмуртская Республика',
|
||||
22 => 'Республика Хакасия',
|
||||
23 => 'Чеченская Республика',
|
||||
24 => 'Чувашская Республика',
|
||||
// 9 краёв
|
||||
25 => 'Алтайский край',
|
||||
26 => 'Забайкальский край',
|
||||
27 => 'Камчатский край',
|
||||
28 => 'Краснодарский край',
|
||||
29 => 'Красноярский край',
|
||||
30 => 'Пермский край',
|
||||
31 => 'Приморский край',
|
||||
32 => 'Ставропольский край',
|
||||
33 => 'Хабаровский край',
|
||||
// 48 областей
|
||||
34 => 'Амурская область',
|
||||
35 => 'Архангельская область',
|
||||
36 => 'Астраханская область',
|
||||
37 => 'Белгородская область',
|
||||
38 => 'Брянская область',
|
||||
39 => 'Владимирская область',
|
||||
40 => 'Волгоградская область',
|
||||
41 => 'Вологодская область',
|
||||
42 => 'Воронежская область',
|
||||
43 => 'Запорожская область',
|
||||
44 => 'Ивановская область',
|
||||
45 => 'Иркутская область',
|
||||
46 => 'Калининградская область',
|
||||
47 => 'Калужская область',
|
||||
48 => 'Кемеровская область',
|
||||
49 => 'Кировская область',
|
||||
50 => 'Костромская область',
|
||||
51 => 'Курганская область',
|
||||
52 => 'Курская область',
|
||||
53 => 'Ленинградская область',
|
||||
54 => 'Липецкая область',
|
||||
55 => 'Магаданская область',
|
||||
56 => 'Московская область',
|
||||
57 => 'Мурманская область',
|
||||
58 => 'Нижегородская область',
|
||||
59 => 'Новгородская область',
|
||||
60 => 'Новосибирская область',
|
||||
61 => 'Омская область',
|
||||
62 => 'Оренбургская область',
|
||||
63 => 'Орловская область',
|
||||
64 => 'Пензенская область',
|
||||
65 => 'Псковская область',
|
||||
66 => 'Ростовская область',
|
||||
67 => 'Рязанская область',
|
||||
68 => 'Самарская область',
|
||||
69 => 'Саратовская область',
|
||||
70 => 'Сахалинская область',
|
||||
71 => 'Свердловская область',
|
||||
72 => 'Смоленская область',
|
||||
73 => 'Тамбовская область',
|
||||
74 => 'Тверская область',
|
||||
75 => 'Томская область',
|
||||
76 => 'Тульская область',
|
||||
77 => 'Тюменская область',
|
||||
78 => 'Ульяновская область',
|
||||
79 => 'Херсонская область',
|
||||
80 => 'Челябинская область',
|
||||
81 => 'Ярославская область',
|
||||
// 3 города федерального значения
|
||||
82 => 'Москва',
|
||||
83 => 'Санкт-Петербург',
|
||||
84 => 'Севастополь',
|
||||
// 1 автономная область
|
||||
85 => 'Еврейская автономная область',
|
||||
// 4 автономных округа
|
||||
86 => 'Ненецкий автономный округ',
|
||||
87 => 'Ханты-Мансийский автономный округ — Югра',
|
||||
88 => 'Чукотский автономный округ',
|
||||
89 => 'Ямало-Ненецкий автономный округ',
|
||||
];
|
||||
|
||||
/** @return array<string, int> name => code (обратный индекс) */
|
||||
public static function nameToCode(): array
|
||||
{
|
||||
return array_flip(self::CODE_TO_NAME);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace Database\Factories;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<Project>
|
||||
@@ -20,7 +21,11 @@ class ProjectFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'name' => fake()->unique()->words(3, true),
|
||||
// Квирк #77: fake()->unique() создаёт новый UniqueGenerator на каждый
|
||||
// definition()-call → history между вызовами не сохраняется, uniqueness
|
||||
// внутри batch не гарантирована (коллизия (tenant_id, name) UNIQUE в
|
||||
// pest --parallel). Str::random(8) суффикс (62^8 ≈ 2e14) гасит коллизию.
|
||||
'name' => fake()->words(3, true).' '.Str::random(8),
|
||||
'type' => 'webhook',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Per-субъект supplier_projects (эпик переделки миграции проектов, v8.26).
|
||||
*
|
||||
* +subject_code SMALLINT NULL (1..89 субъект РФ; NULL = пул «Вся РФ»).
|
||||
* Старый unique (platform, unique_key) → (platform, unique_key, subject_code)
|
||||
* NULLS NOT DISTINCT — пул «Вся РФ» уникален per (platform, unique_key).
|
||||
*
|
||||
* Guard: migrate:fresh грузит schema.sql v8.26 (delta уже там) до миграций.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('supplier_projects', 'subject_code')) {
|
||||
DB::statement('ALTER TABLE supplier_projects ADD COLUMN subject_code SMALLINT');
|
||||
}
|
||||
|
||||
DB::statement(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_supplier_projects_subject_code'
|
||||
) THEN
|
||||
ALTER TABLE supplier_projects
|
||||
ADD CONSTRAINT chk_supplier_projects_subject_code
|
||||
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89)) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
DB::statement('ALTER TABLE supplier_projects VALIDATE CONSTRAINT chk_supplier_projects_subject_code');
|
||||
|
||||
DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_unique_key_unique');
|
||||
DB::statement(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_key_subject_unique '
|
||||
.'ON supplier_projects (platform, unique_key, subject_code) NULLS NOT DISTINCT'
|
||||
);
|
||||
|
||||
DB::statement(
|
||||
'COMMENT ON COLUMN supplier_projects.subject_code IS '
|
||||
."'Субъект РФ 1..89 (resources/js/constants/regions.ts). NULL = пул «Вся РФ». Эпик миграции проектов v8.26.'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_key_subject_unique');
|
||||
DB::statement(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_unique_key_unique '
|
||||
.'ON supplier_projects (platform, unique_key)'
|
||||
);
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT IF EXISTS chk_supplier_projects_subject_code');
|
||||
|
||||
if (Schema::hasColumn('supplier_projects', 'subject_code')) {
|
||||
Schema::table('supplier_projects', fn ($t) => $t->dropColumn('subject_code'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* M:N pivot между projects (tenant) и supplier_projects (SaaS, shared) — v8.26.
|
||||
*
|
||||
* Заменяет 3 FK-слота projects.supplier_b{1,2,3}_project_id (которые не вмещают
|
||||
* per-субъект модель: N субъектов × до 3 платформ = до 3N связей).
|
||||
* SaaS-level (без RLS, как supplier_projects): пишется sync-флоу, читается
|
||||
* sharing-флоу через BYPASSRLS-роль crm_supplier_worker.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$exists = DB::selectOne("SELECT to_regclass('public.project_supplier_links') AS r");
|
||||
if ($exists !== null && $exists->r !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE TABLE project_supplier_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
subject_code SMALLINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1', 'B2', 'B3')),
|
||||
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_psl_supplier_project ON project_supplier_links (supplier_project_id);
|
||||
CREATE INDEX idx_psl_project ON project_supplier_links (project_id);
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP TABLE IF EXISTS project_supplier_links');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* deals.subject_code — субъект РФ из тега поставщика (raw_payload['tag']) — v8.26.
|
||||
*
|
||||
* Источник истины региона сделки = тег проекта у поставщика (надёжнее phone-prefix
|
||||
* для мобильных). Отдельно от deals.region_code (ISO-3166, phone-derived).
|
||||
* deals партиционирована — ADD COLUMN наследуется партициями.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('deals', 'subject_code')) {
|
||||
DB::statement('ALTER TABLE deals ADD COLUMN subject_code SMALLINT');
|
||||
}
|
||||
|
||||
DB::statement(
|
||||
'COMMENT ON COLUMN deals.subject_code IS '
|
||||
."'Субъект РФ 1..89 из тега поставщика (raw_payload[tag]). NULL = «Вся РФ»/неизвестно. v8.26.'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('deals', 'subject_code')) {
|
||||
Schema::table('deals', fn ($t) => $t->dropColumn('subject_code'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Глобальный тумблер режима экспорта проектов поставщику (v8.26).
|
||||
* 'batch' (default, прод-безопасно) | 'online'. Резолвится SupplierExportMode (План 3).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$exists = DB::table('system_settings')->where('key', 'supplier_export_mode')->exists();
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('system_settings')->insert([
|
||||
'key' => 'supplier_export_mode',
|
||||
'value' => 'batch',
|
||||
'type' => 'string',
|
||||
'description' => 'Режим экспорта проектов поставщику: batch (ночной 18:00) | online (сразу при правке).',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->delete();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Бэкофилл pivot project_supplier_links из legacy supplier_b{1,2,3}_project_id (v8.26).
|
||||
*
|
||||
* Для каждого ненулевого слота → строка pivot (subject_code=NULL: legacy-записи без
|
||||
* субъекта). Идемпотентно — ON CONFLICT DO NOTHING по uq_psl_project_supplier.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['B1' => 'supplier_b1_project_id', 'B2' => 'supplier_b2_project_id', 'B3' => 'supplier_b3_project_id'] as $platform => $col) {
|
||||
DB::statement(
|
||||
'INSERT INTO project_supplier_links (project_id, supplier_project_id, platform, subject_code, created_at) '
|
||||
."SELECT id, {$col}, ?, NULL, NOW() FROM projects WHERE {$col} IS NOT NULL "
|
||||
.'ON CONFLICT (project_id, supplier_project_id) DO NOTHING',
|
||||
[$platform]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Бэкофилл-данные не откатываем точечно (pivot живёт дальше); no-op.
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* deals.subject_code range CHECK 1..89 — defensive parity с supplier_projects.subject_code (v8.26).
|
||||
*
|
||||
* Reviewer-finding (Plan 1 code-quality): supplier_projects.subject_code имеет CHECK 1..89,
|
||||
* deals.subject_code — только COMMENT. Malformed webhook tag → silent garbage в deals →
|
||||
* downstream report-by-region undercounts. NOT VALID + VALIDATE (squawk-safe), idempotent.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_deals_subject_code'
|
||||
) THEN
|
||||
ALTER TABLE deals
|
||||
ADD CONSTRAINT chk_deals_subject_code
|
||||
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89)) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
DB::statement('ALTER TABLE deals VALIDATE CONSTRAINT chk_deals_subject_code');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE deals DROP CONSTRAINT IF EXISTS chk_deals_subject_code');
|
||||
}
|
||||
};
|
||||
+78
-60
@@ -204,12 +204,6 @@ parameters:
|
||||
count: 3
|
||||
path: app/Jobs/ImportLeadsJob.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$array \(array\{string\}\) of array_values is already a list, call has no effect\.$#'
|
||||
identifier: arrayValues.list
|
||||
count: 1
|
||||
path: app/Jobs/Supplier/SyncSupplierProjectsJob.php
|
||||
|
||||
-
|
||||
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
|
||||
identifier: nullsafe.neverNull
|
||||
@@ -252,6 +246,18 @@ parameters:
|
||||
count: 1
|
||||
path: app/Services/Project/ProjectService.php
|
||||
|
||||
-
|
||||
message: '#^Call to function is_array\(\) with array\<string, mixed\> will always evaluate to true\.$#'
|
||||
identifier: function.alreadyNarrowedType
|
||||
count: 1
|
||||
path: app/Services/Supplier/Channel/AjaxProjectChannel.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$array \(array\{string\}\) of array_values is already a list, call has no effect\.$#'
|
||||
identifier: arrayValues.list
|
||||
count: 1
|
||||
path: app/Services/Supplier/SupplierProjectGrouping.php
|
||||
|
||||
-
|
||||
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\BalanceTransactionFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\BalanceTransaction, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\BalanceTransaction\>\:\:definition\(\)$#'
|
||||
identifier: method.childReturnType
|
||||
@@ -318,6 +324,18 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminPricingTiersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Admin/AdminSupplierIntegrationTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -330,6 +348,24 @@ parameters:
|
||||
count: 3
|
||||
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 4
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 2
|
||||
path: tests/Feature/Admin/SupplierManualQueueTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1746,6 +1782,36 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/AutoPauseFlowTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property App\\Services\\Supplier\\PlaywrightBridge\:\:\$lastArgs\.$#'
|
||||
identifier: property.notFound
|
||||
count: 7
|
||||
path: tests/Feature/Supplier/Channel/FormProjectChannelTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Illuminate\\Contracts\\Cache\\Repository\:\:lock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/CsvReconcileJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andThrow\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 3
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#1 \$tier1 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
|
||||
identifier: argument.type
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#2 \$tier2 of class App\\Services\\Supplier\\Channel\\FailoverProjectChannel constructor expects App\\Services\\Supplier\\Channel\\SupplierProjectChannel, Mockery\\MockInterface given\.$#'
|
||||
identifier: argument.type
|
||||
count: 2
|
||||
path: tests/Feature/Supplier/FailoverProjectChannelLiveSmokeTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
@@ -1782,6 +1848,12 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/SupplierSessionRefreshCommandTest.php
|
||||
|
||||
-
|
||||
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:mock\(\)\.$#'
|
||||
identifier: method.notFound
|
||||
count: 1
|
||||
path: tests/Feature/Supplier/SyncSupplierProjectJobTest.php
|
||||
|
||||
-
|
||||
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
|
||||
identifier: property.notFound
|
||||
@@ -1902,62 +1974,8 @@ parameters:
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 1, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 3
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 10, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 6
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 10, workdays\: array\{6, 7\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 30, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 4, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 3
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 5, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
identifier: argument.type
|
||||
count: 1
|
||||
path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php
|
||||
|
||||
-
|
||||
message: '#^Parameter \#4 \$activeLiderraProjects of static method App\\Services\\Supplier\\SupplierQuotaAllocator\:\:allocate\(\) expects Illuminate\\Support\\Collection\<int, stdClass\>, Illuminate\\Support\\Collection\<int, object\{daily_limit\: 7, workdays\: array\{1, 2, 3, 4, 5\}, regions\: array\{\}\}&stdClass\> given\.$#'
|
||||
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,270 @@
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>RT Project Form Fixture — Element UI + Vuetify dialog</title>
|
||||
<style>
|
||||
/* Minimal stubs so Playwright class-based locators work */
|
||||
.el-form-item { margin-bottom: 12px; }
|
||||
.el-form-item__label { display: inline-block; min-width: 140px; }
|
||||
.el-form-item__content { display: inline-block; }
|
||||
.el-input__inner { border: 1px solid #cccccc; padding: 4px 8px; }
|
||||
.el-checkbox { cursor: pointer; margin-right: 8px; }
|
||||
.el-checkbox__input.is-checked .el-checkbox__inner { background: #409eff; }
|
||||
.el-checkbox__inner { display: inline-block; width: 14px; height: 14px; border: 1px solid #cccccc; }
|
||||
.el-switch { cursor: pointer; }
|
||||
.el-switch.is-checked .el-switch__core { background: #409eff; }
|
||||
.el-switch__core { display: inline-block; width: 40px; height: 20px; border-radius: 10px; background: #cccccc; }
|
||||
.el-select-dropdown { position: absolute; background: #ffffff; border: 1px solid #cccccc; z-index: 9999; min-width: 120px; }
|
||||
.el-select-dropdown__item { padding: 6px 12px; cursor: pointer; }
|
||||
.el-select-dropdown__item:hover { background: #f5f7fa; }
|
||||
.el-button { padding: 6px 16px; cursor: pointer; border: 1px solid #cccccc; background: #ffffff; }
|
||||
.el-input-number .el-input__inner { width: 80px; }
|
||||
</style>
|
||||
</head><body>
|
||||
|
||||
<!-- Vuetify dialog wrapper — required by manage-project.js locator ".v-dialog--active button:has-text(...)" -->
|
||||
<div class="v-dialog v-dialog--active v-dialog--persistent" style="padding:16px;">
|
||||
|
||||
<form class="el-form el-form--label-left">
|
||||
|
||||
<!-- 1. Tag -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="tag">Тег</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="tag-fixture">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Источник данных (B1/B2/B3 checkboxes) — label for="srcrt" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="srcrt">Источник данных</label>
|
||||
<div class="el-form-item__content" id="srcrt-container">
|
||||
<label class="el-checkbox is-checked" data-platform="B1">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B1</span>
|
||||
</label>
|
||||
<label class="el-checkbox is-checked" data-platform="B2">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B2</span>
|
||||
</label>
|
||||
<label class="el-checkbox is-checked" data-platform="B3">
|
||||
<span class="el-checkbox__input is-checked">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original" checked>
|
||||
</span>
|
||||
<span class="el-checkbox__label">B3</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. Name — label for="name" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="name">Название проекта</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="name-fixture">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. Type select — label for="type" -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="type">Источники сбора</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-select" id="type-select-container">
|
||||
<!-- readonly input that shows selected value; clicking it opens dropdown popup in body -->
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="type-select-input" readonly
|
||||
value="Сайты" placeholder="Выберите" data-current-value="Сайты">
|
||||
<span class="el-input__suffix"><span class="el-select__caret">▼</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. Slider «Период» — no label-for, no DTO field, leave default -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label">Период</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-slider" aria-valuemin="0" aria-valuemax="24" aria-valuetext="10-18">
|
||||
<span style="font-size:12px;color:#999999">10-18 (default)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. Switch «Включить» — no label-for; identified by .el-switch in form-item -->
|
||||
<div class="el-form-item" id="switch-form-item">
|
||||
<label class="el-form-item__label">Статус</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-switch" id="active-switch">
|
||||
<input type="checkbox" class="el-switch__input" id="active-switch-input">
|
||||
<span class="el-switch__core"></span>
|
||||
<span>Включить</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7. Regions — label for="regions", el-select multiple -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="regions">Регион</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-select el-select--multiple">
|
||||
<input type="text" class="el-input__inner" id="regions-input" placeholder="Выберите регионы">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 8. limit_off — no label-for, no DTO field -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="limit_off">Разделять по проектам</label>
|
||||
<div class="el-form-item__content">
|
||||
<label class="el-checkbox">
|
||||
<span class="el-checkbox__input">
|
||||
<span class="el-checkbox__inner"></span>
|
||||
<input type="checkbox" class="el-checkbox__original">
|
||||
</span>
|
||||
<span class="el-checkbox__label">Да</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 9. Content (uniqueKey / domains) — label for="content", el-tabs -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="content">Список сайтов</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-tabs">
|
||||
<div class="el-tabs__header">
|
||||
<div class="el-tabs__item is-active" data-tab="list">Список</div>
|
||||
<div class="el-tabs__item" data-tab="file">Файл</div>
|
||||
</div>
|
||||
<div class="el-tabs__content">
|
||||
<textarea class="el-textarea__inner" id="content-textarea" rows="4" style="width:100%"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 10. Limit — label for="limit", el-input-number -->
|
||||
<div class="el-form-item">
|
||||
<label class="el-form-item__label" for="limit">Лимит в день</label>
|
||||
<div class="el-form-item__content">
|
||||
<div class="el-input-number">
|
||||
<span class="el-input-number__decrease">-</span>
|
||||
<div class="el-input">
|
||||
<input type="text" class="el-input__inner" id="limit-input" value="10">
|
||||
</div>
|
||||
<span class="el-input-number__increase">+</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form><!-- end .el-form -->
|
||||
|
||||
<!-- Save/Cancel buttons OUTSIDE form, INSIDE .v-dialog--active -->
|
||||
<div style="margin-top:16px;">
|
||||
<button type="button" class="el-button" id="save-btn">Сохранить</button>
|
||||
<button type="button" class="el-button" id="cancel-btn">Отмена</button>
|
||||
</div>
|
||||
|
||||
</div><!-- end .v-dialog--active -->
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ---- Checkbox toggle behaviour ----
|
||||
// Click on .el-checkbox toggles .is-checked on itself and .el-checkbox__input child
|
||||
document.querySelectorAll('#srcrt-container .el-checkbox').forEach(function(cb) {
|
||||
cb.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var isChecked = cb.classList.contains('is-checked');
|
||||
cb.classList.toggle('is-checked', !isChecked);
|
||||
var cbInput = cb.querySelector('.el-checkbox__input');
|
||||
if (cbInput) cbInput.classList.toggle('is-checked', !isChecked);
|
||||
var rawInput = cb.querySelector('input.el-checkbox__original');
|
||||
if (rawInput) rawInput.checked = !isChecked;
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Switch toggle behaviour ----
|
||||
var switchEl = document.getElementById('active-switch');
|
||||
if (switchEl) {
|
||||
switchEl.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var isChecked = switchEl.classList.contains('is-checked');
|
||||
switchEl.classList.toggle('is-checked', !isChecked);
|
||||
var inp = document.getElementById('active-switch-input');
|
||||
if (inp) inp.checked = !isChecked;
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Type select popup ----
|
||||
// When input#type-select-input is clicked, create a dropdown in body
|
||||
var typeInput = document.getElementById('type-select-input');
|
||||
var typeOptions = ['Сайты', 'Звонки', 'СМС', 'Ретро сайты', 'Ретро звонки'];
|
||||
|
||||
function removeDropdown() {
|
||||
var existing = document.querySelector('body > .el-select-dropdown');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
if (typeInput) {
|
||||
typeInput.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
removeDropdown();
|
||||
var dropdown = document.createElement('div');
|
||||
dropdown.className = 'el-select-dropdown el-popper';
|
||||
dropdown.style.position = 'absolute';
|
||||
dropdown.style.left = '20px';
|
||||
dropdown.style.top = '200px';
|
||||
var ul = document.createElement('ul');
|
||||
ul.className = 'el-scrollbar__view el-select-dropdown__list';
|
||||
typeOptions.forEach(function(opt) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'el-select-dropdown__item';
|
||||
li.textContent = opt;
|
||||
li.addEventListener('click', function(e2) {
|
||||
e2.stopPropagation();
|
||||
typeInput.value = opt;
|
||||
typeInput.setAttribute('data-current-value', opt);
|
||||
removeDropdown();
|
||||
});
|
||||
ul.appendChild(li);
|
||||
});
|
||||
dropdown.appendChild(ul);
|
||||
document.body.appendChild(dropdown);
|
||||
});
|
||||
}
|
||||
|
||||
// Close dropdown on outside click
|
||||
document.addEventListener('click', function() {
|
||||
removeDropdown();
|
||||
});
|
||||
|
||||
// ---- Save button: POST to /admin/visit/rt-project-save on the same origin ----
|
||||
// NOTE: NO fetch mock here — the HTTP server (manage-project.test.js) handles
|
||||
// this route and returns {status:"OK",id:"99001"}. Playwright's waitForResponse
|
||||
// intercepts real network requests, not mocked fetch.
|
||||
document.getElementById('save-btn').addEventListener('click', function() {
|
||||
var payload = {
|
||||
tag: document.getElementById('tag-fixture') ? document.getElementById('tag-fixture').value : '',
|
||||
name: document.getElementById('name-fixture') ? document.getElementById('name-fixture').value : '',
|
||||
type: typeInput ? typeInput.getAttribute('data-current-value') : 'Сайты',
|
||||
limit: document.getElementById('limit-input') ? document.getElementById('limit-input').value : '10',
|
||||
};
|
||||
fetch('/admin/visit/rt-project-save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -18,11 +18,35 @@
|
||||
* 4 — invalid input или другая ошибка
|
||||
*
|
||||
* Spec §4.3.
|
||||
*
|
||||
* KNOWN GAPS (Tier-2 MVP, зафиксированы по recon 2026-05-19):
|
||||
* - workdays: поле add-project форм НЕ содержит чекбоксы дней недели (только slider «Период»
|
||||
* часы 0-24). DTO.workdays игнорируется; портал применяет дефолт (все 7 дней).
|
||||
* Для точной настройки workdays используйте Tier-1 (AJAX).
|
||||
* - regions: форма требует имена регионов, DTO несёт int[] id. Mapping id→name не реализован.
|
||||
* Tier-2 всегда передаёт пустой массив регионов (нет фильтрации). Регионы должны быть
|
||||
* настроены вручную или через Tier-1.
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const TIMEOUT_MS = 90_000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Возвращает локатор form-item по значению атрибута for= у label.
|
||||
* Стратегия: .el-form-item:has(.el-form-item__label[for="<attrFor>"])
|
||||
*/
|
||||
function fieldByFor(page, attrFor) {
|
||||
return page.locator(`.el-form-item:has(.el-form-item__label[for="${attrFor}"])`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function login(page, args) {
|
||||
// skipLogin: args.url — статическая фикстура формы (тестовый режим),
|
||||
// открываем её напрямую и не логинимся.
|
||||
@@ -39,98 +63,301 @@ async function login(page, args) {
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// fillForm — Element UI label-for локаторы (recon 2026-05-19)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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();
|
||||
// NOTE: статус active/paused НЕ выставляется через форму. Единственный
|
||||
// .el-switch на форме — это include/exclude регионов («Включить/Исключить»,
|
||||
// recon 2026-05-19 row 6), НЕ статус проекта. Статус задаётся дефолтом
|
||||
// портала (active). dto.active игнорируется в Tier-2; switch не трогаем
|
||||
// (regions skip — см. ниже). Verified live 2026-05-19.
|
||||
|
||||
if (dto.tag) await page.fill('input[name=tag]', dto.tag);
|
||||
// --- 1. Tag ---
|
||||
if (dto.tag !== undefined && dto.tag !== null) {
|
||||
await fieldByFor(page, 'tag').locator('input.el-input__inner').fill(String(dto.tag));
|
||||
}
|
||||
|
||||
// --- 2. Platforms (srcrt) — B1/B2/B3 checkboxes ---
|
||||
// Initial: все три checked. Нужно включить только те, что в dto.platforms, остальные выключить.
|
||||
const platformContainer = fieldByFor(page, 'srcrt');
|
||||
for (const p of ['B1', 'B2', 'B3']) {
|
||||
const wanted = (dto.platforms || []).includes(p);
|
||||
const sel = `input[name="platform[]"][value="${p}"]`;
|
||||
const checked = await page.locator(sel).isChecked();
|
||||
if (checked !== wanted) await page.locator(sel).click();
|
||||
// Identification — по `.el-checkbox__label` textContent (per recon-doc
|
||||
// 2026-05-19-rt-project-form-locators.md row 2: реальный портал НЕ имеет
|
||||
// `data-platform`-атрибута, inputs без `name`). Whitespace-tolerant `^\s*B1\s*$`.
|
||||
const cb = platformContainer.locator('.el-checkbox').filter({
|
||||
has: page.locator('.el-checkbox__label', { hasText: new RegExp(`^\\s*${p}\\s*$`) }),
|
||||
}).first();
|
||||
const cbClass = await cb.getAttribute('class').catch(() => '');
|
||||
const isChecked = (cbClass || '').includes('is-checked');
|
||||
if (!!isChecked !== wanted) {
|
||||
await cb.click();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
// --- 3. Name (label for="name") ---
|
||||
// В реальном портале dto.name заполняется в поле «Название проекта»,
|
||||
// а dto.uniqueKey (список сайтов/номеров) — в textarea «content».
|
||||
// manage-project.js получает dto.name напрямую.
|
||||
if (dto.name !== undefined) {
|
||||
await fieldByFor(page, 'name').locator('input.el-input__inner').fill(String(dto.name));
|
||||
}
|
||||
|
||||
if (dto.domains && dto.domains.length) {
|
||||
await page.fill('textarea[name=domains]', dto.domains.join('\n'));
|
||||
// --- 4. Type select (label for="type") ---
|
||||
// El-select readonly input. Клик открывает popup в body > .el-select-dropdown.
|
||||
const signalTypeMap = { site: 'Сайты', call: 'Звонки', sms: 'СМС' };
|
||||
const signalLabel = signalTypeMap[dto.signal_type];
|
||||
if (!signalLabel) {
|
||||
throw new Error(
|
||||
`Unsupported signal_type "${dto.signal_type}". Supported: site, call, sms. ` +
|
||||
'"Ретро сайты" / "Ретро звонки" are not supported in Tier-2 form channel.',
|
||||
);
|
||||
}
|
||||
// Тип меняем ТОЛЬКО если текущее значение ≠ нужное. Смена типа ремоунтит
|
||||
// content tab-pane (Сайты/Звонки/СМС — разные поля сбора) → если сразу
|
||||
// после type-select заполнять content, fill попадёт в detached textarea
|
||||
// (Vue ещё не закончил ре-рендер) → rt-project-save уходит с пустым
|
||||
// `content` → портал «Введите домены». Verified live 2026-05-19.
|
||||
const typeInput = fieldByFor(page, 'type').locator('.el-select input.el-input__inner');
|
||||
const currentType = (await typeInput.inputValue().catch(() => '')).trim();
|
||||
if (currentType !== signalLabel) {
|
||||
await typeInput.click();
|
||||
// Dropdown рендерится снаружи формы в body — ждём его появления
|
||||
const dropdownOption = page.locator('.el-select-dropdown__item', {
|
||||
hasText: new RegExp(`^${signalLabel}$`),
|
||||
});
|
||||
await dropdownOption.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
|
||||
await dropdownOption.click();
|
||||
// Ждём, пока Vue завершит ре-рендер content tab-pane после смены типа.
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
await page.fill('input[name=limit]', String(dto.limit));
|
||||
// --- 7. Regions (label for="regions") — SKIP, gap зафиксирован в JSDoc ---
|
||||
// DTO несёт int[] id; форма требует имена. Mapping не реализован для MVP.
|
||||
if (dto.regions && dto.regions.length > 0) {
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
warning: 'regions skipped in Tier-2 form channel: DTO carries int[] ids but form requires region names. ' +
|
||||
'Region filtering will not be applied. Configure regions manually or use Tier-1.',
|
||||
regions_received: dto.regions,
|
||||
}) + '\n',
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
// --- 9. Content — список сайтов/номеров/отправителей (label for="content") ---
|
||||
// Вкладка «Список» (default active). dto.domains — массив строк или dto.uniqueKey — строка.
|
||||
const contentLines = dto.domains && dto.domains.length
|
||||
? dto.domains.join('\n')
|
||||
: dto.uniqueKey
|
||||
? String(dto.uniqueKey)
|
||||
: null;
|
||||
if (contentLines) {
|
||||
const contentField = fieldByFor(page, 'content');
|
||||
// Вкладка «Список» — default active. Кликаем ТОЛЬКО если она НЕ активна:
|
||||
// клик по вкладке Element UI ремоунтит tab-pane → textarea детачится,
|
||||
// и последующий .fill() гонится с ре-рендером (домены теряются →
|
||||
// rt-project-save уходит с пустым `content` → портал «Введите домены»).
|
||||
// Verified live 2026-05-19: re-click активной вкладки ломал save.
|
||||
const listTab = contentField.locator('.el-tabs__item', { hasText: 'Список' }).first();
|
||||
if ((await listTab.count()) > 0) {
|
||||
const tabClass = (await listTab.getAttribute('class')) || '';
|
||||
if (!tabClass.includes('is-active')) {
|
||||
await listTab.click();
|
||||
await contentField.locator('textarea.el-textarea__inner')
|
||||
.waitFor({ state: 'visible', timeout: TIMEOUT_MS });
|
||||
}
|
||||
}
|
||||
const contentTa = contentField.locator('textarea.el-textarea__inner');
|
||||
await contentTa.fill(contentLines);
|
||||
// Defensive: убедиться, что значение действительно осело в textarea
|
||||
// (если поле детачнулось ре-рендером — fill уйдёт в пустоту).
|
||||
const filledValue = await contentTa.inputValue();
|
||||
if (filledValue.trim() === '') {
|
||||
throw new Error(
|
||||
'Content textarea empty after fill — likely tab/type re-render race; domains lost',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 10. Limit (label for="limit") ---
|
||||
if (dto.limit !== undefined) {
|
||||
await fieldByFor(page, 'limit').locator('input.el-input__inner').fill(String(dto.limit));
|
||||
}
|
||||
|
||||
// NOTE: workdays — gap зафиксирован в JSDoc. Форма add-project не содержит
|
||||
// чекбоксы дней недели. dto.workdays игнорируется.
|
||||
if (dto.workdays && dto.workdays.length !== 7) {
|
||||
process.stderr.write(
|
||||
JSON.stringify({
|
||||
warning: 'workdays ignored in Tier-2 form channel: add-project form has no workdays field. ' +
|
||||
'Portal will apply default (all 7 days). Configure workdays manually or use Tier-1.',
|
||||
workdays_received: dto.workdays,
|
||||
}) + '\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function createOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
await page.click('button:has-text("Добавить проект")');
|
||||
await page.waitForSelector('#add-project-modal', { state: 'visible', timeout: TIMEOUT_MS });
|
||||
await page.goto(
|
||||
args.url.replace(/\/$/, '') + '/admin/visit/rt',
|
||||
{ waitUntil: 'load', timeout: TIMEOUT_MS },
|
||||
);
|
||||
// Кнопка «Добавить проект» — recon: label [title="Добавить проект"]
|
||||
await page.locator('button:has-text("Добавить проект")').click();
|
||||
// Ждём появления формы — label for="name" внутри .el-form
|
||||
await page.locator('.el-form-item__label[for="name"]').waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
await fillForm(page, args.dto);
|
||||
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');
|
||||
// Кликаем «Сохранить» + перехватываем ответ rt-project-save
|
||||
const [saveResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
|
||||
{ timeout: TIMEOUT_MS },
|
||||
),
|
||||
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
|
||||
]);
|
||||
|
||||
const body = await saveResponse.json();
|
||||
if (body.status !== 'OK') {
|
||||
// DIAG: дамп фактически отправленного тела — для расследования "Введите домены"
|
||||
const sentBody = saveResponse.request().postData();
|
||||
process.stderr.write(JSON.stringify({ diag_sent_body: sentBody }) + '\n');
|
||||
throw new Error(`Portal rejected save: ${body.message || 'unknown error'}`);
|
||||
}
|
||||
const externalId = String(body.id ?? '');
|
||||
if (!externalId) {
|
||||
throw new Error('Portal returned status=OK but empty id');
|
||||
}
|
||||
|
||||
return { external_id: externalId };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function updateOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
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 });
|
||||
// Найти строку таблицы по externalId и кликнуть кнопку редактирования.
|
||||
// Реальная таблица портала — Vuetify data-table; строки по data-id или текстовому совпадению.
|
||||
// Стратегия 1: строка с атрибутом data-id
|
||||
const rowLocator = page.locator(`tr[data-id="${args.externalId}"], [data-id="${args.externalId}"]`);
|
||||
const rowCount = await rowLocator.count();
|
||||
if (rowCount > 0) {
|
||||
await rowLocator.first().locator('button').first().click();
|
||||
} else {
|
||||
// Стратегия 2: найти строку содержащую текст externalId и кликнуть edit-кнопку
|
||||
await page.locator(`tr:has-text("${args.externalId}")`).first().locator('button').first().click();
|
||||
}
|
||||
|
||||
// Дождаться формы
|
||||
await page.locator('.el-form-item__label[for="name"]').waitFor({
|
||||
state: 'visible',
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
|
||||
await fillForm(page, args.dto);
|
||||
await page.click('#save-btn');
|
||||
await page.waitForSelector('#add-project-modal', { state: 'hidden', timeout: TIMEOUT_MS });
|
||||
|
||||
// Перехватываем ответ rt-project-save при update (тот же endpoint)
|
||||
const [saveResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(r) => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST',
|
||||
{ timeout: TIMEOUT_MS },
|
||||
),
|
||||
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
|
||||
]);
|
||||
|
||||
const body = await saveResponse.json();
|
||||
if (body.status !== 'OK') {
|
||||
throw new Error(`Portal rejected update: ${body.message || 'unknown error'}`);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listOp
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function listOp(page, args) {
|
||||
await login(page, args);
|
||||
|
||||
if (!args.skipLogin) {
|
||||
await page.goto(args.url.replace(/\/$/, '') + '/admin/visit/rt', { waitUntil: 'load', timeout: TIMEOUT_MS });
|
||||
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,
|
||||
// Стратегия 1: Vuex state (если доступен)
|
||||
const projects = await page.evaluate(() => {
|
||||
try {
|
||||
if (window.app && window.app.$store && window.app.$store.state) {
|
||||
const st = window.app.$store.state;
|
||||
const list = st.projects || st.rtProjects || st.visitProjects || null;
|
||||
if (Array.isArray(list)) {
|
||||
return list.map((p) => ({
|
||||
id: parseInt(p.id, 10),
|
||||
name: p.name || p.title || null,
|
||||
platform: p.platform || null,
|
||||
signal_type: p.type || p.signal_type || null,
|
||||
unique_key: p.content || p.unique_key || null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (_) { /* Vuex недоступен */ }
|
||||
return null;
|
||||
});
|
||||
|
||||
if (projects !== null) {
|
||||
return { projects };
|
||||
}
|
||||
|
||||
// Стратегия 2: DOM-скрейп таблицы
|
||||
// Реальная таблица портала: строки tr с data-id или стандартные td
|
||||
const rows = await page.locator('table tbody tr[data-id], .v-data-table tbody tr[data-id]').evaluateAll(
|
||||
(nodes) => nodes.map((n) => ({
|
||||
id: parseInt(n.dataset.id || '0', 10),
|
||||
name: n.querySelector('td:nth-child(2)')
|
||||
? n.querySelector('td:nth-child(2)').textContent.trim()
|
||||
: null,
|
||||
})),
|
||||
);
|
||||
|
||||
return { projects: rows };
|
||||
if (rows.length > 0) {
|
||||
return { projects: rows };
|
||||
}
|
||||
|
||||
// Стратегия 3: фикстура / пустая страница — возвращаем пустой массив
|
||||
return { projects: [] };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function run(args) {
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
@@ -148,8 +375,14 @@ async function run(args) {
|
||||
} 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);
|
||||
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();
|
||||
@@ -160,8 +393,10 @@ 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); }
|
||||
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);
|
||||
|
||||
@@ -1,65 +1,137 @@
|
||||
/**
|
||||
* Фикстурный тест manage-project.js — против локального HTML, без живого портала.
|
||||
* Фикстурный тест manage-project.js — против локального HTTP-сервера с Element UI фикстурой.
|
||||
*
|
||||
* Runner: встроенный node:test (проект не использует @playwright/test —
|
||||
* в app/playwright только playwright core). Запуск: `node --test manage-project.test.js`.
|
||||
* Почему HTTP, не file://: manage-project.js перехватывает ответ page.waitForResponse()
|
||||
* с URL endsWith('/admin/visit/rt-project-save'). Браузер не шлёт network-запросы при
|
||||
* file://-origin fetch из-за CORS/same-origin ограничений в Chromium.
|
||||
*
|
||||
* Runner: встроенный node:test (Node 18+). Запуск: `node --test manage-project.test.js`.
|
||||
*/
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const { execFile } = require('node:child_process');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const SCRIPT = path.resolve(__dirname, 'manage-project.js');
|
||||
const FIXTURE_URL = 'file://' + path.resolve(__dirname, '../tests/fixtures/supplier-portal/rt-add-project-form.html');
|
||||
const FIXTURE_PATH = path.resolve(__dirname, 'fixtures', 'rt-form-element-ui.html');
|
||||
|
||||
/** Запустить ephemeral HTTP-сервер, отдающий фикстуру и обрабатывающий mock-эндпоинты. */
|
||||
function startFixtureServer() {
|
||||
return new Promise((resolve) => {
|
||||
const html = fs.readFileSync(FIXTURE_PATH, 'utf8');
|
||||
const server = http.createServer((req, res) => {
|
||||
// Mock rt-project-save — Playwright перехватывает реальный сетевой запрос
|
||||
if (req.url && req.url.includes('rt-project-save') && req.method === 'POST') {
|
||||
// Consume request body (important — don't hang connection)
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; });
|
||||
req.on('end', () => {
|
||||
const payload = JSON.stringify({ status: 'OK', message: '', result: null, id: '99001' });
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(payload);
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Default: serve fixture HTML
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(html);
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
/** Спавнить manage-project.js, подать JSON на stdin, вернуть {code, stdout, stderr}. */
|
||||
function runScript(input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = execFile('node', [SCRIPT], { timeout: 60000 }, (err, stdout, stderr) => {
|
||||
if (err && err.code !== undefined && typeof err.code !== 'number') {
|
||||
return reject(err);
|
||||
}
|
||||
resolve({ stdout: stdout.toString(), stderr: stderr.toString() });
|
||||
});
|
||||
const child = execFile(
|
||||
'node',
|
||||
[SCRIPT],
|
||||
{ timeout: 90_000 },
|
||||
(err, stdout, stderr) => {
|
||||
if (err && err.killed) return reject(new Error('Process killed / timed out'));
|
||||
// err.code — exit code; treat as expected (tests assert on code)
|
||||
resolve({
|
||||
code: err ? err.code : 0,
|
||||
stdout: stdout.toString(),
|
||||
stderr: stderr.toString(),
|
||||
});
|
||||
},
|
||||
);
|
||||
child.stdin.write(JSON.stringify(input));
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
test('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,
|
||||
},
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 1 — createProject через Element UI фикстуру → external_id из mock-response
|
||||
// ---------------------------------------------------------------------------
|
||||
test('createProject fills Element UI form and returns external_id from intercept response', async () => {
|
||||
const server = await startFixtureServer();
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
|
||||
const 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');
|
||||
const result = await runScript({
|
||||
operation: 'create',
|
||||
url,
|
||||
skipLogin: true,
|
||||
dto: {
|
||||
tag: '_lidpotok',
|
||||
name: 'example.com',
|
||||
platforms: ['B1'],
|
||||
signal_type: 'site',
|
||||
limit: 5,
|
||||
workdays: [1, 2, 3, 4, 5],
|
||||
domains: ['example.com'],
|
||||
region_mode: 'include',
|
||||
regions: [],
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0, got ${result.code}. stderr: ${result.stderr}`);
|
||||
|
||||
let out;
|
||||
try {
|
||||
out = JSON.parse(result.stdout);
|
||||
} catch (e) {
|
||||
assert.fail(`stdout is not valid JSON: ${result.stdout}\nstderr: ${result.stderr}`);
|
||||
}
|
||||
assert.strictEqual(out.external_id, '99001', `expected external_id "99001", got ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('listProjects returns array', async () => {
|
||||
const result = await runScript({
|
||||
operation: 'list',
|
||||
login: 'fixture-noop',
|
||||
password: 'fixture-noop',
|
||||
url: FIXTURE_URL,
|
||||
skipLogin: true,
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 2 — listProjects в skipLogin-режиме возвращает массив projects
|
||||
// ---------------------------------------------------------------------------
|
||||
test('listProjects returns array (skipLogin mode, fixture page)', async () => {
|
||||
const server = await startFixtureServer();
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
|
||||
const out = JSON.parse(result.stdout);
|
||||
assert.ok(Array.isArray(out.projects), 'projects should be an array');
|
||||
const result = await runScript({
|
||||
operation: 'list',
|
||||
url,
|
||||
skipLogin: true,
|
||||
});
|
||||
|
||||
// listOp в skipLogin-режиме не навигирует на /admin/visit/rt — просто открывает url.
|
||||
// Фикстура не содержит Vuex и таблицы с проектами → возвращает {projects: []}.
|
||||
assert.strictEqual(result.code, 0, `Expected exit 0. stderr: ${result.stderr}`);
|
||||
|
||||
let out;
|
||||
try {
|
||||
out = JSON.parse(result.stdout);
|
||||
} catch (e) {
|
||||
assert.fail(`stdout is not valid JSON: ${result.stdout}`);
|
||||
}
|
||||
assert.ok(Array.isArray(out.projects), `expected projects array, got: ${JSON.stringify(out)}`);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -34,10 +34,29 @@ async function refresh(args) {
|
||||
|
||||
await page.fill(loginSelector, args.login);
|
||||
await page.fill(passwordSelector, args.password);
|
||||
await Promise.all([
|
||||
page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }),
|
||||
page.click(submitSelector),
|
||||
]);
|
||||
|
||||
// Сабмит + ОЖИДАНИЕ пост-логин перехода.
|
||||
// Старый Promise.all([waitForLoadState('networkidle'), click]) — гонка:
|
||||
// логин-страница уже в состоянии networkidle, поэтому waitForLoadState
|
||||
// резолвился мгновенно (ДО редиректа), и скрипт хватал PHPSESSID
|
||||
// неаутентифицированной логин-страницы. Ждём, пока логин-форма исчезнет
|
||||
// из DOM — waitForFunction опрашивает и переживает навигацию.
|
||||
await page.click(submitSelector);
|
||||
await page
|
||||
.waitForFunction(
|
||||
(sel) => !document.querySelector(sel),
|
||||
loginSelector,
|
||||
{ timeout: TIMEOUT_MS },
|
||||
)
|
||||
.catch(() => { /* форма осталась — логин отклонён, ловится guard'ом ниже */ });
|
||||
await page.waitForLoadState('networkidle', { timeout: TIMEOUT_MS }).catch(() => {});
|
||||
|
||||
// Verify: логин-форма всё ещё на странице → вход НЕ удался. Не возвращаем
|
||||
// мусорную (неаутентифицированную) сессию как «успех» (exit 0).
|
||||
if ((await page.locator(loginSelector).count()) > 0) {
|
||||
process.stderr.write(JSON.stringify({ error: 'login rejected: still on login page after submit' }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let csrf = null;
|
||||
try {
|
||||
|
||||
@@ -10,14 +10,18 @@ use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Mockery as M;
|
||||
use Random\Engine\Mt19937;
|
||||
use Random\Randomizer;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class);
|
||||
@@ -37,9 +41,13 @@ function runRouteJob(int $supplierLeadId): void
|
||||
app(DuplicateDetector::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
// `linkProjectToSupplier` helper now lives in tests/Pest.php — single source.
|
||||
|
||||
it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', function (): void {
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
@@ -61,6 +69,7 @@ it('routes 1 lead to N tenants — creates N deal copies (sharing-model)', funct
|
||||
'delivered_today' => 0,
|
||||
'delivered_in_month' => 0,
|
||||
]));
|
||||
linkProjectToSupplier($projects->last(), $supplier);
|
||||
}
|
||||
|
||||
$vid = 432176649;
|
||||
@@ -108,13 +117,14 @@ it('decrements balance_leads for each tenant by 1', function (): void {
|
||||
'unique_key' => 'test.ru',
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'test.ru',
|
||||
'is_active' => true,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
$vid = 99;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
@@ -145,6 +155,7 @@ it('marks duplicate via DuplicateDetector — no charge, no counter increment',
|
||||
'is_active' => true,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
$master = Deal::create([
|
||||
@@ -229,6 +240,7 @@ it('handles mixed routing: 3 projects, 1 with pre-existing master (dup), 2 clean
|
||||
'delivered_today' => 0,
|
||||
'delivered_in_month' => 0,
|
||||
]));
|
||||
linkProjectToSupplier($projects->last(), $supplier);
|
||||
}
|
||||
|
||||
// Tenant #0 имеет master deal с тем же phone в окне 24 ч — будет дубль.
|
||||
@@ -299,6 +311,7 @@ it('idempotent on retry — second handle() returns early, no ghost duplicate de
|
||||
'is_active' => true,
|
||||
'delivered_today' => 0,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
$vid = 7777;
|
||||
$lead = SupplierLead::factory()->create([
|
||||
@@ -367,6 +380,7 @@ it('handles partial failure: one project throws, others continue routing', funct
|
||||
'is_active' => true,
|
||||
'delivered_today' => 0,
|
||||
]));
|
||||
linkProjectToSupplier($projects->last(), $supplier);
|
||||
}
|
||||
|
||||
// Soft-delete tenant #1 — Tenant::firstOrFail() в createDealCopyForProject упадёт.
|
||||
@@ -403,13 +417,14 @@ it('routes B1 lead whose project name embeds a domain in free text (carmoney/car
|
||||
'unique_key' => $domain,
|
||||
]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => $domain,
|
||||
'is_active' => true,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
$vid = random_int(100000, 999999);
|
||||
$lead = SupplierLead::factory()->create([
|
||||
@@ -509,3 +524,48 @@ it('rejects deal copy if delivered_today >= limit at lock time (Plan 2.5 fix #2
|
||||
DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'");
|
||||
expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('caps deal creation at 3 recipients and tags deal with subject from payload', function (): void {
|
||||
// seeded distributor — детерминизм
|
||||
app()->bind(LeadDistributor::class, fn () => new LeadDistributor(
|
||||
new Randomizer(new Mt19937(7))
|
||||
));
|
||||
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
// 5 eligible клиентов, привязанных к sp через pivot, с балансом и лимитом
|
||||
foreach (range(1, 5) as $i) {
|
||||
$t = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$p = Project::factory()->create([
|
||||
'tenant_id' => $t->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($p, $sp);
|
||||
}
|
||||
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'phone' => '79991234567',
|
||||
'vid' => 555111,
|
||||
'raw_payload' => ['project' => 'B1_cap.ru', 'tag' => 'Москва', 'vid' => 555111],
|
||||
'processed_at' => null,
|
||||
'supplier_project_id' => null,
|
||||
'platform' => 'B1',
|
||||
]);
|
||||
|
||||
(new RouteSupplierLeadJob($lead->id))->handle(
|
||||
app(LeadRouter::class),
|
||||
app(SupplierProjectResolver::class),
|
||||
app(DuplicateDetector::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
|
||||
$deals = Deal::query()->where('source_crm_id', 555111)->get();
|
||||
expect($deals)->toHaveCount(3)
|
||||
->and($deals->pluck('subject_code')->unique()->all())->toBe([82]);
|
||||
});
|
||||
|
||||
@@ -19,63 +19,74 @@ beforeEach(function (): void {
|
||||
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
|
||||
});
|
||||
|
||||
it('returns matching active projects for B1 site supplier_project (sharing across tenants)', function (): void {
|
||||
$supplier = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'vashinvestor.ru',
|
||||
// `linkProjectToSupplier` helper now lives in tests/Pest.php — single source.
|
||||
|
||||
it('returns project linked via pivot to the supplier_project', function (): void {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($project, $sp);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(1)
|
||||
->and($matched->first()->id)->toBe($project->id);
|
||||
});
|
||||
|
||||
it('excludes project NOT linked to this supplier_project', function (): void {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r2.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]); // не линкуем
|
||||
|
||||
expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('excludes inactive project, project at limit, and zero-balance tenant', function (): void {
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r3.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$tenant1 = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$tenant2 = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$t1 = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$inactive = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => false, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($inactive, $sp);
|
||||
|
||||
$project1 = Project::factory()->create([
|
||||
'tenant_id' => $tenant1->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'vashinvestor.ru',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
$atLimit = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => true, 'daily_limit_target' => 5, 'delivered_today' => 5, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($atLimit, $sp);
|
||||
|
||||
$project2 = Project::factory()->create([
|
||||
'tenant_id' => $tenant2->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'vashinvestor.ru',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
$t0 = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => 0]);
|
||||
$broke = Project::factory()->create(['tenant_id' => $t0->id, 'is_active' => true, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($broke, $sp);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
$matched = $router->matchEligibleProjects($supplier, '79991234567');
|
||||
|
||||
expect($matched)->toHaveCount(2);
|
||||
expect($matched->pluck('id')->all())->toEqualCanonicalizing([$project1->id, $project2->id]);
|
||||
expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('skips paused project (is_active=false)', function (): void {
|
||||
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'example.com',
|
||||
'is_active' => false,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
|
||||
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('skips project where today is not in delivery_days_mask', function (): void {
|
||||
@@ -87,44 +98,43 @@ it('skips project where today is not in delivery_days_mask', function (): void {
|
||||
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'example.com',
|
||||
'is_active' => true,
|
||||
'delivery_days_mask' => $maskWithoutToday,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
|
||||
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('skips project where delivered_today >= effective_daily_limit_today', function (): void {
|
||||
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'example.com',
|
||||
'is_active' => true,
|
||||
'effective_daily_limit_today' => 5,
|
||||
'delivered_today' => 5,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
|
||||
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('falls back to daily_limit_target when effective_daily_limit_today is null', function (): void {
|
||||
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'example.com',
|
||||
'is_active' => true,
|
||||
@@ -132,28 +142,10 @@ it('falls back to daily_limit_target when effective_daily_limit_today is null',
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 5,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('skips project where region_mode=include and region_mask does not include phone district', function (): void {
|
||||
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
|
||||
$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' => 'example.com',
|
||||
'is_active' => true,
|
||||
'region_mask' => 1, // только Центральный округ
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
// 78121234567 = СПб (Северо-Западный, бит 2)
|
||||
expect($router->matchEligibleProjects($supplier, '78121234567'))->toHaveCount(0);
|
||||
expect($router->matchEligibleProjects($supplier))->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('skips project where tenant has zero in BOTH balance_leads AND balance_rub (Plan 4 dual-balance)', function (): void {
|
||||
@@ -162,16 +154,16 @@ it('skips project where tenant has zero in BOTH balance_leads AND balance_rub (P
|
||||
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '0.00']);
|
||||
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'example.com',
|
||||
'is_active' => true,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0);
|
||||
expect($router->matchEligibleProjects($supplier))->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('includes project when balance_leads=0 BUT balance_rub > 0 (Plan 4 dual-balance rub-only tenant)', function (): void {
|
||||
@@ -182,56 +174,37 @@ it('includes project when balance_leads=0 BUT balance_rub > 0 (Plan 4 dual-balan
|
||||
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'example.com',
|
||||
'is_active' => true,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
$eligible = $router->matchEligibleProjects($supplier, '79991234567');
|
||||
$eligible = $router->matchEligibleProjects($supplier);
|
||||
expect($eligible)->toHaveCount(1);
|
||||
expect($eligible->first()->id)->toBe($project->id);
|
||||
});
|
||||
|
||||
it('routes through correct FK based on platform (B2 → supplier_b2_project_id)', function (): void {
|
||||
$supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => null,
|
||||
'supplier_b2_project_id' => $supplier->id,
|
||||
'supplier_b3_project_id' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'example.com',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('orders results by created_at ASC (deterministic, spec §6 step 4)', function (): void {
|
||||
$supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']);
|
||||
|
||||
$projectsCreated = collect();
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$projectsCreated->push(
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'example.com',
|
||||
'is_active' => true,
|
||||
'created_at' => now()->subDays(3 - $i),
|
||||
])
|
||||
);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'example.com',
|
||||
'is_active' => true,
|
||||
'created_at' => now()->subDays(3 - $i),
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplier);
|
||||
$projectsCreated->push($project);
|
||||
}
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
$matched = $router->matchEligibleProjects($supplier, '79991234567');
|
||||
$matched = $router->matchEligibleProjects($supplier);
|
||||
|
||||
expect($matched->pluck('id')->all())->toBe($projectsCreated->pluck('id')->all());
|
||||
});
|
||||
|
||||
@@ -11,8 +11,10 @@ use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
@@ -47,6 +49,7 @@ function makeFlowWithBalance(array $balance): array
|
||||
'effective_daily_limit_today' => 10, 'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127, 'region_mask' => 255,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplierProject);
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'vid' => random_int(100_000_000, 999_999_999),
|
||||
'phone' => '79991234567',
|
||||
@@ -66,6 +69,8 @@ function runJob(int $leadId): void
|
||||
app(DuplicateDetector::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,12 +156,14 @@ it('sharing-flow isolation: tenant A on zero paused, tenant B with balance recei
|
||||
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
||||
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
||||
]);
|
||||
linkProjectToSupplier($projectA, $supplierProject);
|
||||
$projectB = Project::factory()->create([
|
||||
'tenant_id' => $tenantB->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com',
|
||||
'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'effective_daily_limit_today' => 10,
|
||||
'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255,
|
||||
]);
|
||||
linkProjectToSupplier($projectB, $supplierProject);
|
||||
$lead = SupplierLead::factory()->create([
|
||||
'vid' => random_int(100_000_000, 999_999_999),
|
||||
'phone' => '79991234567',
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('backfills pivot rows from legacy supplier_b{1,2,3}_project_id slots', function (): void {
|
||||
$sp1 = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bf.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$sp3 = SupplierProject::query()->create([
|
||||
'platform' => 'B3', 'signal_type' => 'site', 'unique_key' => 'bf.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'supplier_b1_project_id' => $sp1->id,
|
||||
'supplier_b3_project_id' => $sp3->id,
|
||||
]);
|
||||
|
||||
// Симулируем «до бэкофилла»: pivot пуст.
|
||||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||||
|
||||
// Запуск логики бэкофилла повторно (миграция идемпотентна).
|
||||
require_once base_path('database/migrations/2026_05_20_104000_backfill_project_supplier_links.php');
|
||||
(include base_path('database/migrations/2026_05_20_104000_backfill_project_supplier_links.php'))->up();
|
||||
|
||||
$rows = DB::table('project_supplier_links')->where('project_id', $project->id)->get();
|
||||
expect($rows)->toHaveCount(2)
|
||||
->and($rows->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B3']);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Deal;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('deals has nullable subject_code column', function (): void {
|
||||
expect(Schema::hasColumn('deals', 'subject_code'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects subject_code out of 1..89 range', function (): void {
|
||||
expect(fn () => Deal::factory()->create(['subject_code' => 90]))
|
||||
->toThrow(QueryException::class);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Supplier\SupplierClientException;
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Mail\SupplierCriticalAlertMail;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierManualSyncQueue;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\Exceptions\TierEscalatedException;
|
||||
use App\Services\Supplier\Channel\FailoverProjectChannel;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('Tier-1 fail + Tier-2 fail → Tier-3 escalation creates manual queue row + queues alert mail', function (): void {
|
||||
Mail::fake();
|
||||
config(['services.supplier.alert_email' => 'ops@liderra.local']);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$tier1 = mock(SupplierProjectChannel::class);
|
||||
$tier1->shouldReceive('listProjects')->andReturn([]); // dedup-сверка: нет совпадений
|
||||
$tier1->shouldReceive('createProject')->andThrow(new SupplierClientException('Tier-1 mock fail'));
|
||||
|
||||
$tier2 = mock(SupplierProjectChannel::class);
|
||||
$tier2->shouldReceive('createProject')->andThrow(new RuntimeException('Tier-2 manage-project.js selector break'));
|
||||
|
||||
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'failover-smoke.example',
|
||||
limit: 1, workdays: [1, 2, 3, 4, 5], regions: [], regionsReverse: false, status: 'active',
|
||||
);
|
||||
|
||||
expect(fn () => $channel->createProjectForLiderra($project, $dto))
|
||||
->toThrow(TierEscalatedException::class);
|
||||
|
||||
expect(SupplierManualSyncQueue::where('project_id', $project->id)->count())->toBe(1);
|
||||
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class, fn ($m) => $m->alertType === 'manual_required');
|
||||
});
|
||||
|
||||
test('Tier-1 transient fail (portal unreachable) bypasses Tier-2 and goes straight to Tier-3', function (): void {
|
||||
Mail::fake();
|
||||
config(['services.supplier.alert_email' => 'ops@liderra.local']);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->for($tenant)->create();
|
||||
|
||||
$tier1 = mock(SupplierProjectChannel::class);
|
||||
$tier1->shouldReceive('listProjects')->andReturn([]);
|
||||
$tier1->shouldReceive('createProject')->andThrow(new SupplierTransientException('Connection refused'));
|
||||
|
||||
$tier2 = mock(SupplierProjectChannel::class);
|
||||
$tier2->shouldNotReceive('createProject'); // КЛЮЧЕВОЕ — transient НЕ должен попасть в tier-2
|
||||
|
||||
$channel = new FailoverProjectChannel($tier1, $tier2, app(Mailer::class));
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'transient-smoke.example',
|
||||
limit: 1, workdays: [1, 2, 3, 4, 5], regions: [], regionsReverse: false, status: 'active',
|
||||
);
|
||||
|
||||
expect(fn () => $channel->createProjectForLiderra($project, $dto))
|
||||
->toThrow(TierEscalatedException::class);
|
||||
|
||||
$row = SupplierManualSyncQueue::where('project_id', $project->id)->first();
|
||||
expect($row->failure_reason)->toBe('portal_unreachable');
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('creates pivot row linking project to supplier_project', function (): void {
|
||||
$project = Project::factory()->create();
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'link.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => 'B1',
|
||||
'subject_code' => 82,
|
||||
]);
|
||||
|
||||
expect(DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)->where('supplier_project_id', $sp->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('cascades pivot deletion when supplier_project is deleted', function (): void {
|
||||
$project = Project::factory()->create();
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'cascade.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $sp->id, 'platform' => 'B2', 'subject_code' => 82,
|
||||
]);
|
||||
|
||||
$sp->delete();
|
||||
|
||||
expect(DB::table('project_supplier_links')->where('supplier_project_id', $sp->id)->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('links project to supplier projects via belongsToMany pivot', function (): void {
|
||||
$project = Project::factory()->create();
|
||||
$sp1 = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'rel.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$sp2 = SupplierProject::query()->create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'rel.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$project->supplierProjects()->attach([
|
||||
$sp1->id => ['platform' => 'B1', 'subject_code' => 82],
|
||||
$sp2->id => ['platform' => 'B2', 'subject_code' => 82],
|
||||
]);
|
||||
|
||||
expect($project->supplierProjects()->count())->toBe(2)
|
||||
// @phpstan-ignore-next-line argument.type — qualified 'projects.id' (belongsToMany disambiguator)
|
||||
->and($sp1->projects()->pluck('projects.id')->all())->toContain($project->id)
|
||||
// @phpstan-ignore-next-line property.notFound — withPivot adds dynamic 'pivot' accessor
|
||||
->and($project->supplierProjects->first()->pivot->platform)->not->toBeNull();
|
||||
});
|
||||
@@ -13,8 +13,10 @@ use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Billing\LedgerService;
|
||||
use App\Services\DuplicateDetector;
|
||||
use App\Services\LeadDistributor;
|
||||
use App\Services\LeadRouter;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Services\SupplierProjects\SupplierProjectResolver;
|
||||
use Database\Seeders\PricingTierSeeder;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
@@ -66,6 +68,7 @@ function prepareSharingFlow(int $tenantsCount, array $balances): array
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
linkProjectToSupplier($project, $supplierProject);
|
||||
$tenants[] = $tenant;
|
||||
$projects[] = $project;
|
||||
}
|
||||
@@ -90,6 +93,8 @@ function dispatchJob(int $supplierLeadId): void
|
||||
app(DuplicateDetector::class),
|
||||
app(NotificationService::class),
|
||||
app(LedgerService::class),
|
||||
app(LeadDistributor::class),
|
||||
app(RegionTagResolver::class),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -81,23 +81,27 @@ test("LeadRouter видит проекты всех tenant'ов под pgsql_sup
|
||||
$tenants = Tenant::factory()->count(3)->create(['balance_leads' => 100]);
|
||||
foreach ($tenants as $tenant) {
|
||||
for ($i = 0; $i < 2; $i++) {
|
||||
Project::factory()->create([
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'supplier_b1_project_id' => $supplier->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'plan3-task3-warn2.example.com',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'delivered_today' => 0,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $supplier->id,
|
||||
'platform' => $supplier->platform,
|
||||
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
|
||||
'subject_code' => $supplier->subject_code,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$router = app(LeadRouter::class);
|
||||
$eligible = $router->matchEligibleProjects($supplier, '79991234567');
|
||||
$eligible = $router->matchEligibleProjects($supplier);
|
||||
|
||||
expect($eligible)->toHaveCount(6);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('seeds supplier_export_mode = batch by default', function (): void {
|
||||
$row = DB::table('system_settings')->where('key', 'supplier_export_mode')->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->value)->toBe('batch')
|
||||
->and($row->type)->toBe('string');
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('reads mode from system_settings, defaults batch', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
expect(SupplierExportMode::current())->toBe('online')
|
||||
->and(SupplierExportMode::isOnline())->toBeTrue();
|
||||
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
|
||||
expect(SupplierExportMode::current())->toBe('batch')
|
||||
->and(SupplierExportMode::isOnline())->toBeFalse();
|
||||
});
|
||||
|
||||
it('falls back to batch when setting missing', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->delete();
|
||||
expect(SupplierExportMode::current())->toBe('batch');
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('multi-flag save returns external_id per platform via listProjects', function (): void {
|
||||
Http::fake([
|
||||
'*/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'id' => '300'], 200),
|
||||
'*/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '100', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'rt'],
|
||||
['id' => '200', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'bl'],
|
||||
['id' => '300', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'mt'],
|
||||
['id' => '999', 'name' => 'other.ru', 'tag' => 'Москва', 'src' => 'rt'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'okna.ru', limit: 9,
|
||||
workdays: [1, 2, 3, 4, 5, 6, 7], regions: [82], regionsReverse: false, status: 'active',
|
||||
tag: 'Москва', platforms: ['B1', 'B2', 'B3'],
|
||||
);
|
||||
|
||||
$ids = app(SupplierPortalClient::class)->saveProjectMultiFlag($dto);
|
||||
|
||||
expect($ids)->toBe(['B1' => 100, 'B2' => 200, 'B3' => 300]);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
it('allows same (platform, unique_key) with different subject_code', function (): void {
|
||||
SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
|
||||
'subject_code' => 83, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
expect(SupplierProject::query()->where('unique_key', 'okna.ru')->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('rejects duplicate (platform, unique_key, subject_code) including NULL pool', function (): void {
|
||||
SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'pool.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
expect(fn () => SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'pool.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('rejects subject_code out of 1..89 range', function (): void {
|
||||
expect(fn () => SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bad.ru',
|
||||
'subject_code' => 90, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\SyncSupplierProjectJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\Channel\SupplierProjectChannel;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
|
||||
uses(DatabaseTransactions::class, SharesSupplierPdo::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-12 10:00:00', 'Europe/Moscow'));
|
||||
|
||||
Cache::store('redis')->put('supplier:session', [
|
||||
'phpsessid' => 'sess123',
|
||||
'csrf' => 'csrf123',
|
||||
'refreshed_at' => now()->toIso8601String(),
|
||||
], now()->addHours(6));
|
||||
|
||||
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
|
||||
config(['services.supplier.alert_email' => 'ops@liderra.test']);
|
||||
|
||||
// Default to batch mode so existing Plan5 tests are unaffected
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
Cache::store('redis')->forget('supplier:session');
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Online mode: per-subject supplier_projects + pivot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('online mode creates per-subject supplier_projects with full params + pivot', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'okna.ru',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 12,
|
||||
'regions' => [82],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
// saveProjectMultiFlag → rt-project-save + listProjects → 3 ids
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '1001', 'src' => 'rt', 'name' => 'okna.ru', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'okna.ru'],
|
||||
['id' => '1002', 'src' => 'bl', 'name' => 'okna.ru', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'okna.ru'],
|
||||
['id' => '1003', 'src' => 'mt', 'name' => 'okna.ru', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'okna.ru'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
// 3 supplier_projects: subject_code=82, platforms B1/B2/B3
|
||||
expect(SupplierProject::where('unique_key', 'okna.ru')->where('subject_code', 82)->count())->toBe(3);
|
||||
|
||||
// pivot: 3 links for this project
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('online mode all-RF (no regions): 1 group subject_code=null, 3 supplier_projects + 3 pivot links', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'allrf.example.com',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 6,
|
||||
'regions' => [],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '500'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '500', 'src' => 'rt', 'name' => 'allrf.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'allrf.example.com'],
|
||||
['id' => '501', 'src' => 'bl', 'name' => 'allrf.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'allrf.example.com'],
|
||||
['id' => '502', 'src' => 'mt', 'name' => 'allrf.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'allrf.example.com'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
$sps = SupplierProject::where('unique_key', 'allrf.example.com')->get();
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->pluck('subject_code')->unique()->all())->toContain(null);
|
||||
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch mode: keeps каркас (limit 0, no per-subject save, no pivot)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it('batch mode keeps каркас (limit=0, sets supplier_b{1,2,3}_project_id, no project_supplier_links pivot)', function (): void {
|
||||
// batch is already set in beforeEach — no change needed
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'batch-test.ru',
|
||||
'is_active' => true,
|
||||
'daily_limit_target' => 10,
|
||||
'regions' => [82],
|
||||
'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
$this->mock(SupplierProjectChannel::class, function ($mock): void {
|
||||
$mock->shouldReceive('createProject')->times(3)->andReturn(200001, 200002, 200003);
|
||||
});
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
$project->refresh();
|
||||
// Batch: the old FK columns are set
|
||||
expect($project->supplier_b1_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b2_project_id)->not->toBeNull();
|
||||
expect($project->supplier_b3_project_id)->not->toBeNull();
|
||||
|
||||
// Batch: каркас → limit=0
|
||||
$sp = SupplierProject::find($project->supplier_b1_project_id);
|
||||
expect($sp->current_limit)->toBe(0);
|
||||
|
||||
// Batch: no pivot rows (nightly job fills them)
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(0);
|
||||
});
|
||||
@@ -12,10 +12,10 @@ 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;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
@@ -41,279 +41,356 @@ afterEach(function (): void {
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
||||
test('creates supplier_project at supplier when supplier_external_id is null', function (): void {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-subject grouping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Project regions=[82,83] site → 2 groups (Москва, СПб) →
|
||||
* 2 multi-flag saves → 6 supplier_projects (2 subjects × 3 platforms B1/B2/B3)
|
||||
* with correct subject_code/tag; pivot — 6 links for the project.
|
||||
*/
|
||||
test('per-subject: regions=[82,83] site → 6 supplier_projects + 6 pivot links', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'create-flow.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
Project::factory()->create([
|
||||
|
||||
/** @var Project $project */
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'create-flow.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'signal_identifier' => 'persubject.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127, // all days
|
||||
'regions' => [82, 83],
|
||||
]);
|
||||
|
||||
// saveProjectMultiFlag calls rt-project-save once per subject, then listProjects to get ids
|
||||
Http::fake([
|
||||
// first save (subject 82 = Москва)
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::sequence()
|
||||
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '1001'], 200)
|
||||
// second save (subject 83 = Санкт-Петербург)
|
||||
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '2001'], 200),
|
||||
// listProjects called after each save — return 3 rows per group
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::sequence()
|
||||
// After first save (Москва tag)
|
||||
->push(['projects' => [
|
||||
['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
]], 200)
|
||||
// After second save (СПб tag)
|
||||
->push(['projects' => [
|
||||
['id' => '1001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '1003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Москва', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '2001', 'src' => 'rt', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '2002', 'src' => 'bl', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
['id' => '2003', 'src' => 'mt', 'name' => 'persubject.example.com', 'tag' => 'Санкт-Петербург', 'type' => 'hosts', 'content' => 'persubject.example.com'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// 6 supplier_projects created: 2 subjects × 3 platforms
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'persubject.example.com')
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
|
||||
expect($sps)->toHaveCount(6);
|
||||
|
||||
// subject_code 82 → 3 rows (B1/B2/B3)
|
||||
$m = $sps->where('subject_code', 82);
|
||||
expect($m)->toHaveCount(3);
|
||||
expect($m->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B2', 'B3']);
|
||||
|
||||
// subject_code 83 → 3 rows
|
||||
$spb = $sps->where('subject_code', 83);
|
||||
expect($spb)->toHaveCount(3);
|
||||
|
||||
// pivot: 6 links for this project
|
||||
$pivotCount = DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->count();
|
||||
expect($pivotCount)->toBe(6);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// All-RF pool
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('all-RF pool: regions=[] → 1 group subject_code=null tag=РФ → 3 supplier_projects', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
/** @var Project $project */
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'rf-pool.example.com',
|
||||
'daily_limit_target' => 6,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '500'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '500', 'src' => 'rt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
|
||||
['id' => '501', 'src' => 'bl', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
|
||||
['id' => '502', 'src' => 'mt', 'name' => 'rf-pool.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'rf-pool.example.com'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(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);
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'rf-pool.example.com')
|
||||
->where('signal_type', 'site')
|
||||
->get();
|
||||
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save'));
|
||||
expect($sps)->toHaveCount(3);
|
||||
expect($sps->pluck('subject_code')->unique()->all())->toContain(null);
|
||||
expect($sps->pluck('current_regions')->first())->toBe([]);
|
||||
|
||||
// pivot
|
||||
$pivotCount = DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->count();
|
||||
expect($pivotCount)->toBe(3);
|
||||
});
|
||||
|
||||
test('updates when diff detected', function (): void {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Order: 2 projects on one (source × subject) → computeOrder
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('order: 2 projects same source×subject → computeOrder(limits=[10,20]) → limit=20', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'update-flow.example.com',
|
||||
'supplier_external_id' => '12345',
|
||||
'current_limit' => 1,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'update-flow.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 30,
|
||||
'signal_identifier' => 'order-test.example.com',
|
||||
'daily_limit_target' => 10,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'order-test.example.com',
|
||||
'daily_limit_target' => 20,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
// saveProjectMultiFlag called once (both projects share same group)
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '12345'],
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '600'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '600', 'src' => 'rt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
|
||||
['id' => '601', 'src' => 'bl', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
|
||||
['id' => '602', 'src' => 'mt', 'name' => 'order-test.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'order-test.example.com'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sp->refresh();
|
||||
expect($sp->current_limit)->toBe(10)
|
||||
->and($sp->sync_status)->toBe('ok');
|
||||
|
||||
// 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 {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'no-diff.example.com',
|
||||
'supplier_external_id' => '999',
|
||||
'current_limit' => 9,
|
||||
'current_workdays' => [1, 2, 3, 4, 5, 6, 7],
|
||||
'current_regions' => [],
|
||||
'sync_status' => 'ok',
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'no-diff.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 27,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('isolates failure: one bad supplier_project does not stop others', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$bad = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'bad.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
$good = SupplierProject::factory()->create([
|
||||
'platform' => 'B2',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'good.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'bad.example.com',
|
||||
'supplier_b1_project_id' => $bad->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'good.example.com',
|
||||
'supplier_b2_project_id' => $good->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
|
||||
Http::fakeSequence('crm.bp-gr.ru/admin/visit/rt-project-save')
|
||||
->push('bad request', 422)
|
||||
->push(['status' => 'OK', 'message' => '', 'result' => null, 'id' => '777'], 200);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
expect(
|
||||
SupplierSyncLog::on('pgsql_supplier')
|
||||
->where('supplier_project_id', $bad->id)
|
||||
->whereNotNull('error_message')
|
||||
->exists()
|
||||
)->toBeTrue();
|
||||
|
||||
expect($good->fresh()->supplier_external_id)->toBe('777');
|
||||
});
|
||||
|
||||
test('aborts after 50 consecutive transient failures and sends alert', function (): void {
|
||||
Mail::fake();
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
for ($i = 1; $i <= 60; $i++) {
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => "host{$i}.example.com",
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => "host{$i}.example.com",
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
}
|
||||
|
||||
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
|
||||
return $mail->alertType === 'mass_transient';
|
||||
});
|
||||
});
|
||||
|
||||
test('writes supplier_sync_log row for each successful action', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'audit-log.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'audit-log.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$log = SupplierSyncLog::on('pgsql_supplier')
|
||||
->where('supplier_project_id', $sp->id)
|
||||
// computeOrder([10, 20]) = max(20, ceil(30/3)=10) = 20
|
||||
$sp = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'order-test.example.com')
|
||||
->where('platform', 'B1')
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->action)->toBe('create')
|
||||
->and($log->http_status)->toBe(200)
|
||||
->and($log->error_message)->toBeNull();
|
||||
expect($sp)->not->toBeNull();
|
||||
expect($sp->current_limit)->toBe(20);
|
||||
|
||||
// Only one save call (single group) — not 2
|
||||
Http::assertSentCount(2); // 1 save + 1 listProjects
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SMS platforms
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('sms+keyword → platforms B2+B3 (2 supplier_projects per subject)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => ['79001234567'],
|
||||
'sms_keyword' => 'KVARTIRA',
|
||||
'daily_limit_target' => 5,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '700'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '700', 'src' => 'bl', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'],
|
||||
['id' => '701', 'src' => 'mt', 'name' => '79001234567+KVARTIRA', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79001234567+KVARTIRA'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('signal_type', 'sms')
|
||||
->get();
|
||||
|
||||
// sms+keyword → B2+B3 only
|
||||
expect($sps)->toHaveCount(2);
|
||||
expect($sps->pluck('platform')->sort()->values()->all())->toBe(['B2', 'B3']);
|
||||
expect($sps->where('platform', 'B1')->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('sms without keyword → platform B3 only (1 supplier_project)', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'sms',
|
||||
'signal_identifier' => null,
|
||||
'sms_senders' => ['79009876543'],
|
||||
'sms_keyword' => null,
|
||||
'daily_limit_target' => 5,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '800'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '800', 'src' => 'mt', 'name' => '79009876543', 'tag' => 'РФ', 'type' => 'sms', 'content' => '79009876543'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sps = SupplierProject::on('pgsql_supplier')
|
||||
->where('signal_type', 'sms')
|
||||
->get();
|
||||
|
||||
expect($sps)->toHaveCount(1);
|
||||
expect($sps->first()->platform)->toBe('B3');
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Idempotent: repeat run → updateProject (no duplicate supplier_projects/pivot)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('idempotent: repeat run with no changes → updateProject not duplicate', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'idempotent.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
// First run: create
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '900', 'src' => 'rt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
|
||||
['id' => '901', 'src' => 'bl', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
|
||||
['id' => '902', 'src' => 'mt', 'name' => 'idempotent.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'idempotent.example.com'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
expect(SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'idempotent.example.com')
|
||||
->count())->toBe(3);
|
||||
|
||||
// Second run: no changes → updateProject calls (rt-project-save with id != 0)
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '900'],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
// Still 3 (no duplicates)
|
||||
expect(SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'idempotent.example.com')
|
||||
->count())->toBe(3);
|
||||
|
||||
// updateProject sends id != 0
|
||||
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/admin/visit/rt-project-save')
|
||||
&& (int) ($r['id'] ?? 0) !== 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Orthogonal: time budget, auth, abort-50, sync_log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
Carbon::setTestNow(Carbon::parse('2026-05-12 20:56:00', 'Europe/Moscow'));
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'time-budget.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'time-budget.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake();
|
||||
@@ -322,60 +399,20 @@ test('respects time budget by stopping at 20:55 МСК', function (): void {
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('passes regions directly to allocator without bitmask conversion', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'regions' => [82, 83],
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
$job = new SyncSupplierProjectsJob;
|
||||
$projects = Project::where('tenant_id', $tenant->id)->get();
|
||||
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
|
||||
|
||||
expect($adapted->first()->regions)->toBe([82, 83]);
|
||||
});
|
||||
|
||||
test('passes empty array to allocator when project has regions=[]', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'regions' => [],
|
||||
'region_mask' => 255,
|
||||
]);
|
||||
|
||||
$job = new SyncSupplierProjectsJob;
|
||||
$projects = Project::where('tenant_id', $tenant->id)->get();
|
||||
$adapted = (fn (Collection $p) => $this->adaptProjectsForAllocator($p))->call($job, $projects);
|
||||
|
||||
expect($adapted->first()->regions)->toBe([]);
|
||||
});
|
||||
|
||||
test('sticky auth error throws and sends critical alert email', function (): void {
|
||||
Mail::fake();
|
||||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'auth-fail.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'auth-fail.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
@@ -390,40 +427,77 @@ test('sticky auth error throws and sends critical alert email', function (): voi
|
||||
});
|
||||
});
|
||||
|
||||
test('outbound: copies project regions[] into supplier_project current_regions via full handle()', function (): void {
|
||||
test('aborts after 50 consecutive transient failures and sends alert', function (): void {
|
||||
Mail::fake();
|
||||
$tenant = Tenant::factory()->create();
|
||||
$sp = SupplierProject::factory()->create([
|
||||
'platform' => 'B1',
|
||||
'signal_type' => 'site',
|
||||
'unique_key' => 'regions-flow.example.com',
|
||||
'supplier_external_id' => null,
|
||||
'current_limit' => 0,
|
||||
'current_workdays' => [],
|
||||
'current_regions' => [],
|
||||
]);
|
||||
|
||||
for ($i = 1; $i <= 60; $i++) {
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => "host{$i}.abort.com",
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
Http::fake(['crm.bp-gr.ru/*' => Http::response('upstream', 503)]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
Mail::assertQueued(SupplierCriticalAlertMail::class, function (SupplierCriticalAlertMail $mail): bool {
|
||||
return $mail->alertType === 'mass_transient';
|
||||
});
|
||||
});
|
||||
|
||||
test('writes supplier_sync_log row for each successful action', function (): void {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'is_active' => true,
|
||||
'archived_at' => null,
|
||||
'signal_type' => 'site',
|
||||
'signal_identifier' => 'regions-flow.example.com',
|
||||
'supplier_b1_project_id' => $sp->id,
|
||||
'signal_identifier' => 'audit-log.example.com',
|
||||
'daily_limit_target' => 9,
|
||||
'delivery_days_mask' => 127,
|
||||
'regions' => [82, 83],
|
||||
'region_mask' => 255,
|
||||
'region_mode' => 'include',
|
||||
'regions' => [],
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/admin/visit/rt-project-save' => Http::response(
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '556'],
|
||||
['status' => 'OK', 'message' => '', 'result' => null, 'id' => '555'],
|
||||
200,
|
||||
),
|
||||
'crm.bp-gr.ru/admin/visit/rt-projects-load*' => Http::response(
|
||||
['projects' => [
|
||||
['id' => '555', 'src' => 'rt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
|
||||
['id' => '556', 'src' => 'bl', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
|
||||
['id' => '557', 'src' => 'mt', 'name' => 'audit-log.example.com', 'tag' => 'РФ', 'type' => 'hosts', 'content' => 'audit-log.example.com'],
|
||||
]],
|
||||
200,
|
||||
),
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectsJob)->handle(app(AjaxProjectChannel::class));
|
||||
|
||||
$sp->refresh();
|
||||
expect($sp->current_regions)->toBe([82, 83])
|
||||
->and($sp->supplier_external_id)->toBe('556');
|
||||
// 3 supplier_projects created → 3 log rows (one per platform)
|
||||
$sp = SupplierProject::on('pgsql_supplier')
|
||||
->where('unique_key', 'audit-log.example.com')
|
||||
->where('platform', 'B1')
|
||||
->first();
|
||||
|
||||
expect($sp)->not->toBeNull();
|
||||
|
||||
$log = SupplierSyncLog::on('pgsql_supplier')
|
||||
->where('supplier_project_id', $sp->id)
|
||||
->first();
|
||||
|
||||
expect($log)->not->toBeNull()
|
||||
->and($log->action)->toBe('create')
|
||||
->and($log->http_status)->toBe(200)
|
||||
->and($log->error_message)->toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/*
|
||||
@@ -50,3 +53,23 @@ function something()
|
||||
{
|
||||
// ..
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a Лидерра-project to a supplier_project via the M:N pivot
|
||||
* (Plan 1 model). Post-Plan-2 LeadRouter eligibility queries the pivot
|
||||
* only; legacy supplier_b{1,2,3}_project_id FK is ignored for routing.
|
||||
*
|
||||
* Single source — replaces previous duplicated declarations in
|
||||
* LeadRouterTest.php / RouteSupplierLeadJobTest.php (Plan 2 cleanup).
|
||||
* pivot created_at has DEFAULT NOW(); supplier->subject_code may be null.
|
||||
*/
|
||||
function linkProjectToSupplier(Project $project, SupplierProject $supplier): void
|
||||
{
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $supplier->id,
|
||||
'platform' => $supplier->platform,
|
||||
// @phpstan-ignore-next-line property.notFound — subject_code is in $fillable/casts, IDE stubs lag
|
||||
'subject_code' => $supplier->subject_code,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\LeadDistributor;
|
||||
use Illuminate\Support\Collection;
|
||||
use Random\Engine\Mt19937;
|
||||
use Random\Randomizer;
|
||||
|
||||
function projects(int $n): Collection
|
||||
{
|
||||
return collect(range(1, $n))->map(fn (int $i) => (object) ['id' => $i]);
|
||||
}
|
||||
|
||||
it('returns all when eligible count <= cap (3)', function (): void {
|
||||
$d = new LeadDistributor;
|
||||
expect($d->selectRecipients(projects(2)))->toHaveCount(2)
|
||||
->and($d->selectRecipients(projects(3)))->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('caps at 3 when more eligible', function (): void {
|
||||
$d = new LeadDistributor;
|
||||
expect($d->selectRecipients(projects(7)))->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('selection is a subset of eligible and deterministic under seeded RNG', function (): void {
|
||||
$eligible = projects(7);
|
||||
$d = new LeadDistributor(new Randomizer(new Mt19937(42)));
|
||||
$picked = $d->selectRecipients($eligible)->pluck('id')->all();
|
||||
|
||||
expect($picked)->toHaveCount(3)
|
||||
->and(collect($picked)->every(fn ($id) => $id >= 1 && $id <= 7))->toBeTrue();
|
||||
|
||||
// тот же seed → тот же выбор
|
||||
$d2 = new LeadDistributor(new Randomizer(new Mt19937(42)));
|
||||
expect($d2->selectRecipients($eligible)->pluck('id')->all())->toBe($picked);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Support\RussianRegions;
|
||||
|
||||
it('resolves subject name to code', function (): void {
|
||||
$r = new RegionTagResolver;
|
||||
expect($r->resolve('Москва'))->toBe(82)
|
||||
->and($r->resolve('Санкт-Петербург'))->toBe(83)
|
||||
->and($r->resolve('Республика Адыгея'))->toBe(1);
|
||||
});
|
||||
|
||||
it('returns null for «РФ» pool tag, empty and unknown', function (): void {
|
||||
$r = new RegionTagResolver;
|
||||
expect($r->resolve('РФ'))->toBeNull()
|
||||
->and($r->resolve(''))->toBeNull()
|
||||
->and($r->resolve('Нарния'))->toBeNull();
|
||||
});
|
||||
|
||||
it('canonical region map mirrors regions.ts — exactly 89 subjects', function (): void {
|
||||
expect(count(RussianRegions::CODE_TO_NAME))->toBe(89);
|
||||
});
|
||||
@@ -224,3 +224,65 @@ test('RefreshSupplierSessionJob throws during initial loadSession translated to
|
||||
->and($caught->getPrevious())->toBeInstanceOf(RuntimeException::class)
|
||||
->and($caught->getPrevious()?->getMessage())->toBe('Simulated Playwright crash during loadSession');
|
||||
});
|
||||
|
||||
test('200 HTML login page triggers RefreshSupplierSessionJob sync and retries once', function (): void {
|
||||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||||
|
||||
Http::fakeSequence('crm.bp-gr.ru/*')
|
||||
->push(
|
||||
'<html><body><form action="/login"><input id="loginform-username" name="LoginForm[username]"></form></body></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html; charset=utf-8'],
|
||||
)
|
||||
->push('{"projects":[]}', 200, ['Content-Type' => 'application/json']);
|
||||
|
||||
app(SupplierPortalClient::class)->listProjects();
|
||||
|
||||
Bus::assertDispatchedSync(RefreshSupplierSessionJob::class);
|
||||
Http::assertSentCount(2);
|
||||
});
|
||||
|
||||
test('sticky HTML login page after retry throws SupplierAuthException', function (): void {
|
||||
Bus::fake([RefreshSupplierSessionJob::class]);
|
||||
|
||||
Http::fakeSequence('crm.bp-gr.ru/*')
|
||||
->push(
|
||||
'<html><input id="loginform-username"></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html; charset=utf-8'],
|
||||
)
|
||||
->push(
|
||||
'<html><input id="loginform-username"></html>',
|
||||
200,
|
||||
['Content-Type' => 'text/html; charset=utf-8'],
|
||||
);
|
||||
|
||||
expect(fn () => app(SupplierPortalClient::class)->listProjects())
|
||||
->toThrow(SupplierAuthException::class, 'Portal returned login page after refresh');
|
||||
});
|
||||
|
||||
test('JSON response with substring "loginform-username" is NOT misclassified as login page', function (): void {
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/*' => Http::response(
|
||||
'{"projects":[{"name":"loginform-username is just a string here"}]}',
|
||||
200,
|
||||
['Content-Type' => 'application/json'],
|
||||
),
|
||||
]);
|
||||
|
||||
$result = app(SupplierPortalClient::class)->listProjects();
|
||||
|
||||
expect($result)->toHaveCount(1);
|
||||
Http::assertSentCount(1); // no retry — JSON header skips login-detect
|
||||
});
|
||||
|
||||
test('200 response without Content-Type header is NOT detected as login page', function (): void {
|
||||
// Документирует контракт: пустой Content-Type → str_starts_with('','text/html') === false → детект пропускается.
|
||||
Http::fake([
|
||||
'crm.bp-gr.ru/*' => Http::response('{"projects":[]}', 200), // no Content-Type header
|
||||
]);
|
||||
|
||||
app(SupplierPortalClient::class)->listProjects();
|
||||
|
||||
Http::assertSentCount(1); // no retry — empty Content-Type fails the text/html gate
|
||||
});
|
||||
|
||||
@@ -9,156 +9,53 @@ use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
// 2026-05-12 — это вторник (isoWeekday=2 в Europe/Moscow).
|
||||
// 2026-05-16 — суббота (isoWeekday=6), 2026-05-17 — воскресенье (isoWeekday=7).
|
||||
// 2026-05-12 — вторник (isoWeekday=2 Europe/Moscow).
|
||||
// 2026-05-13 — среда (isoWeekday=3).
|
||||
|
||||
test('site signal distributes B1 ceil(total/3), B2 ceil(remainder/2), B3 remainder', function (): void {
|
||||
$projects = new Collection([
|
||||
(object) ['daily_limit' => 10, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
|
||||
]);
|
||||
it('computeOrder = max(наибольший лимит, ceil(Σ/3))', function (array $limits, int $expected): void {
|
||||
expect(SupplierQuotaAllocator::computeOrder($limits))->toBe($expected);
|
||||
})->with([
|
||||
'brief 1' => [[5, 5, 10, 20], 20],
|
||||
'brief 2' => [array_merge(array_fill(0, 15, 5), [10]), 29], // 15×5+10 → Σ85, наиб10, ceil(85/3)=29
|
||||
'brief 3' => [[15, 15, 15], 15],
|
||||
'brief 4' => [[15, 15, 15, 30], 30],
|
||||
'brief 5' => [[10, 10, 10, 10], 14],
|
||||
'single' => [[7], 7],
|
||||
'empty' => [[], 0],
|
||||
]);
|
||||
|
||||
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
// Orthogonal smoke tests on allocate() — preserved from pre-T3 coverage; assert
|
||||
// invariants independent of the order formula (workdays/regions union, null-on-no-eligible).
|
||||
|
||||
expect($b1)->not->toBeNull()
|
||||
->and($b2)->not->toBeNull()
|
||||
->and($b3)->not->toBeNull()
|
||||
->and($b1->limit + $b2->limit + $b3->limit)->toBe(10)
|
||||
->and($b1->limit)->toBe(4)
|
||||
->and($b2->limit)->toBe(3)
|
||||
->and($b3->limit)->toBe(3);
|
||||
});
|
||||
|
||||
test('call signal same distribution as site (B1/B2/B3 split)', function (): void {
|
||||
$projects = new Collection([
|
||||
(object) ['daily_limit' => 30, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
|
||||
]);
|
||||
|
||||
$b1 = SupplierQuotaAllocator::allocate('B1', 'call', '79991234567', $projects, Carbon::parse('2026-05-12'));
|
||||
|
||||
expect($b1)->not->toBeNull()
|
||||
->and($b1->limit)->toBe(10);
|
||||
});
|
||||
|
||||
test('sms with keyword distributes B2+B3 only (B1 returns 0)', function (): void {
|
||||
$projects = new Collection([
|
||||
(object) ['daily_limit' => 4, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
|
||||
]);
|
||||
|
||||
$b1 = SupplierQuotaAllocator::allocate('B1', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
|
||||
$b2 = SupplierQuotaAllocator::allocate('B2', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
|
||||
$b3 = SupplierQuotaAllocator::allocate('B3', 'sms', 'TINKOFF+ипотека', $projects, Carbon::parse('2026-05-12'));
|
||||
|
||||
expect($b1)->not->toBeNull()
|
||||
->and($b2)->not->toBeNull()
|
||||
->and($b3)->not->toBeNull()
|
||||
->and($b1->limit)->toBe(0)
|
||||
->and($b2->limit)->toBe(2)
|
||||
->and($b3->limit)->toBe(2);
|
||||
});
|
||||
|
||||
test('returns null when no active liderra projects on target weekday', function (): void {
|
||||
$projects = new Collection([
|
||||
(object) ['daily_limit' => 10, 'workdays' => [6, 7], 'regions' => []],
|
||||
]);
|
||||
|
||||
$allocation = SupplierQuotaAllocator::allocate(
|
||||
'B1',
|
||||
'site',
|
||||
'example.com',
|
||||
$projects,
|
||||
Carbon::parse('2026-05-12'),
|
||||
);
|
||||
|
||||
expect($allocation)->toBeNull();
|
||||
});
|
||||
|
||||
test('workdays union deduplicates and sorts', function (): void {
|
||||
// Targeting Wednesday (2026-05-13, isoWeekday=3): оба проекта содержат 3 → оба eligible,
|
||||
// союз их workdays — [1,2,3,4,5].
|
||||
it('workdays union deduplicates and sorts', function (): void {
|
||||
$projects = new Collection([
|
||||
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3], 'regions' => []],
|
||||
(object) ['daily_limit' => 5, 'workdays' => [3, 4, 5], 'regions' => []],
|
||||
]);
|
||||
|
||||
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-13'));
|
||||
$dto = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-13'));
|
||||
|
||||
expect($b1)->not->toBeNull()
|
||||
->and($b1->workdays)->toBe([1, 2, 3, 4, 5]);
|
||||
expect($dto)->not->toBeNull()
|
||||
->and($dto->workdays)->toBe([1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test('regions union deduplicates and sorts', function (): void {
|
||||
it('regions union deduplicates and sorts', function (): void {
|
||||
$projects = new Collection([
|
||||
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3, 4, 5], 'regions' => [77, 50]],
|
||||
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3, 4, 5], 'regions' => [50, 78]],
|
||||
]);
|
||||
|
||||
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
$dto = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
|
||||
expect($b1)->not->toBeNull()
|
||||
->and($b1->regions)->toBe([50, 77, 78]);
|
||||
expect($dto)->not->toBeNull()
|
||||
->and($dto->regions)->toBe([50, 77, 78]);
|
||||
});
|
||||
|
||||
test('empty regions stays empty (all regions semantics)', function (): void {
|
||||
it('returns null when no active liderra projects on target weekday', function (): void {
|
||||
$projects = new Collection([
|
||||
(object) ['daily_limit' => 5, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
|
||||
(object) ['daily_limit' => 10, 'workdays' => [6, 7], 'regions' => []],
|
||||
]);
|
||||
|
||||
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
|
||||
expect($b1)->not->toBeNull()
|
||||
->and($b1->regions)->toBe([]);
|
||||
});
|
||||
|
||||
test('single project with limit=1 sites to B1 only', function (): void {
|
||||
$projects = new Collection([
|
||||
(object) ['daily_limit' => 1, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
|
||||
]);
|
||||
|
||||
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
|
||||
expect($b1)->not->toBeNull()
|
||||
->and($b2)->not->toBeNull()
|
||||
->and($b3)->not->toBeNull()
|
||||
->and($b1->limit)->toBe(1)
|
||||
->and($b2->limit)->toBe(0)
|
||||
->and($b3->limit)->toBe(0);
|
||||
});
|
||||
|
||||
test('large scale: 1000 projects with limit 10 each = 10000 total', function (): void {
|
||||
$projects = new Collection(array_fill(0, 1000, (object) [
|
||||
'daily_limit' => 10,
|
||||
'workdays' => [1, 2, 3, 4, 5],
|
||||
'regions' => [],
|
||||
]));
|
||||
|
||||
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
|
||||
expect($b1)->not->toBeNull()
|
||||
->and($b2)->not->toBeNull()
|
||||
->and($b3)->not->toBeNull()
|
||||
->and($b1->limit + $b2->limit + $b3->limit)->toBe(10000)
|
||||
->and($b1->limit)->toBe(3334);
|
||||
});
|
||||
|
||||
test('odd total: 7 distributes B1=3, B2=2, B3=2', function (): void {
|
||||
$projects = new Collection([
|
||||
(object) ['daily_limit' => 7, 'workdays' => [1, 2, 3, 4, 5], 'regions' => []],
|
||||
]);
|
||||
|
||||
$b1 = SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
$b2 = SupplierQuotaAllocator::allocate('B2', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
$b3 = SupplierQuotaAllocator::allocate('B3', 'site', 'example.com', $projects, Carbon::parse('2026-05-12'));
|
||||
|
||||
expect($b1)->not->toBeNull()
|
||||
->and($b2)->not->toBeNull()
|
||||
->and($b3)->not->toBeNull()
|
||||
->and($b1->limit)->toBe(3)
|
||||
->and($b2->limit)->toBe(2)
|
||||
->and($b3->limit)->toBe(2);
|
||||
expect(SupplierQuotaAllocator::allocate('B1', 'site', 'example.com', $projects, Carbon::parse('2026-05-12')))
|
||||
->toBeNull();
|
||||
});
|
||||
|
||||
@@ -1475,3 +1475,65 @@ DWC
|
||||
инжектим
|
||||
фикстурный
|
||||
роута
|
||||
# Brain dashboard design spec (2026-05-19)
|
||||
визуализирующий
|
||||
анимируются
|
||||
неподсвеченными
|
||||
полл
|
||||
инференс
|
||||
вендорено
|
||||
|
||||
# Brain dashboard implementation plan (2026-05-19)
|
||||
visualises
|
||||
AGD
|
||||
agg
|
||||
|
||||
# Supplier migration follow-up (2026-05-19)
|
||||
ретрая
|
||||
детекта
|
||||
Регэксп
|
||||
фрэш
|
||||
дебагом
|
||||
srcrt
|
||||
srcbl
|
||||
srcmt
|
||||
симв
|
||||
|
||||
# finance-tooling C6+C7 epic — design spec (2026-05-20)
|
||||
GAAP
|
||||
РСБУ
|
||||
вендоренного
|
||||
джоб
|
||||
линтуется
|
||||
парсятся
|
||||
ретрай
|
||||
субледжер
|
||||
хардкодит
|
||||
|
||||
# finance-tooling C6+C7 — billing-audit skill (2026-05-20)
|
||||
TOCTOU
|
||||
bcadd
|
||||
bcsub
|
||||
bcmul
|
||||
|
||||
# finance-tooling C6+C7 — ADR-012 (2026-05-20)
|
||||
непусты
|
||||
|
||||
# Project migration redesign — plan 1 foundation (2026-05-20)
|
||||
сид
|
||||
бэкофилл
|
||||
бэкофилла
|
||||
psl
|
||||
|
||||
# Project migration redesign — plan 2 distribution (2026-05-20)
|
||||
инъектируемый
|
||||
сидируемый
|
||||
проде
|
||||
бэкофиллом
|
||||
|
||||
# Project migration redesign — plan 3 export (2026-05-20)
|
||||
диспатчит
|
||||
rsave
|
||||
|
||||
# Project migration redesign — plan 4 admin + ЛК (2026-05-20)
|
||||
vsya
|
||||
|
||||
+39
-1
@@ -2,10 +2,48 @@
|
||||
|
||||
**Назначение:** консолидированный журнал изменений `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.25, консолидированная — разворачивает БД с нуля).
|
||||
**Файл схемы:** `schema.sql` (текущая версия — v8.26, консолидированная — разворачивает БД с нуля).
|
||||
|
||||
**История записей:**
|
||||
|
||||
## v8.26 — 2026-05-20 — supplier_projects.subject_code (per-субъект экспорт)
|
||||
|
||||
`supplier_projects` +1 колонка `subject_code SMALLINT NULL` (1..89; NULL = пул «Вся РФ»),
|
||||
+1 CHECK `chk_supplier_projects_subject_code`. Unique-индекс
|
||||
`supplier_projects_platform_unique_key_unique` (platform, unique_key) → заменён на
|
||||
`supplier_projects_platform_key_subject_unique` (platform, unique_key, subject_code)
|
||||
NULLS NOT DISTINCT (пул «Вся РФ» уникален per источник×платформа).
|
||||
Эпик: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.2.
|
||||
Миграция: 2026_05_20_100000_supplier_projects_subject_code.php (Schema::hasColumn +
|
||||
pg_constraint guards). Индексы: −1 +1 (нет дельты count). RLS не затронут (SaaS-level).
|
||||
|
||||
## v8.26 (доп) — 2026-05-20 — project_supplier_links (M:N pivot)
|
||||
|
||||
+1 таблица SaaS-level `project_supplier_links` (project_id, supplier_project_id,
|
||||
platform, subject_code, created_at): M:N замена 3 FK-слотов
|
||||
projects.supplier_b{1,2,3}_project_id (per-субъект модель). +2 FK (оба ON DELETE
|
||||
CASCADE), +1 CHECK chk_psl_platform, +1 UNIQUE uq_psl_project_supplier, +2 индекса.
|
||||
Без RLS (как supplier_projects). Старые FK-колонки остаются (двойная запись) до
|
||||
follow-up. Миграция: 2026_05_20_101000_create_project_supplier_links.php.
|
||||
|
||||
## v8.26 (доп) — 2026-05-20 — deals.subject_code
|
||||
|
||||
`deals` +1 колонка `subject_code SMALLINT NULL` — субъект РФ из тега поставщика
|
||||
(raw_payload[tag]); отдельно от region_code (ISO, phone-derived). Наследуется
|
||||
12 партициями. Миграция: 2026_05_20_102000_deals_subject_code.php.
|
||||
|
||||
## v8.26 (доп) — 2026-05-20 — seed system_settings.supplier_export_mode
|
||||
|
||||
Сид-строка `supplier_export_mode='batch'` (тумблер режима экспорта; online|batch).
|
||||
Не структурное изменение. Миграция: 2026_05_20_103000_seed_supplier_export_mode.php.
|
||||
|
||||
## v8.26 (доп) — 2026-05-20 — deals.subject_code range CHECK (defensive parity)
|
||||
|
||||
+1 CHECK `chk_deals_subject_code` на партиционированной `deals` (subject_code IS NULL OR
|
||||
BETWEEN 1 AND 89). Закрывает review-finding Plan 1 — defensive parity с
|
||||
`chk_supplier_projects_subject_code` (malformed tag → silent garbage). NOT VALID + VALIDATE
|
||||
(squawk-safe). Миграция: 2026_05_20_105000_deals_subject_code_check.php.
|
||||
|
||||
## v8.25 — 2026-05-19 — supplier_manual_sync_queue (Tier 3 резерва канала миграции проектов)
|
||||
|
||||
**+1 таблица** SaaS-level (без tenant_id / RLS, как `supplier_csv_reconcile_log`):
|
||||
|
||||
+25
-4
@@ -909,6 +909,7 @@ CREATE TABLE supplier_projects (
|
||||
CHECK (current_limit >= 0),
|
||||
current_workdays JSONB, -- объединение workdays активных tenant'ов
|
||||
current_regions JSONB, -- объединение regions активных tenant'ов
|
||||
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ» (v8.26)
|
||||
sync_status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
last_synced_at TIMESTAMPTZ,
|
||||
inactive_since TIMESTAMPTZ, -- момент когда последний tenant отвалился (TTL 180 дней)
|
||||
@@ -923,11 +924,13 @@ CREATE TABLE supplier_projects (
|
||||
CHECK (sync_status IN ('pending','ok','failed')),
|
||||
-- B1 не поддерживает СМС (см. spec §2.2 — таблица platform×signal_type)
|
||||
CONSTRAINT chk_supplier_projects_b1_not_for_sms
|
||||
CHECK (NOT (platform = 'B1' AND signal_type = 'sms'))
|
||||
CHECK (NOT (platform = 'B1' AND signal_type = 'sms')),
|
||||
CONSTRAINT chk_supplier_projects_subject_code
|
||||
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX supplier_projects_platform_unique_key_unique
|
||||
ON supplier_projects(platform, unique_key);
|
||||
CREATE UNIQUE INDEX supplier_projects_platform_key_subject_unique
|
||||
ON supplier_projects(platform, unique_key, subject_code) NULLS NOT DISTINCT;
|
||||
CREATE INDEX supplier_projects_sync_status_index
|
||||
ON supplier_projects(sync_status);
|
||||
CREATE INDEX supplier_projects_inactive_since_index
|
||||
@@ -950,6 +953,20 @@ CREATE INDEX idx_projects_supplier_b1_project_id ON projects(supplier_b1_project
|
||||
CREATE INDEX idx_projects_supplier_b2_project_id ON projects(supplier_b2_project_id) WHERE supplier_b2_project_id IS NOT NULL;
|
||||
CREATE INDEX idx_projects_supplier_b3_project_id ON projects(supplier_b3_project_id) WHERE supplier_b3_project_id IS NOT NULL;
|
||||
|
||||
-- v8.26: M:N pivot projects ↔ supplier_projects (замена 3 FK-слотов supplier_b{1,2,3}_project_id).
|
||||
CREATE TABLE project_supplier_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ»
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3')),
|
||||
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
|
||||
);
|
||||
CREATE INDEX idx_psl_supplier_project ON project_supplier_links(supplier_project_id);
|
||||
CREATE INDEX idx_psl_project ON project_supplier_links(project_id);
|
||||
|
||||
-- v8.17 (Plan 1/5 Task 2 fix): defense-in-depth — Project-уровень тоже запрещает B1+SMS.
|
||||
-- supplier_projects уже имеет CHECK chk_supplier_projects_b1_not_for_sms; здесь дублируем
|
||||
-- на projects, чтобы исключить логическую несостыковку «sms-проект привязан к B1-supplier».
|
||||
@@ -1610,6 +1627,7 @@ CREATE TABLE deals (
|
||||
-- удалось определить. city — свободный текст (приходит из webhook или
|
||||
-- enrichment-сервиса). Используется для filter в §10.3 + аналитики §12.
|
||||
region_code VARCHAR(8),
|
||||
subject_code SMALLINT, -- v8.26: субъект РФ 1..89 из тега поставщика (raw_payload[tag]); NULL = вся РФ/неизвестно
|
||||
city VARCHAR(100),
|
||||
-- v8.5 (Биз-22): простая lead scoring модель без ML.
|
||||
-- time_in_form_seconds — сколько секунд физлицо заполняло форму
|
||||
@@ -1633,7 +1651,10 @@ CREATE TABLE deals (
|
||||
CONSTRAINT chk_deals_lead_score_range
|
||||
CHECK (lead_score IS NULL OR (lead_score >= 0.00 AND lead_score <= 99.99)),
|
||||
CONSTRAINT chk_deals_escalated_count_nonneg
|
||||
CHECK (escalated_count >= 0)
|
||||
CHECK (escalated_count >= 0),
|
||||
-- v8.26: subject_code диапазон субъекта РФ 1..89 (defensive parity с supplier_projects).
|
||||
CONSTRAINT chk_deals_subject_code
|
||||
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89))
|
||||
) PARTITION BY RANGE (received_at);
|
||||
|
||||
-- Индексы на родительской таблице наследуются партициями
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.17)
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v3.18)
|
||||
|
||||
**Дата:** 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.18** — finance-tooling (C6+C7): R10.1 Блок 1 +finance plugin (#61, marketplace `finance@knowledge-work-plugins`, homed C7, cross-ref C6) + note (+billing-audit #62 / ru-tax-accounting #63 — self-authored project-скилы). Новая 15-я off-phase подкатегория finance-tooling, разделы C6/C7 карты. Не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.18, Pravila v1.34, CLAUDE.md v2.21, ADR-012; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
|
||||
|
||||
**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`.
|
||||
@@ -435,6 +437,7 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
| **hookify** *(skills `/hookify` / `/configure` / `/list` / `/help` + `writing-rules` + агент `conversation-analyzer`)* | `anthropics/claude-plugins-official` (Anthropic Verified) | генератор хуков из анализа транскриптов диалога / явных инструкций. Категория: **authoring-tooling** (Tooling #58) | **только по явному `/hookify`**, не проактивно (HK2). **HK1 hard-rule:** перед генерацией хука — обязательный pre-check на коллизию с уже-зарегистрированными хуками в `~/.claude/settings.json`; перезапись 6-компонентной economy/skill-discipline архитектуры (economy-mode / skill-marker / skill-check / state-guard / postcompact / verifier) **запрещена**; при коллизии — остановка, ручное согласование. HK3 — закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`. Не UI → вне R6.0/R6.1/R14 |
|
||||
| **claude-code-setup** *(skill `claude-automation-recommender`)* | `anthropics/claude-plugins-official` (Anthropic Verified) | рекомендатель Claude Code automations — анализ кодовой базы + советы (хуки / суб-агенты / скилы / плагины / MCP). Read-only. Категория: **dev-support** (Tooling #59, вне UI-пула) | при запросе на оптимизацию Claude Code setup. CCS1 — рекомендации фильтруются R0 stack-gate + R10.1; ничего не устанавливается без явного согласования заказчика. Не UI → вне R6.0/R6.1/R14 |
|
||||
| **context7** *(MCP-tools `query-docs` / `resolve-library-id`)* | `anthropics/claude-plugins-official` (Anthropic Verified) — плагин в `enabledPlugins`, не `.mcp.json`-сервер | актуальная документация библиотек / фреймворков / SDK — отдаёт upstream-доки, обходит cutoff training data. Категория: **dev-support** (Tooling #60) | **первый выбор** для документации **известной библиотеки** (Laravel / Vue / Vuetify / Pest / React / …). CTX1 — WebFetch для конкретного URL, WebSearch — поиск без знания библиотеки. Не UI → вне R6.0/R6.1/R14 |
|
||||
| **finance** *(8 skills: `reconciliation` / `variance-analysis` / `financial-statements` / `close-management` / `journal-entry` / `journal-entry-prep` / `sox-testing` / `audit-support`)* | `anthropics/knowledge-work-plugins` (plugin `finance@knowledge-work-plugins`, Anthropic Verified, v1.2.0) | финансы/бухгалтерия — сверка, анализ отклонений, US-GAAP-отчётность, закрытие периода, проводки. Категория: **finance-tooling** (Tooling #61, вне UI-пула). Homed C7, cross-ref C6 | при учётно-финансовой работе. Применимость РФ: ✅ reconciliation/variance; ⚠️ US-GAAP-скилы частично; ❌ SOX-скилы not-applicable; warehouse-MCP DEFERRED (ADR-012). Не UI → вне R6.0/R6.1/R14 |
|
||||
|
||||
**Блок 1 — note (v3.3):** **mermaid-skill** (Tooling #37, генератор C4/architecture-диаграмм) — вендоренный сторонний скил в `.claude/skills/mermaid/` (`WH-2099/mermaid-skill`, MIT), **не** через marketplace и **не** в `enabledPlugins`. Пассивная утилита (генерация Mermaid-исходника), не решатель — формально вне типологии трёх блоков; регистрируется здесь для полноты. Категория **architecture-tooling**, вне R6/R14.
|
||||
|
||||
@@ -450,6 +453,8 @@ Stack — **головной**. Все плагины вне stack'а — **ин
|
||||
|
||||
**Блок 1 — note (v3.13):** 5 Anthropic dev-плагинов — **skill-creator** (#56) / **plugin-dev** (#57) / **hookify** (#58) / **claude-code-setup** (#59) / **context7** (#60) — marketplace-плагины из `anthropics/claude-plugins-official`, включены в `~/.claude/settings.json` `enabledPlugins` user-level. Формализованы 18.05.2026 после аудита «мозга» (L1-паттерн «плагин включён без формализации» — повтор UPM/21st 10.05 и Sentry/Redis 13.05). Две новые off-phase подкатегории: **authoring-tooling** (13-я — #56-#58, создание Claude-артефактов) + **dev-support** (14-я — #59-#60, поддержка/документация Claude-разработки), не UI → вне R6.0/R6.1/R14. **hookify** несёт hard-rule HK1 (pre-check на коллизию с existing хуками). `context7` — плагин из marketplace (не `.mcp.json`-сервер Блока 3), хотя предоставляет MCP-tools. ADR-010, Tooling §4.31–§4.35.
|
||||
|
||||
**Блок 1 — note (v3.18):** **billing-audit** (Tooling #62) + **ru-tax-accounting** (Tooling #63) — self-authored project-скилы в `.claude/skills/billing-audit/` и `.claude/skills/ru-tax-accounting/`, **не** вендоренные и **не** через marketplace; написаны проектом (паттерн `audit-portal`/`regression`/`process-*`/`discovery-interview`). **Линтуются** lefthook'ом (cspell+markdownlint), **не** в ignorePaths (LINT1). Категория **finance-tooling** (15-я off-phase подкатегория, разделы C6/C7 карты), вне R6.0/R6.1/R14. ADR-012.
|
||||
|
||||
**Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие.
|
||||
|
||||
#### Блок 2: Built-in skills Claude Code (всегда доступны через `Skill` tool по `/имя`)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Правила работы Claude в проекте «Лидерра»
|
||||
|
||||
**Версия:** v1.33 (19.05.2026)
|
||||
**Дата:** 19.05.2026
|
||||
**Версия:** v1.34 (20.05.2026)
|
||||
**Дата:** 20.05.2026
|
||||
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
|
||||
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
|
||||
|
||||
**Что изменилось в v1.34 относительно v1.33:** finance-tooling (C6+C7) — §13.2 +абзац «Off-phase finance-tooling»: #61 finance plugin (marketplace `finance@knowledge-work-plugins`, Anthropic Verified, homed C7, cross-ref C6; РФ-применимость частична — US-GAAP-скилы ⚠️, SOX-скилы not-applicable, warehouse-MCP DEFERRED), #62 billing-audit (self-authored project-скил, C6 — денежные инварианты биллинга), #63 ru-tax-accounting (self-authored project-скил, C7 — РСБУ/НК РФ). 15-я off-phase подкатегория. Не UI → вне R6.0/R6.1/R14. Границы — ADR-012. Архитектурных изменений §§1–12, §14–§16: 0. Связано: Tooling v2.18, PSR_v1 v3.18, CLAUDE.md v2.21; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
|
||||
|
||||
**Что изменилось в 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.
|
||||
@@ -758,6 +760,8 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
|
||||
|
||||
**Off-phase authoring-tooling + dev-support (v1.28, 18.05.2026):** 5 Anthropic dev-плагинов из marketplace `anthropics/claude-plugins-official`, уже включённых в `~/.claude/settings.json` `enabledPlugins` user-level — формализованы 18.05.2026 после аудита «мозга» (L1-паттерн «плагин фактически включён без формализации в правилах» — повтор UPM/21st 10.05 и Sentry/Redis 13.05). Подкатегория **authoring-tooling** (тринадцатая, создание Claude-артефактов): #56 `skill-creator` (Tooling §4.31; конструктор standalone-скилов), #57 `plugin-dev` (§4.32; конструктор marketplace-плагинов — 8 sub-skills + 3 агента), #58 `hookify` (§4.33; генератор хуков). Подкатегория **dev-support** (четырнадцатая, поддержка/документация Claude-разработки): #59 `claude-code-setup` (§4.34; рекомендатель Claude Code automations, read-only), #60 `context7` (§4.35; актуальная документация библиотек). Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. **hookify** — особое правило: вызов только по явному `/hookify`, перед генерацией хука обязательный pre-check на коллизию с уже-зарегистрированными хуками в `~/.claude/settings.json` (перезапись 6-компонентной economy/skill-discipline архитектуры запрещена — конфликт-аудит HK1; закрывает 🔴-конфликт карты `hookify_plugin ↔ hk_pre_claude`). Границы D2–D5 — ADR-010. Регулируется PSR_v1 R10.1 Блок 1. Установлены 18.05.2026 на ветке `feat/anthropic-dev-tooling`; план `docs/superpowers/plans/2026-05-18-anthropic-dev-tooling-formalization.md`.
|
||||
|
||||
**Off-phase finance-tooling (C6+C7, v1.34, 20.05.2026):** Инструменты разделов C6 «Финансы — биллинг и тарификация» и C7 «Финансы — бухгалтерия и налоги» карты — #61 `finance` plugin (Tooling §4.36; marketplace `finance@knowledge-work-plugins`, Anthropic Verified, 8 скилов; homed C7, cross-ref C6; РФ-применимость: ✅ reconciliation/variance, ⚠️ US-GAAP-скилы частично, ❌ SOX-скилы not-applicable, warehouse-MCP DEFERRED), #62 `billing-audit` (Tooling §4.37; self-authored project-скил `.claude/skills/billing-audit/` — денежные инварианты биллинга C6: сохранение суммы bcmath, идемпотентность, tier-резолюция, дрейф reconcile, charge_source), #63 `ru-tax-accounting` (Tooling §4.38; self-authored project-скил `.claude/skills/ru-tax-accounting/` — РСБУ/НК РФ контекст C7: НДС/УСН, налоговая база, выгрузки бухгалтеру; закрывает РФ-gap US-плагина). Плюс reuse-классификация существующих узлов в C6/C7 через `NODE_SECTION_SECONDARY` (Boost/Pest/Larastan/Sentry/Redis/PM metrics-review/data-scientist/operations/process-*/context7) — без новых номеров. **Пятнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. self-authored скилы billing-audit/ru-tax-accounting **линтуются** (не в ignorePaths, LINT1). Границы — ADR-012 (граница C6↔C7: начисление клиенту vs учёт/налоги компании; FIN1–FIN8). Регулируется PSR_v1 R10.1 Блок 1 (finance plugin) + note (2 self-authored скила). Установлено 20.05.2026 на ветке `worktree-finance-tooling-c6-c7`; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
|
||||
|
||||
### 13.3. Скоуп
|
||||
|
||||
| Тип задачи | Кто отвечает |
|
||||
|
||||
+65
-3
File diff suppressed because one or more lines are too long
@@ -0,0 +1,36 @@
|
||||
# ADR-012: Finance-tooling — наполнение разделов карты C6 + C7
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-20
|
||||
**Контекст:** эпик finance-tooling (объединённые C6+C7), spec `docs/superpowers/specs/2026-05-20-finance-tooling-c6-c7-design.md`.
|
||||
|
||||
## Context
|
||||
|
||||
Разделы карты C6 «Финансы — биллинг и тарификация» и C7 «Финансы — бухгалтерия и
|
||||
налоги» были пусты. Биллинг-подсистема (Plan 4) велика в коде, но dedicated dev-tooling
|
||||
скуден. Заказчик решил объединить C6+C7 в один эпик и покрыть полностью.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **finance plugin (#61)** (knowledge-work-plugins) — homed **C7** (primary), cross-ref C6.
|
||||
- ✅ C6: `reconciliation`, `variance-analysis`.
|
||||
- ⚠️ C7 частично (US-GAAP): `financial-statements`, `close-management`, `journal-entry`, `journal-entry-prep`.
|
||||
- ❌ not-applicable РФ: `sox-testing`, `audit-support` (нет SOX у частной РФ-компании).
|
||||
- DEFERRED: warehouse-MCP (snowflake/databricks/bigquery) — не стек проекта (PG+Redis).
|
||||
2. **billing-audit (#62)** — self-authored project-скил, C6. Денежные инварианты Лидерры.
|
||||
3. **ru-tax-accounting (#63)** — self-authored project-скил, C7. РСБУ/НК РФ. Закрывает gap US-плагина.
|
||||
4. **Граница C6 ↔ C7:** C6 = начисление денег клиенту за лиды; C7 = учёт и налоги компании.
|
||||
Точка стыка: billing-выручка (`lead_charges`/`LedgerService`) — выход C6 и вход C7.
|
||||
5. **Reuse** существующих узлов в C6/C7 через `NODE_SECTION_SECONDARY` (см. spec §6).
|
||||
|
||||
## Boundaries (конфликт-аудит)
|
||||
|
||||
- FIN1 warehouse-MCP → DEFERRED. FIN2 SOX → not-applicable РФ. FIN3 finance vs operations.
|
||||
- FIN4 finance reconciliation (инструмент) vs CsvReconcileJob (код). FIN5 billing-audit vs process-*/D3.
|
||||
- FIN6 ru-tax vs finance plugin vs D1/D2. FIN7 граница C6↔C7. FIN8 self-authored скилы линтуются.
|
||||
|
||||
## Consequences
|
||||
|
||||
- C6/C7 карты непусты. Новая off-phase подкатегория `finance-tooling` (15-я).
|
||||
- Реальный платёжный провайдер и warehouse-аналитика — DEFERRED (Б-1 / вне стека).
|
||||
- ru-tax-accounting — контекст/выгрузки, не налоговая консультация (бухгалтерия вне репо).
|
||||
@@ -0,0 +1,621 @@
|
||||
// ════════════════════════════════════════════════════
|
||||
// automation-graph-data.js — shared topology constants
|
||||
// Consumed by:
|
||||
// • docs/automation-graph.html (classic <script>, reads bare consts via shared lexical scope)
|
||||
// • docs/observer/dashboard.html (classic <script>, same mechanism)
|
||||
// Do NOT add ES-module syntax (import/export) — keep as classic script.
|
||||
// ════════════════════════════════════════════════════
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 1: NODES
|
||||
// ════════════════════════════════════════════════════
|
||||
|
||||
// Радиально-секторная компоновка.
|
||||
// Сектора (по 90°): N=workflow (0–90), E=UI (90–180), S=infra (180–270), W=data/RLS (270–360).
|
||||
const RADII = [0, 220, 400, 600, 800, 1000, 1180];
|
||||
function pos(ring, angleDeg) {
|
||||
const r = RADII[ring];
|
||||
const a = angleDeg * Math.PI / 180;
|
||||
return { x: Math.round(r * Math.cos(a)), y: Math.round(r * Math.sin(a)) };
|
||||
}
|
||||
|
||||
const NODES = [
|
||||
// ── ПРАВИЛА (5) ── центр + первое кольцо ───────
|
||||
{ id: 'pravila', label: 'Pravila v1.34', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
|
||||
{ id: 'claude_md', label: 'CLAUDE.md v2.21', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
|
||||
{ id: 'psr_v1', label: 'PSR_v1 v3.18', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
|
||||
{ id: 'tooling', label: 'Tooling v2.18', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
|
||||
{ id: 'router_procedure', label: 'router-procedure v1.0', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
|
||||
|
||||
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
|
||||
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
|
||||
{ id: 'fd_plugin', label: 'Frontend Design', group: 'plugins', size: 26, ring: 2, ...pos(2, 135) },
|
||||
{ id: 'upm', label: 'UI UX Pro Max', group: 'plugins', size: 22, ring: 2, ...pos(2, 165) },
|
||||
{ id: 'claude_md_mgmt', label: 'claude-md-mgmt', group: 'plugins', size: 22, ring: 2, ...pos(2, 225) },
|
||||
{ id: 'hookify_plugin', label: 'hookify (плагин)', group: 'plugins', size: 22, ring: 2, ...pos(2, 200) },
|
||||
{ id: 'skill_creator', label: 'skill-creator', group: 'plugins', size: 20, ring: 2, ...pos(2, 70) },
|
||||
{ id: 'claude_setup', label: 'claude-code-setup', group: 'plugins', size: 22, ring: 2, ...pos(2, 90) },
|
||||
{ id: 'plugin_dev', label: 'plugin-dev', group: 'plugins', size: 22, ring: 2, ...pos(2, 290) },
|
||||
{ id: 'context7', label: 'context7 (docs MCP)', group: 'plugins', size: 20, ring: 2, ...pos(2, 315) },
|
||||
// A6 architecture-tooling — adr-kit / architecture-patterns (плагины) + deptrac (composer dev-dep, job 10) — раздел «Архитектура систем»
|
||||
{ id: 'adr_kit', label: 'adr-kit', group: 'plugins', size: 22, ring: 2, ...pos(2, 240) },
|
||||
{ id: 'arch_patterns', label: 'architecture-patterns',group: 'plugins', size: 20, ring: 2, ...pos(2, 250) },
|
||||
{ id: 'deptrac', label: 'deptrac', group: 'plugins', size: 20, ring: 2, ...pos(2, 260) },
|
||||
// D3 audit-security (17.05.2026) — 2 плагина раздела «Аудит и управление рисками»
|
||||
{ id: 'tob_skills', label: 'Trail of Bits\nskills', group: 'plugins', size: 22, ring: 2, ...pos(2, 330) },
|
||||
{ id: 'sec_guidance', label: 'Security\nGuidance', group: 'plugins', size: 20, ring: 2, ...pos(2, 345) },
|
||||
// C9 project-management-tooling (17.05.2026) — плагин раздела «Управление проектами»
|
||||
{ id: 'product_mgmt', label: 'product-\nmanagement', group: 'plugins', size: 20, ring: 2, ...pos(2, 355) },
|
||||
// A4 design-tooling (17.05.2026) — раздел «Дизайн (UI/UX, графика, бренд)» (плагины)
|
||||
{ id: 'design_plugin', label: 'Design\nplugin', group: 'plugins', size: 20, ring: 2, ...pos(2, 155) },
|
||||
|
||||
// ── СКИЛЫ SUPERPOWERS (14) — N sector (0–90) ────
|
||||
{ id: 'sk_brainstorm', label: 'brainstorming', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 5) },
|
||||
{ id: 'sk_wplans', label: 'writing-plans', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 11) },
|
||||
{ id: 'sk_eplans', label: 'executing-plans', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 17) },
|
||||
{ id: 'sk_subagent', label: 'subagent-driven', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 23) },
|
||||
{ id: 'sk_tdd', label: 'TDD', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 29) },
|
||||
{ id: 'sk_verify', label: 'verification-before-completion', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 36) },
|
||||
{ id: 'sk_debug', label: 'systematic-debugging', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 43) },
|
||||
{ id: 'sk_parallel', label: 'parallel-work', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 50) },
|
||||
{ id: 'sk_worktree', label: 'worktree', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 57) },
|
||||
{ id: 'sk_pr', label: 'finishing-pr', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 64) },
|
||||
{ id: 'sk_coderev', label: 'code-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 71) },
|
||||
{ id: 'sk_spreview', label: 'spec-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 78) },
|
||||
{ id: 'sk_wskills', label: 'writing-skills', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 85) },
|
||||
{ id: 'sk_elements', label: 'elements-of-style', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 92) },
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА (6) — W sector (RLS/arch/audit) ────
|
||||
{ id: 'sk_rls', label: 'rls-check', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 305) },
|
||||
{ id: 'sk_qitem', label: 'q-item-add', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 220) },
|
||||
{ id: 'sk_regression', label: 'regression', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 260) },
|
||||
// A6 architecture-tooling (17.05.2026) — вендоренный скил диаграмм
|
||||
{ id: 'mermaid_skill', label: 'mermaid (skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 280) },
|
||||
// D3 audit-security (17.05.2026) — скилы раздела «Аудит и управление рисками»
|
||||
{ id: 'sk_security_review', label: 'security-review', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 315) },
|
||||
{ id: 'sk_audit_portal', label: 'audit-portal', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 325) },
|
||||
// C9 project-management-tooling (17.05.2026) — вендоренный скил раздела «Управление проектами»
|
||||
{ id: 'ccpm', label: 'CCPM\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 335) },
|
||||
// A11 ml-ai-tooling (17.05.2026) — скилы и CLI раздела «ML / AI-разработка»
|
||||
{ id: 'claude_api', label: 'claude-api\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 345) },
|
||||
{ id: 'data_scientist', label: 'Data Scientist\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 355) },
|
||||
{ id: 'promptfoo', label: 'promptfoo', group: 'plugins', size: 20, ring: 2, ...pos(2, 365) },
|
||||
// C10 business-process (17.05.2026) — плагин и скилы раздела «Бизнес-процессы (общее)»
|
||||
{ id: 'ops_plugin', label: 'operations\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 385) },
|
||||
{ id: 'process_modeling', label: 'process-modeling\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 367) },
|
||||
{ id: 'process_analysis', label: 'process-analysis\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 377) },
|
||||
// discovery-tooling (18.05.2026) — self-authored скил интервью-discovery
|
||||
{ id: 'discovery_interview', label: 'discovery-interview\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 387) },
|
||||
// finance-tooling C6+C7 (20.05.2026) — разделы «Финансы»
|
||||
{ id: 'finance_plugin', label: 'finance\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 200) },
|
||||
{ id: 'billing_audit', label: 'billing-audit\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 397) },
|
||||
{ id: 'ru_tax', label: 'ru-tax-accounting\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 407) },
|
||||
// brain governance iter9 (19.05.2026) — проектный скил факторного анализа
|
||||
{ id: 'sk_brain_retro', label: '/brain-retro\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 210) },
|
||||
|
||||
// ── ХУКИ (13) — S+infra + E (economy/skill/brain) ───
|
||||
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
|
||||
{ id: 'hk_economy', label: 'UserPromptSubmit:\neconomy-mode', group: 'hooks', size: 22, ring: 4, ...pos(4, 95) },
|
||||
{ id: 'hk_pre_claude', label: 'PreToolUse:\nCLAUDE.md-warn', group: 'hooks', size: 22, ring: 4, ...pos(4, 215) },
|
||||
{ id: 'hk_post_md', label: 'PostToolUse:\nmarkdownlint', group: 'hooks', size: 20, ring: 4, ...pos(4, 195) },
|
||||
{ id: 'hk_post_schema', label: 'PostToolUse:\nschema-changelog',group: 'hooks', size: 20, ring: 4, ...pos(4, 300) },
|
||||
{ id: 'hk_self_check', label: 'SessionStart:\neconomy-self-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 105) },
|
||||
{ id: 'hk_skill_marker', label: 'PreToolUse:\nskill-marker', group: 'hooks', size: 20, ring: 4, ...pos(4, 115) },
|
||||
{ id: 'hk_skill_check', label: 'PreToolUse:\nskill-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 125) },
|
||||
{ id: 'hk_state_guard', label: 'PreToolUse:\neconomy-state-guard', group: 'hooks', size: 20, ring: 4, ...pos(4, 135) },
|
||||
{ id: 'hk_postcompact', label: 'PostCompact:\neconomy-postcompact', group: 'hooks', size: 20, ring: 4, ...pos(4, 145) },
|
||||
{ id: 'hk_verifier', label: 'Stop:\neconomy-verifier (агент)', group: 'hooks', size: 22, ring: 4, ...pos(4, 155) },
|
||||
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: 'ruflo', size: 20, ring: 4, ...pos(4, 165) },
|
||||
// brain governance iter9 (19.05.2026) — Stop-хук observer
|
||||
{ id: 'observer_stophook', label: 'Stop:\nobserver-stop-hook', group: 'hooks', size: 22, ring: 4, ...pos(4, 205) },
|
||||
|
||||
// ── АГЕНТЫ (11) — N (workflow) + W (RLS) ──────
|
||||
{ id: 'ag_explore', label: 'Explore', group: 'agents', size: 20, ring: 4, ...pos(4, 10) },
|
||||
{ id: 'ag_general', label: 'general-purpose', group: 'agents', size: 20, ring: 4, ...pos(4, 25) },
|
||||
{ id: 'ag_plan', label: 'Plan', group: 'agents', size: 20, ring: 4, ...pos(4, 40) },
|
||||
{ id: 'ag_pest', label: 'pest-parallel-debugger', group: 'agents', size: 24, ring: 4, ...pos(4, 55) },
|
||||
{ id: 'ag_guide', label: 'claude-code-guide', group: 'agents', size: 18, ring: 4, ...pos(4, 70) },
|
||||
{ id: 'ag_statusline', label: 'statusline-setup', group: 'agents', size: 18, ring: 4, ...pos(4, 85) },
|
||||
{ id: 'ag_hookify', label: 'hookify:\nconversation-analyzer', group: 'agents', size: 18, ring: 4, ...pos(4, 230) },
|
||||
{ id: 'ag_pcreator', label: 'plugin-dev:\nagent-creator', group: 'agents', size: 16, ring: 4, ...pos(4, 245) },
|
||||
{ id: 'ag_pvalid', label: 'plugin-dev:\nplugin-validator',group: 'agents', size: 16, ring: 4, ...pos(4, 260) },
|
||||
{ id: 'ag_skreview', label: 'plugin-dev:\nskill-reviewer', group: 'agents', size: 16, ring: 4, ...pos(4, 275) },
|
||||
{ id: 'ag_rls', label: 'rls-reviewer', group: 'agents', size: 22, ring: 4, ...pos(4, 315) },
|
||||
// A3 integration-tooling (17.05.2026) — agent раздела «Программирование — интеграции»
|
||||
{ id: 'ag_apidocs', label: 'api-docs (agent)', group: 'agents', size: 18, ring: 4, ...pos(4, 175) },
|
||||
|
||||
// ── MCP-СЕРВЕРЫ (9) — E (UI) + W (data) ───────
|
||||
{ id: 'mcp_21st', label: 'MCP: 21st.dev Magic', group: 'mcp', size: 20, ring: 5, ...pos(5, 130) },
|
||||
// A4 design-tooling (17.05.2026) — MCP-серверы раздела «Дизайн (UI/UX, графика, бренд)»
|
||||
{ id: 'mcp_figma', label: 'MCP: Figma\n(DEFERRED)', group: 'mcp', size: 18, ring: 5, ...pos(5, 140) },
|
||||
{ id: 'mcp_icons', label: 'MCP: Universal\nIcons', group: 'mcp', size: 18, ring: 5, ...pos(5, 120) },
|
||||
{ id: 'mcp_pw', label: 'MCP: playwright', group: 'mcp', size: 22, ring: 5, ...pos(5, 110) },
|
||||
{ id: 'mcp_gh', label: 'MCP: github', group: 'mcp', size: 22, ring: 5, ...pos(5, 75) },
|
||||
{ id: 'mcp_boost', label: 'MCP: laravel-boost', group: 'mcp', size: 24, ring: 5, ...pos(5, 290) },
|
||||
{ id: 'mcp_redis', label: 'MCP: redis', group: 'mcp', size: 22, ring: 5, ...pos(5, 310) },
|
||||
{ id: 'mcp_sentry', label: 'MCP: sentry', group: 'mcp', size: 22, ring: 5, ...pos(5, 330) },
|
||||
{ id: 'mcp_semgrep', label: 'MCP: semgrep', group: 'mcp', size: 20, ring: 5, ...pos(5, 350) },
|
||||
// A3 integration-tooling (17.05.2026) — MCP-сервер раздела «Программирование — интеграции»
|
||||
{ id: 'mcp_openapi', label: 'MCP: openapi', group: 'mcp', size: 20, ring: 5, ...pos(5, 5) },
|
||||
|
||||
// ── LEFTHOOK JOBS (15) — S+W (infra/data/brain) ─────
|
||||
{ id: 'lh_mdlint', label: 'lefthook:\nmarkdownlint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 185) },
|
||||
{ id: 'lh_cspell', label: 'lefthook:\ncspell', group: 'lefthook', size: 18, ring: 5, ...pos(5, 200) },
|
||||
{ id: 'lh_stylelint', label: 'lefthook:\nstylelint', group: 'lefthook', size: 16, ring: 5, ...pos(5, 215) },
|
||||
{ id: 'lh_eslint', label: 'lefthook:\neslint-vue', group: 'lefthook', size: 18, ring: 5, ...pos(5, 230) },
|
||||
{ id: 'lh_lychee', label: 'lefthook:\nlychee-links', group: 'lefthook', size: 18, ring: 5, ...pos(5, 245) },
|
||||
{ id: 'lh_gitleaks', label: 'lefthook:\ngitleaks', group: 'lefthook', size: 18, ring: 5, ...pos(5, 260) },
|
||||
{ id: 'lh_gitleaks2', label: 'lefthook:\ngitleaks pre-push', group: 'lefthook', size: 18, ring: 5, ...pos(5, 275) },
|
||||
{ id: 'lh_pint', label: 'lefthook:\npint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 25) },
|
||||
{ id: 'lh_larastan', label: 'lefthook:\nlarastan', group: 'lefthook', size: 18, ring: 5, ...pos(5, 50) },
|
||||
{ id: 'lh_squawk', label: 'lefthook:\nsquawk', group: 'lefthook', size: 18, ring: 5, ...pos(5, 320) },
|
||||
// brain governance iter9 (19.05.2026) — 5 контролёров C1-C5 (lefthook jobs 11-15)
|
||||
{ id: 'lh_l1watcher', label: 'lefthook:\nl1-watcher (C1)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 150) },
|
||||
{ id: 'lh_crossref', label: 'lefthook:\ncross-ref-checker (C2)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 157) },
|
||||
{ id: 'lh_obs_obs', label: 'lefthook:\nobserver-of-observer (C3)',group: 'lefthook', size: 16, ring: 5, ...pos(5, 164) },
|
||||
{ id: 'lh_status_md', label: 'lefthook:\nstatus-md (C4)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 171) },
|
||||
{ id: 'lh_obs_cov', label: 'lefthook:\nobserver-coverage (C5)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 178) },
|
||||
|
||||
// ── MEMORY FILES (24) — внешнее кольцо ──────────
|
||||
{ id: 'mem_user', label: 'memory:\nuser_profile', group: 'memory', size: 16, ring: 6, ...pos(6, 0) },
|
||||
{ id: 'mem_comm', label: 'memory:\nfeedback_comm', group: 'memory', size: 14, ring: 6, ...pos(6, 24) },
|
||||
{ id: 'mem_env', label: 'memory:\nfeedback_env', group: 'memory', size: 16, ring: 6, ...pos(6, 48) },
|
||||
{ id: 'mem_sp', label: 'memory:\nfeedback_superpowers',group: 'memory', size: 16, ring: 6, ...pos(6, 72) },
|
||||
{ id: 'mem_plugins', label: 'memory:\nfeedback_plugins', group: 'memory', size: 16, ring: 6, ...pos(6, 96) },
|
||||
{ id: 'mem_handoff', label: 'memory:\nreference_handoff', group: 'memory', size: 14, ring: 6, ...pos(6, 120) },
|
||||
{ id: 'mem_redesign', label: 'memory:\nportal_redesign', group: 'memory', size: 14, ring: 6, ...pos(6, 144) },
|
||||
{ id: 'mem_devindices', label: 'memory:\ndev_indices', group: 'memory', size: 12, ring: 6, ...pos(6, 168) },
|
||||
{ id: 'mem_phase1', label: 'memory:\nphase1_strategy', group: 'memory', size: 14, ring: 6, ...pos(6, 192) },
|
||||
{ id: 'mem_state', label: 'memory:\nproject_state', group: 'memory', size: 16, ring: 6, ...pos(6, 216) },
|
||||
{ id: 'mem_brain', label: 'memory:\nclaude_brain', group: 'memory', size: 14, ring: 6, ...pos(6, 240) },
|
||||
{ id: 'mem_supplier', label: 'memory:\nsupplier_integration',group: 'memory', size: 14, ring: 6, ...pos(6, 264) },
|
||||
{ id: 'mem_audit', label: 'memory:\naudit_2026-05-13', group: 'memory', size: 14, ring: 6, ...pos(6, 288) },
|
||||
{ id: 'mem_archive', label: 'memory:\nreference_archive', group: 'memory', size: 14, ring: 6, ...pos(6, 312) },
|
||||
{ id: 'mem_github', label: 'memory:\nreference_github', group: 'memory', size: 14, ring: 6, ...pos(6, 336) },
|
||||
{ id: 'mem_audit_b', label: 'memory:\naudit_B_status', group: 'memory', size: 12, ring: 6, ...pos(6, 12) },
|
||||
{ id: 'mem_audit_c', label: 'memory:\naudit_C_pending', group: 'memory', size: 12, ring: 6, ...pos(6, 36) },
|
||||
{ id: 'mem_suppliercrm',label: 'memory:\nsupplier_crm', group: 'memory', size: 12, ring: 6, ...pos(6, 60) },
|
||||
{ id: 'mem_audit12', label: 'memory:\nfull_audit_05-12', group: 'memory', size: 12, ring: 6, ...pos(6, 84) },
|
||||
{ id: 'mem_audit14', label: 'memory:\nfull_audit_05-14', group: 'memory', size: 12, ring: 6, ...pos(6, 108) },
|
||||
{ id: 'mem_sprint1', label: 'memory:\nsprint1_p0_closure', group: 'memory', size: 12, ring: 6, ...pos(6, 132) },
|
||||
{ id: 'mem_sprint2', label: 'memory:\nsprint2_p1_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 156) },
|
||||
{ id: 'mem_sprint3', label: 'memory:\nsprint3_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 180) },
|
||||
// brain governance iter9 (19.05.2026) — хранилище evidence «мозга»
|
||||
{ id: 'observer_evidence', label: 'docs/observer/\nepisodes+STATUS', group: 'memory', size: 16, ring: 6, ...pos(6, 204) },
|
||||
|
||||
// ── RUFLO ОРКЕСТРАТОР (9) — фактический реколлаж iter5 — кластер вне радиального layout (верх-лево) ──
|
||||
{ id: 'ruflo_queen', label: 'ruflo Queen\n(hive-mind)', group: 'ruflo', size: 44, x: -1340, y: -700 },
|
||||
{ id: 'ruflo_plugins', label: 'плагины ruflo\n0 из 20 · скилов 0', group: 'ruflo', size: 20, x: -1340, y: -880 },
|
||||
{ id: 'ruflo_workers', label: '10 воркеров\nhive-mind (idle)', group: 'ruflo', size: 26, x: -1160, y: -800 },
|
||||
{ id: 'ruflo_agents_catalog', label: 'каталог агентов ruflo\n(100 определений)', group: 'ruflo', size: 24, x: -1530, y: -830 },
|
||||
{ id: 'ruflo_commands', label: 'slash-команды\nruflo (88)', group: 'ruflo', size: 22, x: -1140, y: -630 },
|
||||
{ id: 'ruflo_daemon', label: 'демон ruflo\n(воркеры падают)', group: 'ruflo', size: 24, x: -1560, y: -650 },
|
||||
{ id: 'ruflo_memory', label: 'память ruflo\n(~0 записей)', group: 'ruflo', size: 24, x: -1380, y: -500 },
|
||||
{ id: 'ruflo_mcp', label: 'ruflo MCP\n(~210 инструментов)', group: 'ruflo', size: 26, x: -1190, y: -460 },
|
||||
{ id: 'ruflo_recall_hook', label: 'хук recall\n(UserPromptSubmit)', group: 'ruflo', size: 22, x: -1570, y: -470 },
|
||||
|
||||
// ── MEMORY +1 (артефакт ruflo big-bang) ──
|
||||
{ id: 'mem_ruflo', label: 'memory:\nproject_ruflo_integration', group: 'memory', size: 14, x: -1740, y: -620 },
|
||||
];
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 2: EDGES
|
||||
// ════════════════════════════════════════════════════
|
||||
const CONFLICT_TYPES = {
|
||||
RED: { color: '#ff5f57', bg: '#2d0000', emoji: '🔴', label: 'Не закрыт правилом', rank: 1 },
|
||||
BLACK: { color: '#888888', bg: '#1a1a1a', emoji: '⚫', label: 'Возник на практике', rank: 2 },
|
||||
GREEN: { color: '#859900', bg: '#0e1a00', emoji: '🟢', label: 'Закрыт правилом', rank: 3 },
|
||||
};
|
||||
const E = (from, to, label) => ({
|
||||
from, to,
|
||||
title: label,
|
||||
color: { color: '#586e75', highlight: '#93a1a1', hover: '#93a1a1' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.6 } },
|
||||
smooth: { type: 'continuous', roundness: 0.5 }
|
||||
});
|
||||
const CONFLICT = (from, to, label, type = 'RED') => ({
|
||||
from, to,
|
||||
title: label,
|
||||
label: CONFLICT_TYPES[type].emoji,
|
||||
dashes: true,
|
||||
width: 2,
|
||||
color: { color: CONFLICT_TYPES[type].color, highlight: '#ff8880', hover: '#ff8880' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.7 }, from: { enabled: true, scaleFactor: 0.7 } },
|
||||
font: { color: CONFLICT_TYPES[type].color, size: 14, align: 'middle', strokeWidth: 3, strokeColor: '#1e1e2e' },
|
||||
smooth: { type: 'curvedCW', roundness: 0.35 }
|
||||
});
|
||||
|
||||
const EDGES = [
|
||||
// ── ПРАВИЛА — иерархия ──────────────────────────
|
||||
E('pravila', 'claude_md', 'подчиняет\n(уровень 1→2a)'),
|
||||
E('pravila', 'psr_v1', 'подчиняет\n(уровень 1→3)'),
|
||||
E('claude_md', 'tooling', 'ссылается\nна реестр'),
|
||||
E('pravila', 'superpowers', '§12: обязывает\nинвокировать 1-м'),
|
||||
|
||||
// ── PSR_v1 координирует плагины ─────────────────
|
||||
E('psr_v1', 'superpowers', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'fd_plugin', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'upm', 'R14.3: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'mcp_21st', 'R14.4: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'claude_md_mgmt','R10.1 блок 1:\nинфраструктурный'),
|
||||
|
||||
// ── CLAUDE.md ────────────────────────────────────
|
||||
E('claude_md', 'mcp_boost', 'описывает §3.2'),
|
||||
E('claude_md', 'mcp_sentry', 'описывает §4.8'),
|
||||
E('claude_md', 'mcp_redis', 'описывает §4.9'),
|
||||
E('claude_md', 'claude_md_mgmt', '§5п.10:\nединственный канал'),
|
||||
E('claude_md', 'ag_pest', 'описывает\nкогда вызывать'),
|
||||
E('claude_md', 'ag_rls', 'описывает\nкогда вызывать'),
|
||||
|
||||
// ── ХУКИ ────────────────────────────────────────
|
||||
E('hk_pre_claude', 'claude_md', 'проверяет\nпри Edit/Write'),
|
||||
E('hk_post_md', 'lh_mdlint', 'дублирует задачу\n(локально)'),
|
||||
E('hk_post_schema', 'claude_md', 'напоминает про\nCHANGELOG_schema'),
|
||||
E('hk_session', 'mem_user', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_env', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_sp', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_plugins', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_state', 'читает\nпри старте'),
|
||||
E('hk_economy', 'superpowers', 'парсит уровень\nэкономии'),
|
||||
|
||||
// ── SUPERPOWERS содержит скилы ──────────────────
|
||||
E('superpowers', 'sk_brainstorm', 'содержит'),
|
||||
E('superpowers', 'sk_tdd', 'содержит'),
|
||||
E('superpowers', 'sk_debug', 'содержит'),
|
||||
E('superpowers', 'sk_wplans', 'содержит'),
|
||||
E('superpowers', 'sk_eplans', 'содержит'),
|
||||
E('superpowers', 'sk_verify', 'содержит'),
|
||||
E('superpowers', 'sk_parallel', 'содержит'),
|
||||
E('superpowers', 'sk_worktree', 'содержит'),
|
||||
E('superpowers', 'sk_pr', 'содержит'),
|
||||
E('superpowers', 'sk_subagent', 'содержит'),
|
||||
E('superpowers', 'sk_wskills', 'содержит'),
|
||||
E('superpowers', 'sk_spreview', 'содержит'),
|
||||
E('superpowers', 'sk_coderev', 'содержит'),
|
||||
E('superpowers', 'sk_elements', 'содержит'),
|
||||
|
||||
// ── СКИЛЫ вызывают друг друга ───────────────────
|
||||
E('sk_brainstorm', 'sk_wplans', 'вызывает\nпосле дизайна'),
|
||||
E('sk_wplans', 'sk_eplans', 'вызывает\nдля выполнения'),
|
||||
E('sk_wplans', 'sk_subagent','альтернатива\nexecuting-plans'),
|
||||
E('sk_subagent', 'ag_explore', 'запускает\nдля поиска'),
|
||||
E('sk_subagent', 'ag_general', 'запускает\nдля задач'),
|
||||
E('sk_subagent', 'ag_plan', 'запускает\nдля архитектуры'),
|
||||
E('sk_parallel', 'sk_worktree','использует\nдля изоляции'),
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА ───────────────────────────────
|
||||
E('sk_rls', 'tooling', 'использует\nsquawk + grep §3.2'),
|
||||
E('sk_rls', 'mcp_boost', 'SQL запросы\nк схеме'),
|
||||
E('sk_qitem', 'claude_md_mgmt','делегирует\nправку CLAUDE.md'),
|
||||
|
||||
// ── CLAUDE-MD-MGMT ──────────────────────────────
|
||||
E('claude_md_mgmt', 'claude_md', 'единственный\nканал правок'),
|
||||
|
||||
// ── HOOKIFY ─────────────────────────────────────
|
||||
E('ag_hookify', 'hookify_plugin', 'передаёт\nанализ'),
|
||||
E('hookify_plugin', 'hk_pre_claude', 'может создавать\nновые хуки'),
|
||||
E('hookify_plugin', 'hk_economy', 'может создавать\nновые хуки'),
|
||||
|
||||
// ── АГЕНТЫ используют MCP ───────────────────────
|
||||
E('ag_pest', 'mcp_redis', 'читает\nочереди/кэш'),
|
||||
E('ag_rls', 'mcp_boost', 'SQL запросы\nк БД'),
|
||||
E('ag_guide', 'mcp_gh', 'ищет\nв репозитории'),
|
||||
|
||||
// ── LEFTHOOK вызывается git ──────────────────────
|
||||
E('lh_gitleaks', 'mem_plugins', 'блокирует коммит\nпри ПДн в staged'),
|
||||
E('lh_larastan', 'mcp_boost', 'Boost даёт\nконтекст типов'),
|
||||
E('lh_squawk', 'tooling', 'соответствует\n§3.2 #15'),
|
||||
E('lh_gitleaks2', 'lh_gitleaks', 'строже:\nвся история'),
|
||||
E('lh_lychee', 'claude_md', 'проверяет\nссылки в .md'),
|
||||
|
||||
// ── MEMORY читается Claude ──────────────────────
|
||||
E('mem_env', 'ag_pest', 'квирки 73/77\nиспользует агент'),
|
||||
E('mem_plugins', 'psr_v1', 'отражает\nтекущие версии'),
|
||||
E('mem_archive', 'claude_md', 'синхронизирует\nверсии доков'),
|
||||
|
||||
// ── MCP ─────────────────────────────────────────
|
||||
E('mcp_pw', 'hk_session', 'используется\nдля a11y smoke'),
|
||||
E('mcp_gh', 'sk_pr', 'PR, issues\nпри finishing-pr'),
|
||||
E('mcp_boost', 'ag_rls', 'схема БД\nдля RLS-review'),
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — связи новых узлов ──
|
||||
// 4 ребра psr_v1→skill_creator/claude_setup/plugin_dev/context7 — перенесены
|
||||
// в ADT-блок 18.05.2026 (точные категории authoring-tooling/dev-support, дедуп)
|
||||
E('plugin_dev', 'ag_pcreator', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_pvalid', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_skreview', 'содержит\nагента'),
|
||||
E('skill_creator', 'sk_wskills', 'обе создают\nскилы'),
|
||||
E('hk_self_check', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_skill_marker', 'hk_skill_check', 'пара\nmarker/check'),
|
||||
E('hk_skill_check', 'superpowers', 'энфорсит §12:\nскил перед кодом'),
|
||||
E('hk_state_guard', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_postcompact', 'hk_economy', 'переинжект\nрежима после компакта'),
|
||||
E('hk_verifier', 'sk_verify', 'энфорсит\nпроверку готовности'),
|
||||
E('hk_ruflo_queen', 'ruflo_queen', '§14: маршрут\nqueen-задач'),
|
||||
E('sk_regression', 'ag_pest', 'передаёт разбор\nпадений Pest --parallel'),
|
||||
|
||||
// ── A6 ARCHITECTURE-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'adr_kit', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('psr_v1', 'arch_patterns', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('tooling', 'mermaid_skill', '§4.12: реестр\n(вендоренный скил)'),
|
||||
E('psr_v1', 'deptrac', 'R10.1 блок 1 note:\narchitecture-tooling'),
|
||||
|
||||
// ── A4 DESIGN-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'design_plugin', 'R10.1 блок 1:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_icons', 'R10.1 блок 3:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_figma', 'R10.1 блок 3:\ndesign-tooling (DEFERRED)'),
|
||||
|
||||
// ── D3 AUDIT-SECURITY 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'tob_skills', 'R10.1 блок 1:\naudit-security'),
|
||||
E('psr_v1', 'sec_guidance', 'R10.1 блок 1:\naudit-security'),
|
||||
E('tooling', 'tob_skills', '§4.14 #39 — реестр'),
|
||||
E('tooling', 'sec_guidance', '§4.15 #40 — реестр'),
|
||||
E('sk_audit_portal', 'sk_security_review', 'оркеструет\nкак фазу аудита'),
|
||||
E('sk_audit_portal', 'tob_skills', 'оркеструет\nглубокие кампании'),
|
||||
E('sk_audit_portal', 'sk_regression', 'использует\nна фазе тестов'),
|
||||
CONFLICT('tob_skills', 'mcp_semgrep', 'TB1: граница разграничена — Semgrep = inline SAST, Trail of Bits = глубокие on-demand аудит-кампании. Параллельное использование разрешено при разных сценариях.', 'GREEN'),
|
||||
|
||||
// ── A3 INTEGRATION-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'mcp_openapi', 'R10.1 блок 3:\nintegration-tooling'),
|
||||
E('tooling', 'mcp_openapi', '§4.22 #47 — реестр'),
|
||||
E('ag_apidocs', 'mcp_openapi', 'спека → MCP-ресурс'),
|
||||
|
||||
// ── A11 ML-AI-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'promptfoo', 'R10.1 блок 1:\nml-ai-tooling'),
|
||||
E('tooling', 'claude_api', 'reuse — built-in skill\n(PSR_v1 R10.1 блок 2)'),
|
||||
E('tooling', 'data_scientist', '§4.24 #49 — реестр'),
|
||||
|
||||
// ── C10 BUSINESS-PROCESS 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'ops_plugin', 'R10.1 блок 1:\nbusiness-process'),
|
||||
E('tooling', 'process_modeling', '§4.27 #52 — реестр'),
|
||||
E('tooling', 'process_analysis', '§4.28 #53 — реестр'),
|
||||
|
||||
// ── DISCOVERY-TOOLING 18.05.2026 — связи узла discovery-interview ──
|
||||
E('tooling', 'discovery_interview', '§4.30 #55 — реестр'),
|
||||
E('psr_v1', 'discovery_interview', 'R10.1 блок 1 note:\ndiscovery-tooling'),
|
||||
E('discovery_interview', 'sk_brainstorm', 'хэндофф:\nFEATURE-brief'),
|
||||
E('discovery_interview', 'process_analysis', 'граница: слой-источник\n(ADR-009 DI2)'),
|
||||
|
||||
// ── ANTHROPIC DEV-TOOLING 18.05.2026 — связи 5 узлов ──
|
||||
E('psr_v1', 'skill_creator', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'plugin_dev', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'hookify_plugin', 'R10.1 блок 1:\nauthoring-tooling (HK1)'),
|
||||
E('psr_v1', 'claude_setup', 'R10.1 блок 1:\ndev-support'),
|
||||
E('psr_v1', 'context7', 'R10.1 блок 1:\ndev-support'),
|
||||
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) — связи 9 новых узлов ──
|
||||
E('claude_md', 'router_procedure', '§3.6: SoT\nпроцедуры роутера'),
|
||||
E('tooling', 'router_procedure', '§4.X реестр →\nшаг 3 роутера'),
|
||||
E('pravila', 'router_procedure', '§12/§14/§15\nhard-floor'),
|
||||
E('pravila', 'observer_stophook', '§16: observer\n+ routing-тег'),
|
||||
E('observer_stophook', 'observer_evidence', 'пишет эпизоды\n+ routing-gate'),
|
||||
E('pravila', 'sk_brain_retro', '§16: факторный\nанализ раз в спринт'),
|
||||
E('sk_brain_retro', 'observer_evidence', 'читает эпизоды\n(факторный анализ)'),
|
||||
E('lh_l1watcher', 'tooling', 'C1 STRICT: settings.json\n↔ Tooling drift'),
|
||||
E('lh_crossref', 'claude_md', 'C2 STRICT: version\ndrift §0 cross-refs'),
|
||||
E('lh_obs_obs', 'observer_evidence', 'C3 warn: счётчик\n+54w self-prune'),
|
||||
E('lh_status_md', 'observer_evidence', 'C4: генерит\nSTATUS.md'),
|
||||
E('lh_obs_cov', 'observer_evidence', 'C5 warn: покрытие\n+ регистрация'),
|
||||
|
||||
// ── FINANCE-TOOLING C6+C7 (20.05.2026, ADR-012) — связи 3 узлов ──
|
||||
E('tooling', 'finance_plugin', '§4.36 #61 — реестр'),
|
||||
E('tooling', 'billing_audit', '§4.37 #62 — реестр'),
|
||||
E('tooling', 'ru_tax', '§4.38 #63 — реестр'),
|
||||
E('billing_audit', 'ag_pest', 'аудит инвариантов\nчерез тесты'),
|
||||
E('mcp_boost', 'billing_audit', 'модели биллинга'),
|
||||
E('finance_plugin', 'ru_tax', 'РФ-специфика поверх\nUS-механики (ADR-012)'),
|
||||
E('billing_audit', 'ru_tax', 'выручка C6 →\nналог.база C7'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
|
||||
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
|
||||
// ══════════════════════════════════════════════════
|
||||
CONFLICT('sk_rls', 'ag_rls', 'RLS: граница задана — скил по таблице, агент по diff/PR (spec 2026-05-16)', 'GREEN'),
|
||||
CONFLICT('hookify_plugin', 'hk_pre_claude', 'Закрыто правилом HK1 (ADR-010, PSR_v1 R10.1 v3.14): hookify вызывается только по явному /hookify + обязательный pre-check на коллизию с зарегистрированными хуками; перезапись economy/skill-discipline архитектуры запрещена', 'GREEN'),
|
||||
CONFLICT('mcp_pw', 'sk_parallel', 'Профиль Playwright MCP хэшируется per-cwd (квирк #95) → worktrees получают разные mcp-chrome-{hash}, не конфликтуют. Same-dir parallel — редкий случай (две Claude-сессии в одной dir), регулируется Pravila §15.2 claim в docs/sessions/CURRENT.md', 'GREEN'),
|
||||
CONFLICT('ag_pest', 'mcp_redis', 'Квирк 72 устранён 16.05.2026 (commit 0fa1a73 — array-стор в тестах): гонки в Redis при Pest --parallel больше нет', 'GREEN'),
|
||||
CONFLICT('psr_v1', 'claude_md', 'Закрыто §5п.10 CLAUDE.md + хук CLAUDE.md-warn', 'GREEN'),
|
||||
CONFLICT('upm', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('mcp_21st', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('hk_economy', 'superpowers', '§12 — hard-rule уровня 0; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),
|
||||
CONFLICT('observer_stophook', 'hk_verifier', 'HK1 §5.3: оба на Stop-event — коллизии нет (append-chain). Оба способны decision:block; Claude Code прогоняет все Stop-хуки, любой block ⇒ продолжение хода. observer-gate детерминированный и дешёвый.', 'GREEN'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// RUFLO ОРКЕСТРАТОР — фактический реколлаж (iter5, 2026-05-15)
|
||||
// ══════════════════════════════════════════════════
|
||||
// Queen → артефакты установки ruflo init (рой idle, артефакты не задействованы)
|
||||
E('ruflo_queen', 'ruflo_workers', 'координирует\n(0 задач)'),
|
||||
E('ruflo_queen', 'ruflo_agents_catalog', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_commands', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_plugins', 'плагинов ruflo:\n0 установлено'),
|
||||
// MCP-сервер ruflo — связывает половины кластера + читает/пишет память
|
||||
E('ruflo_mcp', 'ruflo_queen', 'инструменты\nуправления роем'),
|
||||
E('ruflo_mcp', 'ruflo_memory', 'читает/пишет\nпамять'),
|
||||
// память ruflo — recall-хук и воркер consolidate демона
|
||||
E('ruflo_recall_hook', 'ruflo_memory', 'запускает\nruflo memory search'),
|
||||
E('ruflo_daemon', 'ruflo_memory', 'воркер consolidate\nобращается к памяти'),
|
||||
// 4 узла-правила → Queen (реколлаж 16.05.2026: ruflo — advisory-подсистема; Pravila §14 — queen-триггер)
|
||||
E('pravila', 'ruflo_queen', '§14:\nqueen-триггер'),
|
||||
E('claude_md', 'ruflo_queen', '§3.5: описывает\n(advisory-подсистема)'),
|
||||
E('psr_v1', 'ruflo_queen', '§14:\ncross-ref'),
|
||||
E('tooling', 'ruflo_queen', '§4.10: реестр\n(advisory-подсистема)'),
|
||||
// memory → ruflo
|
||||
E('mem_ruflo', 'ruflo_queen', 'документирует\nинтеграцию'),
|
||||
|
||||
// 3 конфликта ruflo (3-color, iter2 §4)
|
||||
CONFLICT('ruflo_queen', 'pravila', 'Закрыто реколлажем 16.05.2026: нормативка приведена к рантайму — ruflo переописан в advisory/automation-подсистему, декларация уровня −1 убрана', 'GREEN'),
|
||||
CONFLICT('ruflo_memory', 'mem_state', 'Два хранилища памяти не синхронизированы; память ruflo почти пуста (0 записей)', 'BLACK'),
|
||||
CONFLICT('ruflo_daemon', 'ag_pest', 'Worker-jitter демона ruflo усиливает Pest-квирки 73/77 (квирк 72 устранён 16.05 — его jitter больше не усиливает)', 'BLACK'),
|
||||
];
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3: CATEGORY LABELS
|
||||
// ════════════════════════════════════════════════════
|
||||
const CATEGORY_LABELS = {
|
||||
rules: 'Правило', plugins: 'Плагин', skills_sp: 'Скил Superpowers',
|
||||
skills_proj: 'Скил проекта', hooks: 'Хук .claude', agents: 'Агент',
|
||||
mcp: 'MCP-сервер', lefthook: 'Lefthook job', memory: 'Memory-файл',
|
||||
ruflo: 'ruflo (изолирован)'
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3.4: SECTION BUCKETS & SECTIONS
|
||||
// ════════════════════════════════════════════════════
|
||||
const SECTION_BUCKETS = [
|
||||
{ id: 'A', label: 'Технические и продуктовые' },
|
||||
{ id: 'B', label: 'Коммуникации' },
|
||||
{ id: 'C', label: 'Бизнес и операции' },
|
||||
{ id: 'D', label: 'Право и комплаенс' },
|
||||
{ id: 'E', label: 'Мета и управление' },
|
||||
];
|
||||
const SECTIONS = [
|
||||
{ id: 'A1', bucket: 'A', label: 'Программирование — backend' },
|
||||
{ id: 'A2', bucket: 'A', label: 'Программирование — frontend' },
|
||||
{ id: 'A3', bucket: 'A', label: 'Программирование — интеграции (API, вебхуки)' },
|
||||
{ id: 'A4', bucket: 'A', label: 'Дизайн (UI/UX, графика, бренд)' },
|
||||
{ id: 'A5', bucket: 'A', label: 'Тестирование, QA и отладка' },
|
||||
{ id: 'A6', bucket: 'A', label: 'Архитектура систем' },
|
||||
{ id: 'A7', bucket: 'A', label: 'DevOps, инфраструктура, деплой' },
|
||||
{ id: 'A8', bucket: 'A', label: 'Информационная безопасность' },
|
||||
{ id: 'A9', bucket: 'A', label: 'Работа с данными (БД, миграции, RLS)' },
|
||||
{ id: 'A10', bucket: 'A', label: 'Аналитика и отчётность (BI)' },
|
||||
{ id: 'A11', bucket: 'A', label: 'ML / AI-разработка' },
|
||||
{ id: 'B1', bucket: 'B', label: 'Голосовое общение по телефону' },
|
||||
{ id: 'B2', bucket: 'B', label: 'Мессенджеры' },
|
||||
{ id: 'B3', bucket: 'B', label: 'Электронная почта' },
|
||||
{ id: 'B4', bucket: 'B', label: 'SMS-рассылки' },
|
||||
{ id: 'B5', bucket: 'B', label: 'Видеосвязь' },
|
||||
{ id: 'B6', bucket: 'B', label: 'Чат на сайте / онлайн-консультант' },
|
||||
{ id: 'B7', bucket: 'B', label: 'Социальные сети' },
|
||||
{ id: 'B8', bucket: 'B', label: 'Push / in-app уведомления' },
|
||||
{ id: 'C1', bucket: 'C', label: 'Маркетинг и лидогенерация' },
|
||||
{ id: 'C2', bucket: 'C', label: 'Продажи' },
|
||||
{ id: 'C3', bucket: 'C', label: 'Квалификация и обработка лидов' },
|
||||
{ id: 'C4', bucket: 'C', label: 'Работа с поставщиками лидов' },
|
||||
{ id: 'C5', bucket: 'C', label: 'Клиентский успех, поддержка, удержание' },
|
||||
{ id: 'C6', bucket: 'C', label: 'Финансы — биллинг и тарификация' },
|
||||
{ id: 'C7', bucket: 'C', label: 'Финансы — бухгалтерия и налоги' },
|
||||
{ id: 'C8', bucket: 'C', label: 'HR и управление персоналом' },
|
||||
{ id: 'C9', bucket: 'C', label: 'Управление проектами' },
|
||||
{ id: 'C10', bucket: 'C', label: 'Бизнес-процессы (общее)' },
|
||||
{ id: 'D1', bucket: 'D', label: 'Юриспруденция и договорная работа' },
|
||||
{ id: 'D2', bucket: 'D', label: 'Защита ПДн (152-ФЗ, РКН)' },
|
||||
{ id: 'D3', bucket: 'D', label: 'Аудит и управление рисками' },
|
||||
{ id: 'E1', bucket: 'E', label: 'Мета — правила и нормативка' },
|
||||
{ id: 'E2', bucket: 'E', label: 'Мета — оркестрация и автоматизация (Claude-воркфлоу)' },
|
||||
{ id: 'E3', bucket: 'E', label: 'Документация' },
|
||||
{ id: 'E4', bucket: 'E', label: 'Управление знаниями и память' },
|
||||
{ id: 'E5', bucket: 'E', label: 'Стратегия и принятие решений' },
|
||||
{ id: 'E6', bucket: 'E', label: 'Обучение и онбординг' },
|
||||
{ id: 'E7', bucket: 'E', label: 'Исследования' },
|
||||
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
|
||||
];
|
||||
// Узел -> раздел. Покрывает все 134 узла карты.
|
||||
const NODE_SECTION = {
|
||||
// правила (4)
|
||||
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
|
||||
// плагины (5)
|
||||
superpowers: 'E2', fd_plugin: 'A4', upm: 'A4', claude_md_mgmt: 'E1', hookify_plugin: 'E2',
|
||||
// скилы superpowers (14)
|
||||
sk_brainstorm: 'E5', sk_wplans: 'E2', sk_eplans: 'E2', sk_subagent: 'E2',
|
||||
sk_tdd: 'A5', sk_verify: 'A5', sk_debug: 'A5', sk_parallel: 'E2',
|
||||
sk_worktree: 'E2', sk_pr: 'E2', sk_coderev: 'A5', sk_spreview: 'A5',
|
||||
sk_wskills: 'E2', sk_elements: 'E3',
|
||||
// скилы проекта (2)
|
||||
sk_rls: 'A9', sk_qitem: 'E3',
|
||||
// хуки (5)
|
||||
hk_session: 'E4', hk_economy: 'E2', hk_pre_claude: 'E1', hk_post_md: 'E3', hk_post_schema: 'A9',
|
||||
// агенты (11)
|
||||
ag_explore: 'E2', ag_general: 'E2', ag_plan: 'E2', ag_pest: 'A5', ag_guide: 'E6',
|
||||
ag_statusline: 'E2', ag_hookify: 'E2', ag_pcreator: 'E2', ag_pvalid: 'E2',
|
||||
ag_skreview: 'E2', ag_rls: 'A9',
|
||||
// MCP-серверы (7)
|
||||
mcp_21st: 'A4', mcp_pw: 'A5', mcp_gh: 'A7', mcp_boost: 'A1',
|
||||
mcp_redis: 'A7', mcp_sentry: 'A7', mcp_semgrep: 'A8',
|
||||
// lefthook jobs (10)
|
||||
lh_mdlint: 'E3', lh_cspell: 'E3', lh_stylelint: 'A2', lh_eslint: 'A2',
|
||||
lh_lychee: 'E3', lh_gitleaks: 'A8', lh_gitleaks2: 'A8', lh_pint: 'A1',
|
||||
lh_larastan: 'A1', lh_squawk: 'A9',
|
||||
// memory files (16)
|
||||
mem_user: 'E4', mem_comm: 'E4', mem_env: 'E4', mem_sp: 'E4', mem_plugins: 'E4',
|
||||
mem_handoff: 'E4', mem_redesign: 'E4', mem_devindices: 'E4', mem_phase1: 'E4',
|
||||
mem_state: 'E4', mem_brain: 'E4', mem_supplier: 'E4', mem_audit: 'E4',
|
||||
mem_archive: 'E4', mem_github: 'E4', mem_ruflo: 'E4',
|
||||
// ruflo (9)
|
||||
ruflo_queen: 'E2', ruflo_plugins: 'E2', ruflo_workers: 'E2', ruflo_agents_catalog: 'E2',
|
||||
ruflo_commands: 'E2', ruflo_daemon: 'E2', ruflo_memory: 'E4', ruflo_mcp: 'E2',
|
||||
ruflo_recall_hook: 'E4',
|
||||
// АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — новые узлы
|
||||
skill_creator: 'E8', claude_setup: 'E8', plugin_dev: 'E2', context7: 'E7',
|
||||
hk_self_check: 'E2', hk_skill_marker: 'E2', hk_skill_check: 'E2', hk_state_guard: 'E2',
|
||||
hk_postcompact: 'E2', hk_verifier: 'E2', hk_ruflo_queen: 'E2',
|
||||
sk_regression: 'A5',
|
||||
mem_audit_b: 'E4', mem_audit_c: 'E4', mem_suppliercrm: 'E4', mem_audit12: 'E4',
|
||||
mem_audit14: 'E4', mem_sprint1: 'E4', mem_sprint2: 'E4', mem_sprint3: 'E4',
|
||||
// A6 architecture-tooling 17.05.2026 — раздел «Архитектура систем» наполнен (+deptrac)
|
||||
adr_kit: 'A6', arch_patterns: 'A6', mermaid_skill: 'A6', deptrac: 'A6',
|
||||
// D3 audit-security 17.05.2026 — раздел «Аудит и управление рисками» наполнен
|
||||
tob_skills: 'D3', sec_guidance: 'D3', sk_security_review: 'D3', sk_audit_portal: 'D3',
|
||||
// C9 project-management-tooling 17.05.2026 — раздел «Управление проектами» наполнен
|
||||
ccpm: 'C9', product_mgmt: 'C9',
|
||||
// A4 design-tooling 17.05.2026 — раздел «Дизайн (UI/UX, графика, бренд)» расширен (3→6 узлов)
|
||||
mcp_figma: 'A4', mcp_icons: 'A4', design_plugin: 'A4',
|
||||
// A3 integration-tooling 17.05.2026 — раздел «Программирование — интеграции» наполнен
|
||||
ag_apidocs: 'A3', mcp_openapi: 'A3',
|
||||
// A11 ml-ai-tooling 17.05.2026 — раздел «ML / AI-разработка» наполнен
|
||||
claude_api: 'A11', promptfoo: 'A11', data_scientist: 'A11',
|
||||
// C10 business-process 17.05.2026 — раздел «Бизнес-процессы (общее)» наполнен
|
||||
ops_plugin: 'C10', process_modeling: 'C10', process_analysis: 'C10',
|
||||
// discovery-interview 18.05.2026 — раздел E5 «Стратегия и принятие решений» (рядом с brainstorming)
|
||||
discovery_interview: 'E5',
|
||||
// brain governance iter9 19.05.2026 — ADR-011 подсистема
|
||||
router_procedure: 'E1', observer_stophook: 'E2', sk_brain_retro: 'E8', observer_evidence: 'E4',
|
||||
lh_l1watcher: 'E1', lh_crossref: 'E1', lh_obs_obs: 'E2', lh_status_md: 'E2', lh_obs_cov: 'E2',
|
||||
// finance-tooling C6+C7 (20.05.2026) — разделы «Финансы»
|
||||
finance_plugin: 'C7', billing_audit: 'C6', ru_tax: 'C7',
|
||||
};
|
||||
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
|
||||
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
|
||||
// частично кросс-реф существующих интеграционных инструментов. NODE_SECTION 1:1 не трогается.
|
||||
const NODE_SECTION_SECONDARY = {
|
||||
mcp_boost: ['A3', 'C6', 'C7'],
|
||||
context7: ['A3', 'C6'],
|
||||
ag_pest: ['A3', 'C6', 'C7'],
|
||||
mcp_semgrep: ['A3'],
|
||||
mcp_sentry: ['A3', 'C6'],
|
||||
// C10 business-process 17.05.2026 — кросс-реф reuse-инструментов раздела «Бизнес-процессы»
|
||||
mermaid_skill: ['C10'],
|
||||
arch_patterns: ['C10'],
|
||||
ccpm: ['C10'],
|
||||
product_mgmt: ['C10', 'C6'],
|
||||
sk_wplans: ['C10'],
|
||||
// finance-tooling C6+C7 (20.05.2026) — finance cross-ref + reuse-классификация
|
||||
finance_plugin: ['C6'],
|
||||
lh_larastan: ['C6'], mcp_redis: ['C6'],
|
||||
data_scientist: ['C6', 'C7'], ops_plugin: ['C6', 'C7'],
|
||||
process_modeling: ['C6'], process_analysis: ['C6'],
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 4: VIS GROUPS
|
||||
// ════════════════════════════════════════════════════
|
||||
const GROUPS = {
|
||||
rules: { color: { background: '#073642', border: '#268bd2', highlight: { border: '#93a1a1', background: '#0d4a5a' } }, font: { color: '#fdf6e3', size: 13, bold: true } },
|
||||
plugins: { color: { background: '#001a00', border: '#859900', highlight: { border: '#b8cc00', background: '#002600' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
skills_sp: { color: { background: '#1a0033', border: '#6c71c4', highlight: { border: '#9b9fea', background: '#250047' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
skills_proj: { color: { background: '#2d0020', border: '#d33682', highlight: { border: '#e869a8', background: '#3d0028' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
hooks: { color: { background: '#002233', border: '#2aa198', highlight: { border: '#4dd7ce', background: '#003344' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
agents: { color: { background: '#1a1200', border: '#b58900', highlight: { border: '#e0ad00', background: '#261a00' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
mcp: { color: { background: '#2d1200', border: '#cb4b16', highlight: { border: '#ff6b30', background: '#3d1900' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
lefthook: { color: { background: '#2d0000', border: '#dc322f', highlight: { border: '#ff5f5c', background: '#3d0000' } }, font: { color: '#fdf6e3', size: 10 } },
|
||||
memory: { color: { background: '#112233', border: '#586e75', highlight: { border: '#839496', background: '#1a2f40' } }, font: { color: '#eee8d5', size: 10 } },
|
||||
ruflo: { color: { background: '#262626', border: '#555555', highlight: { border: '#777777', background: '#333333' } }, font: { color: '#8a8a8a', size: 12, bold: true }, shapeProperties: { borderDashes: [4, 4] } },
|
||||
};
|
||||
|
||||
// Expose for ES-module consumers (the dashboard). The map's classic inline
|
||||
// script reads the bare consts directly via the shared global lexical scope.
|
||||
window.AGD = {
|
||||
NODES, EDGES, SECTIONS, SECTION_BUCKETS,
|
||||
NODE_SECTION, NODE_SECTION_SECONDARY,
|
||||
CONFLICT_TYPES, GROUPS, CATEGORY_LABELS,
|
||||
};
|
||||
+158
-538
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Система автоматизации Лидерры</title>
|
||||
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
||||
<script src="automation-graph-data.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #0d0d1a; color: #fdf6e3; font-family: 'Segoe UI', system-ui, sans-serif; height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
@@ -217,412 +218,21 @@
|
||||
// SECTION 1: NODES
|
||||
// ════════════════════════════════════════════════════
|
||||
|
||||
// Радиально-секторная компоновка.
|
||||
// Сектора (по 90°): N=workflow (0–90), E=UI (90–180), S=infra (180–270), W=data/RLS (270–360).
|
||||
const RADII = [0, 220, 400, 600, 800, 1000, 1180];
|
||||
function pos(ring, angleDeg) {
|
||||
const r = RADII[ring];
|
||||
const a = angleDeg * Math.PI / 180;
|
||||
return { x: Math.round(r * Math.cos(a)), y: Math.round(r * Math.sin(a)) };
|
||||
}
|
||||
// RADII, pos() — moved to automation-graph-data.js
|
||||
|
||||
const NODES = [
|
||||
// ── ПРАВИЛА (4) ── центр + первое кольцо ───────
|
||||
{ id: 'pravila', label: 'Pravila v1.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) },
|
||||
{ id: 'fd_plugin', label: 'Frontend Design', group: 'plugins', size: 26, ring: 2, ...pos(2, 135) },
|
||||
{ id: 'upm', label: 'UI UX Pro Max', group: 'plugins', size: 22, ring: 2, ...pos(2, 165) },
|
||||
{ id: 'claude_md_mgmt', label: 'claude-md-mgmt', group: 'plugins', size: 22, ring: 2, ...pos(2, 225) },
|
||||
{ id: 'hookify_plugin', label: 'hookify (плагин)', group: 'plugins', size: 22, ring: 2, ...pos(2, 200) },
|
||||
{ id: 'skill_creator', label: 'skill-creator', group: 'plugins', size: 20, ring: 2, ...pos(2, 70) },
|
||||
{ id: 'claude_setup', label: 'claude-code-setup', group: 'plugins', size: 22, ring: 2, ...pos(2, 90) },
|
||||
{ id: 'plugin_dev', label: 'plugin-dev', group: 'plugins', size: 22, ring: 2, ...pos(2, 290) },
|
||||
{ id: 'context7', label: 'context7 (docs MCP)', group: 'plugins', size: 20, ring: 2, ...pos(2, 315) },
|
||||
// A6 architecture-tooling — adr-kit / architecture-patterns (плагины) + deptrac (composer dev-dep, job 10) — раздел «Архитектура систем»
|
||||
{ id: 'adr_kit', label: 'adr-kit', group: 'plugins', size: 22, ring: 2, ...pos(2, 240) },
|
||||
{ id: 'arch_patterns', label: 'architecture-patterns',group: 'plugins', size: 20, ring: 2, ...pos(2, 250) },
|
||||
{ id: 'deptrac', label: 'deptrac', group: 'plugins', size: 20, ring: 2, ...pos(2, 260) },
|
||||
// D3 audit-security (17.05.2026) — 2 плагина раздела «Аудит и управление рисками»
|
||||
{ id: 'tob_skills', label: 'Trail of Bits\nskills', group: 'plugins', size: 22, ring: 2, ...pos(2, 330) },
|
||||
{ id: 'sec_guidance', label: 'Security\nGuidance', group: 'plugins', size: 20, ring: 2, ...pos(2, 345) },
|
||||
// C9 project-management-tooling (17.05.2026) — плагин раздела «Управление проектами»
|
||||
{ id: 'product_mgmt', label: 'product-\nmanagement', group: 'plugins', size: 20, ring: 2, ...pos(2, 355) },
|
||||
// A4 design-tooling (17.05.2026) — раздел «Дизайн (UI/UX, графика, бренд)» (плагины)
|
||||
{ id: 'design_plugin', label: 'Design\nplugin', group: 'plugins', size: 20, ring: 2, ...pos(2, 155) },
|
||||
|
||||
// ── СКИЛЫ SUPERPOWERS (14) — N sector (0–90) ────
|
||||
{ id: 'sk_brainstorm', label: 'brainstorming', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 5) },
|
||||
{ id: 'sk_wplans', label: 'writing-plans', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 11) },
|
||||
{ id: 'sk_eplans', label: 'executing-plans', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 17) },
|
||||
{ id: 'sk_subagent', label: 'subagent-driven', group: 'skills_sp', size: 20, ring: 3, ...pos(3, 23) },
|
||||
{ id: 'sk_tdd', label: 'TDD', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 29) },
|
||||
{ id: 'sk_verify', label: 'verification-before-completion', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 36) },
|
||||
{ id: 'sk_debug', label: 'systematic-debugging', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 43) },
|
||||
{ id: 'sk_parallel', label: 'parallel-work', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 50) },
|
||||
{ id: 'sk_worktree', label: 'worktree', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 57) },
|
||||
{ id: 'sk_pr', label: 'finishing-pr', group: 'skills_sp', size: 18, ring: 3, ...pos(3, 64) },
|
||||
{ id: 'sk_coderev', label: 'code-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 71) },
|
||||
{ id: 'sk_spreview', label: 'spec-review', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 78) },
|
||||
{ id: 'sk_wskills', label: 'writing-skills', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 85) },
|
||||
{ id: 'sk_elements', label: 'elements-of-style', group: 'skills_sp', size: 16, ring: 3, ...pos(3, 92) },
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА (6) — W sector (RLS/arch/audit) ────
|
||||
{ id: 'sk_rls', label: 'rls-check', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 305) },
|
||||
{ id: 'sk_qitem', label: 'q-item-add', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 220) },
|
||||
{ id: 'sk_regression', label: 'regression', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 260) },
|
||||
// A6 architecture-tooling (17.05.2026) — вендоренный скил диаграмм
|
||||
{ id: 'mermaid_skill', label: 'mermaid (skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 280) },
|
||||
// D3 audit-security (17.05.2026) — скилы раздела «Аудит и управление рисками»
|
||||
{ id: 'sk_security_review', label: 'security-review', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 315) },
|
||||
{ id: 'sk_audit_portal', label: 'audit-portal', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 325) },
|
||||
// C9 project-management-tooling (17.05.2026) — вендоренный скил раздела «Управление проектами»
|
||||
{ id: 'ccpm', label: 'CCPM\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 335) },
|
||||
// A11 ml-ai-tooling (17.05.2026) — скилы и CLI раздела «ML / AI-разработка»
|
||||
{ id: 'claude_api', label: 'claude-api\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 345) },
|
||||
{ id: 'data_scientist', label: 'Data Scientist\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 355) },
|
||||
{ id: 'promptfoo', label: 'promptfoo', group: 'plugins', size: 20, ring: 2, ...pos(2, 365) },
|
||||
// C10 business-process (17.05.2026) — плагин и скилы раздела «Бизнес-процессы (общее)»
|
||||
{ id: 'ops_plugin', label: 'operations\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 385) },
|
||||
{ id: 'process_modeling', label: 'process-modeling\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 367) },
|
||||
{ id: 'process_analysis', label: 'process-analysis\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 377) },
|
||||
// discovery-tooling (18.05.2026) — self-authored скил интервью-discovery
|
||||
{ id: 'discovery_interview', label: 'discovery-interview\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 387) },
|
||||
|
||||
// ── ХУКИ (12) — S+infra + E (economy/skill) ───
|
||||
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
|
||||
{ id: 'hk_economy', label: 'UserPromptSubmit:\neconomy-mode', group: 'hooks', size: 22, ring: 4, ...pos(4, 95) },
|
||||
{ id: 'hk_pre_claude', label: 'PreToolUse:\nCLAUDE.md-warn', group: 'hooks', size: 22, ring: 4, ...pos(4, 215) },
|
||||
{ id: 'hk_post_md', label: 'PostToolUse:\nmarkdownlint', group: 'hooks', size: 20, ring: 4, ...pos(4, 195) },
|
||||
{ id: 'hk_post_schema', label: 'PostToolUse:\nschema-changelog',group: 'hooks', size: 20, ring: 4, ...pos(4, 300) },
|
||||
{ id: 'hk_self_check', label: 'SessionStart:\neconomy-self-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 105) },
|
||||
{ id: 'hk_skill_marker', label: 'PreToolUse:\nskill-marker', group: 'hooks', size: 20, ring: 4, ...pos(4, 115) },
|
||||
{ id: 'hk_skill_check', label: 'PreToolUse:\nskill-check', group: 'hooks', size: 20, ring: 4, ...pos(4, 125) },
|
||||
{ id: 'hk_state_guard', label: 'PreToolUse:\neconomy-state-guard', group: 'hooks', size: 20, ring: 4, ...pos(4, 135) },
|
||||
{ id: 'hk_postcompact', label: 'PostCompact:\neconomy-postcompact', group: 'hooks', size: 20, ring: 4, ...pos(4, 145) },
|
||||
{ id: 'hk_verifier', label: 'Stop:\neconomy-verifier (агент)', group: 'hooks', size: 22, ring: 4, ...pos(4, 155) },
|
||||
{ id: 'hk_ruflo_queen', label: 'UserPromptSubmit:\nruflo-queen-hook', group: '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) },
|
||||
{ id: 'ag_general', label: 'general-purpose', group: 'agents', size: 20, ring: 4, ...pos(4, 25) },
|
||||
{ id: 'ag_plan', label: 'Plan', group: 'agents', size: 20, ring: 4, ...pos(4, 40) },
|
||||
{ id: 'ag_pest', label: 'pest-parallel-debugger', group: 'agents', size: 24, ring: 4, ...pos(4, 55) },
|
||||
{ id: 'ag_guide', label: 'claude-code-guide', group: 'agents', size: 18, ring: 4, ...pos(4, 70) },
|
||||
{ id: 'ag_statusline', label: 'statusline-setup', group: 'agents', size: 18, ring: 4, ...pos(4, 85) },
|
||||
{ id: 'ag_hookify', label: 'hookify:\nconversation-analyzer', group: 'agents', size: 18, ring: 4, ...pos(4, 230) },
|
||||
{ id: 'ag_pcreator', label: 'plugin-dev:\nagent-creator', group: 'agents', size: 16, ring: 4, ...pos(4, 245) },
|
||||
{ id: 'ag_pvalid', label: 'plugin-dev:\nplugin-validator',group: 'agents', size: 16, ring: 4, ...pos(4, 260) },
|
||||
{ id: 'ag_skreview', label: 'plugin-dev:\nskill-reviewer', group: 'agents', size: 16, ring: 4, ...pos(4, 275) },
|
||||
{ id: 'ag_rls', label: 'rls-reviewer', group: 'agents', size: 22, ring: 4, ...pos(4, 315) },
|
||||
// A3 integration-tooling (17.05.2026) — agent раздела «Программирование — интеграции»
|
||||
{ id: 'ag_apidocs', label: 'api-docs (agent)', group: 'agents', size: 18, ring: 4, ...pos(4, 175) },
|
||||
|
||||
// ── MCP-СЕРВЕРЫ (9) — E (UI) + W (data) ───────
|
||||
{ id: 'mcp_21st', label: 'MCP: 21st.dev Magic', group: 'mcp', size: 20, ring: 5, ...pos(5, 130) },
|
||||
// A4 design-tooling (17.05.2026) — MCP-серверы раздела «Дизайн (UI/UX, графика, бренд)»
|
||||
{ id: 'mcp_figma', label: 'MCP: Figma\n(DEFERRED)', group: 'mcp', size: 18, ring: 5, ...pos(5, 140) },
|
||||
{ id: 'mcp_icons', label: 'MCP: Universal\nIcons', group: 'mcp', size: 18, ring: 5, ...pos(5, 120) },
|
||||
{ id: 'mcp_pw', label: 'MCP: playwright', group: 'mcp', size: 22, ring: 5, ...pos(5, 110) },
|
||||
{ id: 'mcp_gh', label: 'MCP: github', group: 'mcp', size: 22, ring: 5, ...pos(5, 75) },
|
||||
{ id: 'mcp_boost', label: 'MCP: laravel-boost', group: 'mcp', size: 24, ring: 5, ...pos(5, 290) },
|
||||
{ id: 'mcp_redis', label: 'MCP: redis', group: 'mcp', size: 22, ring: 5, ...pos(5, 310) },
|
||||
{ id: 'mcp_sentry', label: 'MCP: sentry', group: 'mcp', size: 22, ring: 5, ...pos(5, 330) },
|
||||
{ id: 'mcp_semgrep', label: 'MCP: semgrep', group: 'mcp', size: 20, ring: 5, ...pos(5, 350) },
|
||||
// A3 integration-tooling (17.05.2026) — MCP-сервер раздела «Программирование — интеграции»
|
||||
{ id: 'mcp_openapi', label: 'MCP: openapi', group: 'mcp', size: 20, ring: 5, ...pos(5, 5) },
|
||||
|
||||
// ── LEFTHOOK JOBS (10) — S+W (infra/data) ─────
|
||||
{ id: 'lh_mdlint', label: 'lefthook:\nmarkdownlint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 185) },
|
||||
{ id: 'lh_cspell', label: 'lefthook:\ncspell', group: 'lefthook', size: 18, ring: 5, ...pos(5, 200) },
|
||||
{ id: 'lh_stylelint', label: 'lefthook:\nstylelint', group: 'lefthook', size: 16, ring: 5, ...pos(5, 215) },
|
||||
{ id: 'lh_eslint', label: 'lefthook:\neslint-vue', group: 'lefthook', size: 18, ring: 5, ...pos(5, 230) },
|
||||
{ id: 'lh_lychee', label: 'lefthook:\nlychee-links', group: 'lefthook', size: 18, ring: 5, ...pos(5, 245) },
|
||||
{ id: 'lh_gitleaks', label: 'lefthook:\ngitleaks', group: 'lefthook', size: 18, ring: 5, ...pos(5, 260) },
|
||||
{ id: 'lh_gitleaks2', label: 'lefthook:\ngitleaks pre-push', group: 'lefthook', size: 18, ring: 5, ...pos(5, 275) },
|
||||
{ id: 'lh_pint', label: 'lefthook:\npint', group: 'lefthook', size: 18, ring: 5, ...pos(5, 25) },
|
||||
{ id: 'lh_larastan', label: 'lefthook:\nlarastan', group: 'lefthook', size: 18, ring: 5, ...pos(5, 50) },
|
||||
{ id: 'lh_squawk', label: 'lefthook:\nsquawk', group: 'lefthook', size: 18, ring: 5, ...pos(5, 320) },
|
||||
|
||||
// ── MEMORY FILES (23) — внешнее кольцо ──────────
|
||||
{ id: 'mem_user', label: 'memory:\nuser_profile', group: 'memory', size: 16, ring: 6, ...pos(6, 0) },
|
||||
{ id: 'mem_comm', label: 'memory:\nfeedback_comm', group: 'memory', size: 14, ring: 6, ...pos(6, 24) },
|
||||
{ id: 'mem_env', label: 'memory:\nfeedback_env', group: 'memory', size: 16, ring: 6, ...pos(6, 48) },
|
||||
{ id: 'mem_sp', label: 'memory:\nfeedback_superpowers',group: 'memory', size: 16, ring: 6, ...pos(6, 72) },
|
||||
{ id: 'mem_plugins', label: 'memory:\nfeedback_plugins', group: 'memory', size: 16, ring: 6, ...pos(6, 96) },
|
||||
{ id: 'mem_handoff', label: 'memory:\nreference_handoff', group: 'memory', size: 14, ring: 6, ...pos(6, 120) },
|
||||
{ id: 'mem_redesign', label: 'memory:\nportal_redesign', group: 'memory', size: 14, ring: 6, ...pos(6, 144) },
|
||||
{ id: 'mem_devindices', label: 'memory:\ndev_indices', group: 'memory', size: 12, ring: 6, ...pos(6, 168) },
|
||||
{ id: 'mem_phase1', label: 'memory:\nphase1_strategy', group: 'memory', size: 14, ring: 6, ...pos(6, 192) },
|
||||
{ id: 'mem_state', label: 'memory:\nproject_state', group: 'memory', size: 16, ring: 6, ...pos(6, 216) },
|
||||
{ id: 'mem_brain', label: 'memory:\nclaude_brain', group: 'memory', size: 14, ring: 6, ...pos(6, 240) },
|
||||
{ id: 'mem_supplier', label: 'memory:\nsupplier_integration',group: 'memory', size: 14, ring: 6, ...pos(6, 264) },
|
||||
{ id: 'mem_audit', label: 'memory:\naudit_2026-05-13', group: 'memory', size: 14, ring: 6, ...pos(6, 288) },
|
||||
{ id: 'mem_archive', label: 'memory:\nreference_archive', group: 'memory', size: 14, ring: 6, ...pos(6, 312) },
|
||||
{ id: 'mem_github', label: 'memory:\nreference_github', group: 'memory', size: 14, ring: 6, ...pos(6, 336) },
|
||||
{ id: 'mem_audit_b', label: 'memory:\naudit_B_status', group: 'memory', size: 12, ring: 6, ...pos(6, 12) },
|
||||
{ id: 'mem_audit_c', label: 'memory:\naudit_C_pending', group: 'memory', size: 12, ring: 6, ...pos(6, 36) },
|
||||
{ id: 'mem_suppliercrm',label: 'memory:\nsupplier_crm', group: 'memory', size: 12, ring: 6, ...pos(6, 60) },
|
||||
{ id: 'mem_audit12', label: 'memory:\nfull_audit_05-12', group: 'memory', size: 12, ring: 6, ...pos(6, 84) },
|
||||
{ id: 'mem_audit14', label: 'memory:\nfull_audit_05-14', group: 'memory', size: 12, ring: 6, ...pos(6, 108) },
|
||||
{ id: 'mem_sprint1', label: 'memory:\nsprint1_p0_closure', group: 'memory', size: 12, ring: 6, ...pos(6, 132) },
|
||||
{ id: 'mem_sprint2', label: 'memory:\nsprint2_p1_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 156) },
|
||||
{ id: 'mem_sprint3', label: 'memory:\nsprint3_progress', group: 'memory', size: 12, ring: 6, ...pos(6, 180) },
|
||||
|
||||
// ── RUFLO ОРКЕСТРАТОР (9) — фактический реколлаж iter5 — кластер вне радиального layout (верх-лево) ──
|
||||
{ id: 'ruflo_queen', label: 'ruflo Queen\n(hive-mind)', group: 'ruflo', size: 44, x: -1340, y: -700 },
|
||||
{ id: 'ruflo_plugins', label: 'плагины ruflo\n0 из 20 · скилов 0', group: 'ruflo', size: 20, x: -1340, y: -880 },
|
||||
{ id: 'ruflo_workers', label: '10 воркеров\nhive-mind (idle)', group: 'ruflo', size: 26, x: -1160, y: -800 },
|
||||
{ id: 'ruflo_agents_catalog', label: 'каталог агентов ruflo\n(100 определений)', group: 'ruflo', size: 24, x: -1530, y: -830 },
|
||||
{ id: 'ruflo_commands', label: 'slash-команды\nruflo (88)', group: 'ruflo', size: 22, x: -1140, y: -630 },
|
||||
{ id: 'ruflo_daemon', label: 'демон ruflo\n(воркеры падают)', group: 'ruflo', size: 24, x: -1560, y: -650 },
|
||||
{ id: 'ruflo_memory', label: 'память ruflo\n(~0 записей)', group: 'ruflo', size: 24, x: -1380, y: -500 },
|
||||
{ id: 'ruflo_mcp', label: 'ruflo MCP\n(~210 инструментов)', group: 'ruflo', size: 26, x: -1190, y: -460 },
|
||||
{ id: 'ruflo_recall_hook', label: 'хук recall\n(UserPromptSubmit)', group: 'ruflo', size: 22, x: -1570, y: -470 },
|
||||
|
||||
// ── MEMORY +1 (артефакт ruflo big-bang) ──
|
||||
{ id: 'mem_ruflo', label: 'memory:\nproject_ruflo_integration', group: 'memory', size: 14, x: -1740, y: -620 },
|
||||
];
|
||||
// NODES — moved to automation-graph-data.js
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 2: EDGES
|
||||
// ════════════════════════════════════════════════════
|
||||
const CONFLICT_TYPES = {
|
||||
RED: { color: '#ff5f57', bg: '#2d0000', emoji: '🔴', label: 'Не закрыт правилом', rank: 1 },
|
||||
BLACK: { color: '#888888', bg: '#1a1a1a', emoji: '⚫', label: 'Возник на практике', rank: 2 },
|
||||
GREEN: { color: '#859900', bg: '#0e1a00', emoji: '🟢', label: 'Закрыт правилом', rank: 3 },
|
||||
};
|
||||
const E = (from, to, label) => ({
|
||||
from, to,
|
||||
title: label,
|
||||
color: { color: '#586e75', highlight: '#93a1a1', hover: '#93a1a1' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.6 } },
|
||||
smooth: { type: 'continuous', roundness: 0.5 }
|
||||
});
|
||||
const CONFLICT = (from, to, label, type = 'RED') => ({
|
||||
from, to,
|
||||
title: label,
|
||||
label: CONFLICT_TYPES[type].emoji,
|
||||
dashes: true,
|
||||
width: 2,
|
||||
color: { color: CONFLICT_TYPES[type].color, highlight: '#ff8880', hover: '#ff8880' },
|
||||
arrows: { to: { enabled: true, scaleFactor: 0.7 }, from: { enabled: true, scaleFactor: 0.7 } },
|
||||
font: { color: CONFLICT_TYPES[type].color, size: 14, align: 'middle', strokeWidth: 3, strokeColor: '#1e1e2e' },
|
||||
smooth: { type: 'curvedCW', roundness: 0.35 }
|
||||
});
|
||||
// CONFLICT_TYPES, E, CONFLICT — moved to automation-graph-data.js
|
||||
|
||||
const EDGES = [
|
||||
// ── ПРАВИЛА — иерархия ──────────────────────────
|
||||
E('pravila', 'claude_md', 'подчиняет\n(уровень 1→2a)'),
|
||||
E('pravila', 'psr_v1', 'подчиняет\n(уровень 1→3)'),
|
||||
E('claude_md', 'tooling', 'ссылается\nна реестр'),
|
||||
E('pravila', 'superpowers', '§12: обязывает\nинвокировать 1-м'),
|
||||
|
||||
// ── PSR_v1 координирует плагины ─────────────────
|
||||
E('psr_v1', 'superpowers', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'fd_plugin', 'R5: координирует\nпарный стек'),
|
||||
E('psr_v1', 'upm', 'R14.3: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'mcp_21st', 'R14.4: активирует\nтолько через pipeline'),
|
||||
E('psr_v1', 'claude_md_mgmt','R10.1 блок 1:\nинфраструктурный'),
|
||||
|
||||
// ── CLAUDE.md ────────────────────────────────────
|
||||
E('claude_md', 'mcp_boost', 'описывает §3.2'),
|
||||
E('claude_md', 'mcp_sentry', 'описывает §4.8'),
|
||||
E('claude_md', 'mcp_redis', 'описывает §4.9'),
|
||||
E('claude_md', 'claude_md_mgmt', '§5п.10:\nединственный канал'),
|
||||
E('claude_md', 'ag_pest', 'описывает\nкогда вызывать'),
|
||||
E('claude_md', 'ag_rls', 'описывает\nкогда вызывать'),
|
||||
|
||||
// ── ХУКИ ────────────────────────────────────────
|
||||
E('hk_pre_claude', 'claude_md', 'проверяет\nпри Edit/Write'),
|
||||
E('hk_post_md', 'lh_mdlint', 'дублирует задачу\n(локально)'),
|
||||
E('hk_post_schema', 'claude_md', 'напоминает про\nCHANGELOG_schema'),
|
||||
E('hk_session', 'mem_user', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_env', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_sp', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_plugins', 'читает\nпри старте'),
|
||||
E('hk_session', 'mem_state', 'читает\nпри старте'),
|
||||
E('hk_economy', 'superpowers', 'парсит уровень\nэкономии'),
|
||||
|
||||
// ── SUPERPOWERS содержит скилы ──────────────────
|
||||
E('superpowers', 'sk_brainstorm', 'содержит'),
|
||||
E('superpowers', 'sk_tdd', 'содержит'),
|
||||
E('superpowers', 'sk_debug', 'содержит'),
|
||||
E('superpowers', 'sk_wplans', 'содержит'),
|
||||
E('superpowers', 'sk_eplans', 'содержит'),
|
||||
E('superpowers', 'sk_verify', 'содержит'),
|
||||
E('superpowers', 'sk_parallel', 'содержит'),
|
||||
E('superpowers', 'sk_worktree', 'содержит'),
|
||||
E('superpowers', 'sk_pr', 'содержит'),
|
||||
E('superpowers', 'sk_subagent', 'содержит'),
|
||||
E('superpowers', 'sk_wskills', 'содержит'),
|
||||
E('superpowers', 'sk_spreview', 'содержит'),
|
||||
E('superpowers', 'sk_coderev', 'содержит'),
|
||||
E('superpowers', 'sk_elements', 'содержит'),
|
||||
|
||||
// ── СКИЛЫ вызывают друг друга ───────────────────
|
||||
E('sk_brainstorm', 'sk_wplans', 'вызывает\nпосле дизайна'),
|
||||
E('sk_wplans', 'sk_eplans', 'вызывает\nдля выполнения'),
|
||||
E('sk_wplans', 'sk_subagent','альтернатива\nexecuting-plans'),
|
||||
E('sk_subagent', 'ag_explore', 'запускает\nдля поиска'),
|
||||
E('sk_subagent', 'ag_general', 'запускает\nдля задач'),
|
||||
E('sk_subagent', 'ag_plan', 'запускает\nдля архитектуры'),
|
||||
E('sk_parallel', 'sk_worktree','использует\nдля изоляции'),
|
||||
|
||||
// ── СКИЛЫ ПРОЕКТА ───────────────────────────────
|
||||
E('sk_rls', 'tooling', 'использует\nsquawk + grep §3.2'),
|
||||
E('sk_rls', 'mcp_boost', 'SQL запросы\nк схеме'),
|
||||
E('sk_qitem', 'claude_md_mgmt','делегирует\nправку CLAUDE.md'),
|
||||
|
||||
// ── CLAUDE-MD-MGMT ──────────────────────────────
|
||||
E('claude_md_mgmt', 'claude_md', 'единственный\nканал правок'),
|
||||
|
||||
// ── HOOKIFY ─────────────────────────────────────
|
||||
E('ag_hookify', 'hookify_plugin', 'передаёт\nанализ'),
|
||||
E('hookify_plugin', 'hk_pre_claude', 'может создавать\nновые хуки'),
|
||||
E('hookify_plugin', 'hk_economy', 'может создавать\nновые хуки'),
|
||||
|
||||
// ── АГЕНТЫ используют MCP ───────────────────────
|
||||
E('ag_pest', 'mcp_redis', 'читает\nочереди/кэш'),
|
||||
E('ag_rls', 'mcp_boost', 'SQL запросы\nк БД'),
|
||||
E('ag_guide', 'mcp_gh', 'ищет\nв репозитории'),
|
||||
|
||||
// ── LEFTHOOK вызывается git ──────────────────────
|
||||
E('lh_gitleaks', 'mem_plugins', 'блокирует коммит\nпри ПДн в staged'),
|
||||
E('lh_larastan', 'mcp_boost', 'Boost даёт\nконтекст типов'),
|
||||
E('lh_squawk', 'tooling', 'соответствует\n§3.2 #15'),
|
||||
E('lh_gitleaks2', 'lh_gitleaks', 'строже:\nвся история'),
|
||||
E('lh_lychee', 'claude_md', 'проверяет\nссылки в .md'),
|
||||
|
||||
// ── MEMORY читается Claude ──────────────────────
|
||||
E('mem_env', 'ag_pest', 'квирки 73/77\nиспользует агент'),
|
||||
E('mem_plugins', 'psr_v1', 'отражает\nтекущие версии'),
|
||||
E('mem_archive', 'claude_md', 'синхронизирует\nверсии доков'),
|
||||
|
||||
// ── MCP ─────────────────────────────────────────
|
||||
E('mcp_pw', 'hk_session', 'используется\nдля a11y smoke'),
|
||||
E('mcp_gh', 'sk_pr', 'PR, issues\nпри finishing-pr'),
|
||||
E('mcp_boost', 'ag_rls', 'схема БД\nдля RLS-review'),
|
||||
|
||||
// ── АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — связи новых узлов ──
|
||||
// 4 ребра psr_v1→skill_creator/claude_setup/plugin_dev/context7 — перенесены
|
||||
// в ADT-блок 18.05.2026 (точные категории authoring-tooling/dev-support, дедуп)
|
||||
E('plugin_dev', 'ag_pcreator', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_pvalid', 'содержит\nагента'),
|
||||
E('plugin_dev', 'ag_skreview', 'содержит\nагента'),
|
||||
E('skill_creator', 'sk_wskills', 'обе создают\nскилы'),
|
||||
E('hk_self_check', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_skill_marker', 'hk_skill_check', 'пара\nmarker/check'),
|
||||
E('hk_skill_check', 'superpowers', 'энфорсит §12:\nскил перед кодом'),
|
||||
E('hk_state_guard', 'hk_economy', 'система\nэкономии'),
|
||||
E('hk_postcompact', 'hk_economy', 'переинжект\nрежима после компакта'),
|
||||
E('hk_verifier', 'sk_verify', 'энфорсит\nпроверку готовности'),
|
||||
E('hk_ruflo_queen', 'ruflo_queen', '§14: маршрут\nqueen-задач'),
|
||||
E('sk_regression', 'ag_pest', 'передаёт разбор\nпадений Pest --parallel'),
|
||||
|
||||
// ── A6 ARCHITECTURE-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'adr_kit', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('psr_v1', 'arch_patterns', 'R10.1 блок 1:\narchitecture-tooling'),
|
||||
E('tooling', 'mermaid_skill', '§4.12: реестр\n(вендоренный скил)'),
|
||||
E('psr_v1', 'deptrac', 'R10.1 блок 1 note:\narchitecture-tooling'),
|
||||
|
||||
// ── A4 DESIGN-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'design_plugin', 'R10.1 блок 1:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_icons', 'R10.1 блок 3:\ndesign-tooling'),
|
||||
E('psr_v1', 'mcp_figma', 'R10.1 блок 3:\ndesign-tooling (DEFERRED)'),
|
||||
|
||||
// ── D3 AUDIT-SECURITY 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'tob_skills', 'R10.1 блок 1:\naudit-security'),
|
||||
E('psr_v1', 'sec_guidance', 'R10.1 блок 1:\naudit-security'),
|
||||
E('tooling', 'tob_skills', '§4.14 #39 — реестр'),
|
||||
E('tooling', 'sec_guidance', '§4.15 #40 — реестр'),
|
||||
E('sk_audit_portal', 'sk_security_review', 'оркеструет\nкак фазу аудита'),
|
||||
E('sk_audit_portal', 'tob_skills', 'оркеструет\nглубокие кампании'),
|
||||
E('sk_audit_portal', 'sk_regression', 'использует\nна фазе тестов'),
|
||||
CONFLICT('tob_skills', 'mcp_semgrep', 'TB1: граница разграничена — Semgrep = inline SAST, Trail of Bits = глубокие on-demand аудит-кампании. Параллельное использование разрешено при разных сценариях.', 'GREEN'),
|
||||
|
||||
// ── A3 INTEGRATION-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'mcp_openapi', 'R10.1 блок 3:\nintegration-tooling'),
|
||||
E('tooling', 'mcp_openapi', '§4.22 #47 — реестр'),
|
||||
E('ag_apidocs', 'mcp_openapi', 'спека → MCP-ресурс'),
|
||||
|
||||
// ── A11 ML-AI-TOOLING 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'promptfoo', 'R10.1 блок 1:\nml-ai-tooling'),
|
||||
E('tooling', 'claude_api', 'reuse — built-in skill\n(PSR_v1 R10.1 блок 2)'),
|
||||
E('tooling', 'data_scientist', '§4.24 #49 — реестр'),
|
||||
|
||||
// ── C10 BUSINESS-PROCESS 17.05.2026 — связи новых узлов ──
|
||||
E('psr_v1', 'ops_plugin', 'R10.1 блок 1:\nbusiness-process'),
|
||||
E('tooling', 'process_modeling', '§4.27 #52 — реестр'),
|
||||
E('tooling', 'process_analysis', '§4.28 #53 — реестр'),
|
||||
|
||||
// ── DISCOVERY-TOOLING 18.05.2026 — связи узла discovery-interview ──
|
||||
E('tooling', 'discovery_interview', '§4.30 #55 — реестр'),
|
||||
E('psr_v1', 'discovery_interview', 'R10.1 блок 1 note:\ndiscovery-tooling'),
|
||||
E('discovery_interview', 'sk_brainstorm', 'хэндофф:\nFEATURE-brief'),
|
||||
E('discovery_interview', 'process_analysis', 'граница: слой-источник\n(ADR-009 DI2)'),
|
||||
|
||||
// ── ANTHROPIC DEV-TOOLING 18.05.2026 — связи 5 узлов ──
|
||||
E('psr_v1', 'skill_creator', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'plugin_dev', 'R10.1 блок 1:\nauthoring-tooling'),
|
||||
E('psr_v1', 'hookify_plugin', 'R10.1 блок 1:\nauthoring-tooling (HK1)'),
|
||||
E('psr_v1', 'claude_setup', 'R10.1 блок 1:\ndev-support'),
|
||||
E('psr_v1', 'context7', 'R10.1 блок 1:\ndev-support'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
|
||||
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
|
||||
// ══════════════════════════════════════════════════
|
||||
CONFLICT('sk_rls', 'ag_rls', 'RLS: граница задана — скил по таблице, агент по diff/PR (spec 2026-05-16)', 'GREEN'),
|
||||
CONFLICT('hookify_plugin', 'hk_pre_claude', 'Закрыто правилом HK1 (ADR-010, PSR_v1 R10.1 v3.14): hookify вызывается только по явному /hookify + обязательный pre-check на коллизию с зарегистрированными хуками; перезапись economy/skill-discipline архитектуры запрещена', 'GREEN'),
|
||||
CONFLICT('mcp_pw', 'sk_parallel', 'Профиль Playwright MCP хэшируется per-cwd (квирк #95) → worktrees получают разные mcp-chrome-{hash}, не конфликтуют. Same-dir parallel — редкий случай (две Claude-сессии в одной dir), регулируется Pravila §15.2 claim в docs/sessions/CURRENT.md', 'GREEN'),
|
||||
CONFLICT('ag_pest', 'mcp_redis', 'Квирк 72 устранён 16.05.2026 (commit 0fa1a73 — array-стор в тестах): гонки в Redis при Pest --parallel больше нет', 'GREEN'),
|
||||
CONFLICT('psr_v1', 'claude_md', 'Закрыто §5п.10 CLAUDE.md + хук CLAUDE.md-warn', 'GREEN'),
|
||||
CONFLICT('upm', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('mcp_21st', 'fd_plugin', 'PSR_v1 R14.5: не параллельно', 'GREEN'),
|
||||
CONFLICT('hk_economy', 'superpowers', '§12 — hard-rule уровня 0; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),
|
||||
|
||||
// ══════════════════════════════════════════════════
|
||||
// RUFLO ОРКЕСТРАТОР — фактический реколлаж (iter5, 2026-05-15)
|
||||
// ══════════════════════════════════════════════════
|
||||
// Queen → артефакты установки ruflo init (рой idle, артефакты не задействованы)
|
||||
E('ruflo_queen', 'ruflo_workers', 'координирует\n(0 задач)'),
|
||||
E('ruflo_queen', 'ruflo_agents_catalog', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_commands', 'ruflo init высыпал\n(не задействовано)'),
|
||||
E('ruflo_queen', 'ruflo_plugins', 'плагинов ruflo:\n0 установлено'),
|
||||
// MCP-сервер ruflo — связывает половины кластера + читает/пишет память
|
||||
E('ruflo_mcp', 'ruflo_queen', 'инструменты\nуправления роем'),
|
||||
E('ruflo_mcp', 'ruflo_memory', 'читает/пишет\nпамять'),
|
||||
// память ruflo — recall-хук и воркер consolidate демона
|
||||
E('ruflo_recall_hook', 'ruflo_memory', 'запускает\nruflo memory search'),
|
||||
E('ruflo_daemon', 'ruflo_memory', 'воркер consolidate\nобращается к памяти'),
|
||||
// 4 узла-правила → Queen (реколлаж 16.05.2026: ruflo — advisory-подсистема; Pravila §14 — queen-триггер)
|
||||
E('pravila', 'ruflo_queen', '§14:\nqueen-триггер'),
|
||||
E('claude_md', 'ruflo_queen', '§3.5: описывает\n(advisory-подсистема)'),
|
||||
E('psr_v1', 'ruflo_queen', '§14:\ncross-ref'),
|
||||
E('tooling', 'ruflo_queen', '§4.10: реестр\n(advisory-подсистема)'),
|
||||
// memory → ruflo
|
||||
E('mem_ruflo', 'ruflo_queen', 'документирует\nинтеграцию'),
|
||||
|
||||
// 3 конфликта ruflo (3-color, iter2 §4)
|
||||
CONFLICT('ruflo_queen', 'pravila', 'Закрыто реколлажем 16.05.2026: нормативка приведена к рантайму — ruflo переописан в advisory/automation-подсистему, декларация уровня −1 убрана', 'GREEN'),
|
||||
CONFLICT('ruflo_memory', 'mem_state', 'Два хранилища памяти не синхронизированы; память ruflo почти пуста (0 записей)', 'BLACK'),
|
||||
CONFLICT('ruflo_daemon', 'ag_pest', 'Worker-jitter демона ruflo усиливает Pest-квирки 73/77 (квирк 72 устранён 16.05 — его jitter больше не усиливает)', 'BLACK'),
|
||||
];
|
||||
// EDGES — moved to automation-graph-data.js
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 3: NODE DETAILS
|
||||
// ════════════════════════════════════════════════════
|
||||
const CATEGORY_LABELS = {
|
||||
rules: 'Правило', plugins: 'Плагин', skills_sp: 'Скил Superpowers',
|
||||
skills_proj: 'Скил проекта', hooks: 'Хук .claude', agents: 'Агент',
|
||||
mcp: 'MCP-сервер', lefthook: 'Lefthook job', memory: 'Memory-файл',
|
||||
ruflo: 'ruflo (изолирован)'
|
||||
};
|
||||
// CATEGORY_LABELS — moved to automation-graph-data.js
|
||||
|
||||
function nd(desc, when, limits, reportsTo, manages, together, conflicts) {
|
||||
// Backward-compat: old 5-arg signature was nd(desc, reportsTo, manages, together, conflicts).
|
||||
@@ -944,6 +554,32 @@ const NODE_DETAILS = {
|
||||
[{ name: 'process-analysis', cond: 'граница: app-код ↔ голова заказчика/мета-слой' }, { name: 'brainstorming', cond: 'хэндофф FEATURE-brief' }]
|
||||
),
|
||||
|
||||
// ── FINANCE-TOOLING C6+C7 (20.05.2026, ADR-012) ──
|
||||
finance_plugin: nd(
|
||||
'Marketplace-плагин (Anthropic): финансы/бухгалтерия — сверка, variance-анализ, US-GAAP-отчётность, закрытие периода, проводки. 8 скилов.',
|
||||
'При учётно-финансовой работе C7 (и сверке/variance для C6). РФ: reconciliation/variance ✅; US-GAAP-скилы ⚠️; SOX ❌; warehouse-MCP DEFERRED.',
|
||||
'plugin finance@knowledge-work-plugins (enabledPlugins). Категория finance-tooling, homed C7. Не UI → вне R6/R14. Tooling §4.36 #61, CLAUDE.md §3.3 #61, ADR-012.',
|
||||
[{ name: 'Tooling', cond: '§4.36 #61 — реестр' }],
|
||||
[{ name: 'FIN2', cond: 'SOX not-applicable РФ' }, { name: 'FIN3', cond: 'граница с operations #51' }],
|
||||
[{ name: 'ru-tax-accounting', cond: 'РФ-специфика поверх US-механики' }]
|
||||
),
|
||||
billing_audit: nd(
|
||||
'Self-authored скил: аудит денежных инвариантов биллинга Лидерры — сумма (bcmath), идемпотентность, tier-резолюция, дрейф reconcile, charge_source.',
|
||||
'При правке/ревью кода Billing — проверить денежную корректность начисления.',
|
||||
'Свой project-скил .claude/skills/billing-audit/ (линтуется, LINT1). Не UI → вне R6/R14. Tooling §4.37 #62, CLAUDE.md §3.3 #62, ADR-012.',
|
||||
[{ name: 'Tooling', cond: '§4.37 #62 — реестр' }],
|
||||
[{ name: 'FIN5', cond: 'объект ≠ process-*/D3/ru-tax' }],
|
||||
[{ name: 'Pest', cond: 'инварианты через тесты' }, { name: 'Boost', cond: 'модели биллинга' }]
|
||||
),
|
||||
ru_tax: nd(
|
||||
'Self-authored скил: РСБУ/НК РФ контекст для выручки Лидерры — НДС/УСН, налоговая база, налогооблагаемое событие, выгрузки бухгалтеру.',
|
||||
'При «как учесть/обложить по РФ» — перевод billing-выручки (выход C6) в учётно-налоговый контекст C7.',
|
||||
'Свой project-скил .claude/skills/ru-tax-accounting/ (линтуется, LINT1). Закрывает РФ-gap US-плагина finance. Не UI → вне R6/R14. Tooling §4.38 #63, CLAUDE.md §3.3 #63, ADR-012.',
|
||||
[{ name: 'Tooling', cond: '§4.38 #63 — реестр' }],
|
||||
[{ name: 'FIN6', cond: '≠ finance plugin/billing-audit/D1/D2' }],
|
||||
[{ name: 'billing-audit', cond: 'выручка C6 → налог.база C7' }, { name: 'finance plugin', cond: 'US-механика' }]
|
||||
),
|
||||
|
||||
// ── СКИЛЫ SUPERPOWERS ────────────────────────────
|
||||
sk_brainstorm: nd(
|
||||
'Продумывает задачу вместе с заказчиком, формулирует варианты A/B/C и согласует дизайн до написания кода.',
|
||||
@@ -1751,6 +1387,88 @@ const NODE_DETAILS = {
|
||||
'Снимок-история, обновляется по ходу спринта.',
|
||||
[], [], []
|
||||
),
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
router_procedure: nd(
|
||||
'Единый источник истины процедуры роутера «задача → узел(ы)» — docs/router-procedure.md v1.0. 5 шагов: hard-floor (§12/§14/§15) → классификация → выбор по триггерам (Tooling Прил. Н §4.X) → проверка связок L1-L12 → исполнение. ADR-011.',
|
||||
'При любой задаче (имплицитно) определяет узел/связку; явно — при разборе routing-решений и в /brain-retro.',
|
||||
'Не вводит новый реестр — формализует процедуру над существующим (Tooling §4.X). Кэша «проверенных цепочек» нет (router-only). Каждая задача — свежая сборка пути.',
|
||||
[{ name: 'Pravila §12/§14/§15', cond: 'hard-floor — шаг 1 процедуры' }, { name: 'CLAUDE.md §3.6', cond: 'cross-ref на router-procedure.md' }],
|
||||
[{ name: 'Tooling Прил. Н §4.X', cond: 'реестр узлов — вход шага 3' }],
|
||||
[{ name: 'observer (Stop-хук)', cond: 'пишет evidence о routing-решениях' }, { name: '/brain-retro', cond: 'факторный анализ routing' }],
|
||||
[]
|
||||
),
|
||||
observer_stophook: nd(
|
||||
'Stop-хук observer (tools/observer-stop-hook.mjs, project-level) — пишет один JSONL-эпизод в docs/observer/episodes-YYYY-MM.jsonl в конце каждого хода + routing-gate. Внутри: transcript-parser (схема v2), routing-detector + choice-detector (provenance), pii-filter (маскирование ПДн). ADR-011 + observer factor-analysis.',
|
||||
'Конец каждого хода (Stop-event). routing-gate: при навязанном методе без routing-тега → decision:block (необойдёмо).',
|
||||
'Только пишет evidence, не вмешивается в нормативку. При внутреннем отказе — маркер observer_error, не тихий пропуск. HK1 §5.3: сосуществует с economy-verifier на Stop (append-chain).',
|
||||
[{ name: 'Pravila §16', cond: 'observer + routing-тег-дисциплина' }, { name: '.claude/settings.json', cond: 'зарегистрирован как Stop-хук' }],
|
||||
[{ name: 'observer-transcript-parser / routing-detector / choice-detector / pii-filter', cond: 'внутренние .mjs модули' }],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'пишет эпизоды' }, { name: '/brain-retro', cond: 'читает то, что хук пишет' }],
|
||||
[{ name: 'hk_verifier', desc: 'HK1 §5.3: оба на Stop-event — коллизии нет (append-chain), оба decision:block отрабатываются', type: 'GREEN' }]
|
||||
),
|
||||
sk_brain_retro: nd(
|
||||
'Проектный скил /brain-retro (.claude/skills/brain-retro/) — раз в спринт читает docs/observer/episodes-*.jsonl и строит факторный анализ: распределение path_type, топ-узлы/связки, вывод исхода, факторная матрица (9 осей × outcome). Анализатор tools/brain-retro-analyzer.mjs.',
|
||||
'Раз в спринт по команде заказчика («брейн-ретро»). Read-only агрегатор.',
|
||||
'Только читает и предлагает кандидатов на корректировку нормативки — не пишет в логи, не правит Tooling/Pravila/PSR_v1. Решение по правкам — за заказчиком.',
|
||||
[{ name: 'Pravila §16', cond: 'evidence-loop, раз в спринт' }, { name: 'PSR_v1 R16', cond: 'brain evidence loop' }],
|
||||
[{ name: 'tools/brain-retro-analyzer.mjs', cond: 'детерминированный анализатор' }],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'читает эпизоды' }],
|
||||
[]
|
||||
),
|
||||
observer_evidence: nd(
|
||||
'Хранилище evidence «мозга» — docs/observer/: помесячные episodes-YYYY-MM.jsonl (схема v2), STATUS.md (панель C1-C5), .read-counter.json (для C3), notes/. Визуализируется страницей docs/observer/dashboard.html (Карта/Лента/Разбор/Агрегат/конфликты; кормится из общего automation-graph-data.js).',
|
||||
'Пишется Stop-хуком (эпизоды) + контролёрами (STATUS.md, счётчик); читается /brain-retro и dashboard.',
|
||||
'ПДн маскируется pii-filter перед записью (§5.4). Помесячное rotation; архив после 12 месяцев. Память ruflo (.swarm/memory.db) — отдельное хранилище, не связано.',
|
||||
[{ name: 'observer Stop-хук', cond: 'источник эпизодов' }],
|
||||
[],
|
||||
[{ name: '/brain-retro', cond: 'читатель' }, { name: 'C3/C4/C5 контролёры', cond: 'счётчик / STATUS / покрытие' }],
|
||||
[]
|
||||
),
|
||||
lh_l1watcher: nd(
|
||||
'Контролёр C1 (lefthook pre-commit job 11, tools/l1-watcher.mjs) — детектор «плагин включён в settings.json без формализации в Tooling Прил. Н». Закрывает трижды повторившийся L1-паттерн (UPM/21st, Sentry/Redis, Anthropic dev-tooling). 0 LLM-вызовов.',
|
||||
'pre-commit при правке .claude/settings.json или docs/Tooling_v8_3.md.',
|
||||
'STRICT: блокирует коммит при drift. Групповые/human-имена разрешаются через tools/.l1-watcher-aliases.txt. ADR-011 spec §6.1.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 11 pre-commit' }, { name: 'ADR-011 §6.1', cond: 'C1' }],
|
||||
[],
|
||||
[{ name: 'tooling', cond: 'сверяет settings.json ↔ Tooling' }, { name: 'C2 cross-ref', cond: 'оба — нормативная консистентность' }],
|
||||
[]
|
||||
),
|
||||
lh_crossref: nd(
|
||||
'Контролёр C2 (lefthook pre-commit job 12, tools/cross-ref-checker.mjs) — детектор version drift между нормативными файлами (Tooling v2.11 collision 17.05). Сверяет версии в §0 cross-refs vs шапки целевых файлов. 0 LLM-вызовов.',
|
||||
'pre-commit при правке Pravila / Tooling / PSR_v1 / CLAUDE.md / MEMORY.md.',
|
||||
'STRICT: блокирует коммит при расхождении версии. Link-anchored детекция + scope-cut по history-маркерам (исторические «наследие»-цепочки не дают ложных срабатываний). ADR-011 spec §6.2.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 12 pre-commit' }, { name: 'ADR-011 §6.2', cond: 'C2' }],
|
||||
[],
|
||||
[{ name: 'claude_md / pravila / tooling / psr_v1', cond: 'сверяет 5 нормативных файлов' }, { name: 'C1 l1-watcher', cond: 'оба — нормативная консистентность' }],
|
||||
[]
|
||||
),
|
||||
lh_obs_obs: nd(
|
||||
'Контролёр C3 (lefthook pre-commit job 13, tools/observer-of-observer.mjs) — счётчик чтений docs/observer/ + 54-недельный self-prune. «Кто наблюдает за наблюдателями»: если evidence-loop не читается ≥54 недель — предлагает архивировать observer.',
|
||||
'pre-commit (каждый коммит) — обновляет/проверяет docs/observer/.read-counter.json.',
|
||||
'Warn-only (скрипт всегда exit 0) — не блокирует. 54 недели (≈год) — порог осознанно поднят заказчиком с 4 недель. ADR-011 spec §6.3.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 13 pre-commit' }, { name: 'ADR-011 §6.3', cond: 'C3' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'читает .read-counter.json' }],
|
||||
[]
|
||||
),
|
||||
lh_status_md: nd(
|
||||
'Контролёр C4 (lefthook post-commit job, tools/status-md-generator.mjs) — генерит docs/observer/STATUS.md (панель: C1-C5 + информационные метрики). Pure JS, Security Guidance #40 compliant.',
|
||||
'post-commit (после каждого коммита) — перегенерит STATUS.md, git add (для следующего коммита).',
|
||||
'Через `|| true` — не блокирует. Метрика «N раз использован» — информационная, не алерт (capability-readiness). ADR-011 spec §6.4.',
|
||||
[{ name: 'lefthook.yml', cond: 'post-commit job' }, { name: 'ADR-011 §6.4', cond: 'C4' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'пишет STATUS.md' }, { name: 'C1/C2/C3', cond: 'агрегирует их сигнал' }],
|
||||
[]
|
||||
),
|
||||
lh_obs_cov: nd(
|
||||
'Контролёр C5 (lefthook pre-commit job 15, tools/observer-coverage-checker.mjs) — observer factor-analysis spec §5.2. Флагует пропуски покрытия (git-активность есть, эпизодов 0) + поломки регистрации (Stop-хук снят из settings.json, post-commit не установлен).',
|
||||
'pre-commit (каждый коммит).',
|
||||
'Warn-only (скрипт всегда exit 0) — не блокирует; находки в docs/observer/STATUS.md строка C5.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 15 pre-commit' }, { name: 'observer factor-analysis §5.2', cond: 'C5' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'проверяет покрытие + регистрацию' }, { name: 'C4 status-md', cond: 'находки в STATUS.md' }],
|
||||
[]
|
||||
),
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
@@ -1871,6 +1589,20 @@ const EDGE_DETAILS = {
|
||||
'mem_ruflo->ruflo_queen': { type: 'документирует', when: 'memory-файл хранит историю ruflo-интеграции', transfers: 'данные', mandatory: 'рекомендуется', rule: 'memory/project_ruflo_integration.md' },
|
||||
'ruflo_memory->mem_state': { type: 'конфликт', when: 'два хранилища памяти не синхронизированы; память ruflo почти пуста', transfers: 'coverage', mandatory: 'опционально', rule: 'нет регламента синхронизации (alpha-баг HNSW #1122)' },
|
||||
'ruflo_daemon->ag_pest': { type: 'конфликт', when: 'daemon worker-jitter усиливает частоту Pest-квирка 72', transfers: 'coverage', mandatory: 'опционально', rule: 'memory feedback_environment квирк #93' },
|
||||
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
'claude_md->router_procedure': { type: 'документирует', when: 'CLAUDE.md §3.6 — cross-ref на router-procedure.md v1.0', transfers: 'документация', mandatory: 'обязательно', rule: 'CLAUDE.md §3.6 (single SoT routing procedure)' },
|
||||
'tooling->router_procedure': { type: 'питает', when: 'реестр Прил. Н §4.X — вход шага 3 процедуры роутера', transfers: 'данные', mandatory: 'обязательно', rule: 'router-procedure.md §4.2 шаг 3' },
|
||||
'pravila->router_procedure': { type: 'подчиняет', when: 'hard-floor §12/§14/§15 — шаг 1 процедуры роутера', transfers: 'контроль', mandatory: 'hard-floor', rule: 'router-procedure.md §4.2 шаг 1 (Pravila §12/§14/§15)' },
|
||||
'pravila->observer_stophook': { type: 'подчиняет', when: '§16: observer + routing-тег-дисциплина', transfers: 'контроль', mandatory: 'обязательно', rule: 'Pravila §16.2/§16.7 (ADR-011)' },
|
||||
'observer_stophook->observer_evidence': { type: 'пишет', when: 'конец каждого хода (Stop-event)', transfers: 'данные (эпизод JSONL)', mandatory: 'обязательно (exit-0-safe)', rule: 'ADR-011 §5.2 (observer scope B)' },
|
||||
'pravila->sk_brain_retro': { type: 'подчиняет', when: '§16: факторный анализ раз в спринт', transfers: 'контроль', mandatory: 'по команде заказчика', rule: 'Pravila §16 + PSR_v1 R16' },
|
||||
'sk_brain_retro->observer_evidence': { type: 'читает', when: 'раз в спринт — агрегирует эпизоды', transfers: 'данные', mandatory: 'read-only', rule: 'ADR-011 §5.5 (/brain-retro — читатель)' },
|
||||
'lh_l1watcher->tooling': { type: 'проверяет', when: 'pre-commit при правке settings.json / Tooling', transfers: 'проверка', mandatory: 'STRICT (блокирует)', rule: 'ADR-011 §6.1 (C1) + lefthook.yml job 11' },
|
||||
'lh_crossref->claude_md': { type: 'проверяет', when: 'pre-commit при правке любого из 5 нормативных файлов', transfers: 'проверка', mandatory: 'STRICT (блокирует)', rule: 'ADR-011 §6.2 (C2) + lefthook.yml job 12' },
|
||||
'lh_obs_obs->observer_evidence': { type: 'проверяет', when: 'pre-commit (каждый коммит) — счётчик чтений', transfers: 'проверка', mandatory: 'warn-only', rule: 'ADR-011 §6.3 (C3) + lefthook.yml job 13' },
|
||||
'lh_status_md->observer_evidence': { type: 'пишет', when: 'post-commit — перегенерит STATUS.md', transfers: 'данные', mandatory: 'не блокирует (|| true)', rule: 'ADR-011 §6.4 (C4) + lefthook.yml post-commit' },
|
||||
'lh_obs_cov->observer_evidence': { type: 'проверяет', when: 'pre-commit (каждый коммит) — покрытие + регистрация', transfers: 'проверка', mandatory: 'warn-only', rule: 'observer factor-analysis §5.2 (C5) + lefthook.yml job 15' },
|
||||
};
|
||||
|
||||
// ════════════════════════════════════════════════════
|
||||
@@ -1889,18 +1621,18 @@ const EDGE_DETAILS = {
|
||||
// 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 дней)
|
||||
const META_SNAPSHOT = '20.05.2026'; // дата генерации значений
|
||||
const META_WINDOW = '09–20.05.2026'; // окно подсчёта использования (12 дней)
|
||||
|
||||
// uses: number — измеримый узел (0 = реально простаивал); null — измерить нельзя
|
||||
// (узел-правило / плагин-обёртка / автономный демон / пассивное хранилище) → «нет данных».
|
||||
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | 'интеграция' | 'DEFERRED' | '—'
|
||||
const NODE_META = {
|
||||
// ── ПРАВИЛА (4) — узлы-правила, напрямую не вызываются ──
|
||||
pravila: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_md: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
psr_v1: { since: '09.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
tooling: { since: '06.05.2026', changed: '18.05.2026', uses: null, usesSrc: '—' },
|
||||
pravila: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
claude_md: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
psr_v1: { since: '09.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
tooling: { since: '06.05.2026', changed: '19.05.2026', uses: null, usesSrc: '—' },
|
||||
|
||||
// ── ПЛАГИНЫ (5) ──
|
||||
superpowers: { since: '09.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
@@ -2069,6 +1801,25 @@ const NODE_META = {
|
||||
// ── 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' },
|
||||
|
||||
// ── FINANCE-TOOLING C6+C7 (20.05.2026) ──
|
||||
finance_plugin: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' },
|
||||
billing_audit: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' },
|
||||
ru_tax: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' },
|
||||
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
// uses: observer_stophook=31 эпизодов; lh_obs_obs/status_md/obs_cov=112 коммитов с 19.05
|
||||
// (glob-less, каждый коммит); lh_l1watcher=10, lh_crossref=13 (коммиты по glob с 19.05);
|
||||
// observer_evidence=0 (.read-counter.json — 0 чтений); router_procedure=null (rule-like).
|
||||
router_procedure: { since: '19.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
observer_stophook: { since: '19.05.2026', changed: '—', uses: 31, usesSrc: 'хук (эпизоды)' },
|
||||
sk_brain_retro: { since: '19.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
observer_evidence: { since: '19.05.2026', changed: '—', uses: 0, usesSrc: 'observer counter' },
|
||||
lh_l1watcher: { since: '19.05.2026', changed: '—', uses: 10, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_crossref: { since: '19.05.2026', changed: '—', uses: 13, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_obs_obs: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_status_md: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_obs_cov: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
};
|
||||
|
||||
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
|
||||
@@ -2102,130 +1853,10 @@ const DUP_NODE_SET = new Set(DUP_BY_NODE.keys()); // 12 узлов-членов
|
||||
// (NODE_SECTION). Часть разделов пока пустая — это бизнес-домены, под которые
|
||||
// в карте dev-автоматики ещё нет узлов. Основа будущего «мозга»: 1 раздел =
|
||||
// 1 playbook «как и что делать».
|
||||
const SECTION_BUCKETS = [
|
||||
{ id: 'A', label: 'Технические и продуктовые' },
|
||||
{ id: 'B', label: 'Коммуникации' },
|
||||
{ id: 'C', label: 'Бизнес и операции' },
|
||||
{ id: 'D', label: 'Право и комплаенс' },
|
||||
{ id: 'E', label: 'Мета и управление' },
|
||||
];
|
||||
const SECTIONS = [
|
||||
{ id: 'A1', bucket: 'A', label: 'Программирование — backend' },
|
||||
{ id: 'A2', bucket: 'A', label: 'Программирование — frontend' },
|
||||
{ id: 'A3', bucket: 'A', label: 'Программирование — интеграции (API, вебхуки)' },
|
||||
{ id: 'A4', bucket: 'A', label: 'Дизайн (UI/UX, графика, бренд)' },
|
||||
{ id: 'A5', bucket: 'A', label: 'Тестирование, QA и отладка' },
|
||||
{ id: 'A6', bucket: 'A', label: 'Архитектура систем' },
|
||||
{ id: 'A7', bucket: 'A', label: 'DevOps, инфраструктура, деплой' },
|
||||
{ id: 'A8', bucket: 'A', label: 'Информационная безопасность' },
|
||||
{ id: 'A9', bucket: 'A', label: 'Работа с данными (БД, миграции, RLS)' },
|
||||
{ id: 'A10', bucket: 'A', label: 'Аналитика и отчётность (BI)' },
|
||||
{ id: 'A11', bucket: 'A', label: 'ML / AI-разработка' },
|
||||
{ id: 'B1', bucket: 'B', label: 'Голосовое общение по телефону' },
|
||||
{ id: 'B2', bucket: 'B', label: 'Мессенджеры' },
|
||||
{ id: 'B3', bucket: 'B', label: 'Электронная почта' },
|
||||
{ id: 'B4', bucket: 'B', label: 'SMS-рассылки' },
|
||||
{ id: 'B5', bucket: 'B', label: 'Видеосвязь' },
|
||||
{ id: 'B6', bucket: 'B', label: 'Чат на сайте / онлайн-консультант' },
|
||||
{ id: 'B7', bucket: 'B', label: 'Социальные сети' },
|
||||
{ id: 'B8', bucket: 'B', label: 'Push / in-app уведомления' },
|
||||
{ id: 'C1', bucket: 'C', label: 'Маркетинг и лидогенерация' },
|
||||
{ id: 'C2', bucket: 'C', label: 'Продажи' },
|
||||
{ id: 'C3', bucket: 'C', label: 'Квалификация и обработка лидов' },
|
||||
{ id: 'C4', bucket: 'C', label: 'Работа с поставщиками лидов' },
|
||||
{ id: 'C5', bucket: 'C', label: 'Клиентский успех, поддержка, удержание' },
|
||||
{ id: 'C6', bucket: 'C', label: 'Финансы — биллинг и тарификация' },
|
||||
{ id: 'C7', bucket: 'C', label: 'Финансы — бухгалтерия и налоги' },
|
||||
{ id: 'C8', bucket: 'C', label: 'HR и управление персоналом' },
|
||||
{ id: 'C9', bucket: 'C', label: 'Управление проектами' },
|
||||
{ id: 'C10', bucket: 'C', label: 'Бизнес-процессы (общее)' },
|
||||
{ id: 'D1', bucket: 'D', label: 'Юриспруденция и договорная работа' },
|
||||
{ id: 'D2', bucket: 'D', label: 'Защита ПДн (152-ФЗ, РКН)' },
|
||||
{ id: 'D3', bucket: 'D', label: 'Аудит и управление рисками' },
|
||||
{ id: 'E1', bucket: 'E', label: 'Мета — правила и нормативка' },
|
||||
{ id: 'E2', bucket: 'E', label: 'Мета — оркестрация и автоматизация (Claude-воркфлоу)' },
|
||||
{ id: 'E3', bucket: 'E', label: 'Документация' },
|
||||
{ id: 'E4', bucket: 'E', label: 'Управление знаниями и память' },
|
||||
{ id: 'E5', bucket: 'E', label: 'Стратегия и принятие решений' },
|
||||
{ id: 'E6', bucket: 'E', label: 'Обучение и онбординг' },
|
||||
{ id: 'E7', bucket: 'E', label: 'Исследования' },
|
||||
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
|
||||
];
|
||||
// Узел -> раздел. Покрывает все 125 узлов карты.
|
||||
const NODE_SECTION = {
|
||||
// правила (4)
|
||||
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
|
||||
// плагины (5)
|
||||
superpowers: 'E2', fd_plugin: 'A4', upm: 'A4', claude_md_mgmt: 'E1', hookify_plugin: 'E2',
|
||||
// скилы superpowers (14)
|
||||
sk_brainstorm: 'E5', sk_wplans: 'E2', sk_eplans: 'E2', sk_subagent: 'E2',
|
||||
sk_tdd: 'A5', sk_verify: 'A5', sk_debug: 'A5', sk_parallel: 'E2',
|
||||
sk_worktree: 'E2', sk_pr: 'E2', sk_coderev: 'A5', sk_spreview: 'A5',
|
||||
sk_wskills: 'E2', sk_elements: 'E3',
|
||||
// скилы проекта (2)
|
||||
sk_rls: 'A9', sk_qitem: 'E3',
|
||||
// хуки (5)
|
||||
hk_session: 'E4', hk_economy: 'E2', hk_pre_claude: 'E1', hk_post_md: 'E3', hk_post_schema: 'A9',
|
||||
// агенты (11)
|
||||
ag_explore: 'E2', ag_general: 'E2', ag_plan: 'E2', ag_pest: 'A5', ag_guide: 'E6',
|
||||
ag_statusline: 'E2', ag_hookify: 'E2', ag_pcreator: 'E2', ag_pvalid: 'E2',
|
||||
ag_skreview: 'E2', ag_rls: 'A9',
|
||||
// MCP-серверы (7)
|
||||
mcp_21st: 'A4', mcp_pw: 'A5', mcp_gh: 'A7', mcp_boost: 'A1',
|
||||
mcp_redis: 'A7', mcp_sentry: 'A7', mcp_semgrep: 'A8',
|
||||
// lefthook jobs (10)
|
||||
lh_mdlint: 'E3', lh_cspell: 'E3', lh_stylelint: 'A2', lh_eslint: 'A2',
|
||||
lh_lychee: 'E3', lh_gitleaks: 'A8', lh_gitleaks2: 'A8', lh_pint: 'A1',
|
||||
lh_larastan: 'A1', lh_squawk: 'A9',
|
||||
// memory files (16)
|
||||
mem_user: 'E4', mem_comm: 'E4', mem_env: 'E4', mem_sp: 'E4', mem_plugins: 'E4',
|
||||
mem_handoff: 'E4', mem_redesign: 'E4', mem_devindices: 'E4', mem_phase1: 'E4',
|
||||
mem_state: 'E4', mem_brain: 'E4', mem_supplier: 'E4', mem_audit: 'E4',
|
||||
mem_archive: 'E4', mem_github: 'E4', mem_ruflo: 'E4',
|
||||
// ruflo (9)
|
||||
ruflo_queen: 'E2', ruflo_plugins: 'E2', ruflo_workers: 'E2', ruflo_agents_catalog: 'E2',
|
||||
ruflo_commands: 'E2', ruflo_daemon: 'E2', ruflo_memory: 'E4', ruflo_mcp: 'E2',
|
||||
ruflo_recall_hook: 'E4',
|
||||
// АУДИТ-АКТУАЛИЗАЦИЯ 16.05.2026 — новые узлы
|
||||
skill_creator: 'E8', claude_setup: 'E8', plugin_dev: 'E2', context7: 'E7',
|
||||
hk_self_check: 'E2', hk_skill_marker: 'E2', hk_skill_check: 'E2', hk_state_guard: 'E2',
|
||||
hk_postcompact: 'E2', hk_verifier: 'E2', hk_ruflo_queen: 'E2',
|
||||
sk_regression: 'A5',
|
||||
mem_audit_b: 'E4', mem_audit_c: 'E4', mem_suppliercrm: 'E4', mem_audit12: 'E4',
|
||||
mem_audit14: 'E4', mem_sprint1: 'E4', mem_sprint2: 'E4', mem_sprint3: 'E4',
|
||||
// A6 architecture-tooling 17.05.2026 — раздел «Архитектура систем» наполнен (+deptrac)
|
||||
adr_kit: 'A6', arch_patterns: 'A6', mermaid_skill: 'A6', deptrac: 'A6',
|
||||
// D3 audit-security 17.05.2026 — раздел «Аудит и управление рисками» наполнен
|
||||
tob_skills: 'D3', sec_guidance: 'D3', sk_security_review: 'D3', sk_audit_portal: 'D3',
|
||||
// C9 project-management-tooling 17.05.2026 — раздел «Управление проектами» наполнен
|
||||
ccpm: 'C9', product_mgmt: 'C9',
|
||||
// A4 design-tooling 17.05.2026 — раздел «Дизайн (UI/UX, графика, бренд)» расширен (3→6 узлов)
|
||||
mcp_figma: 'A4', mcp_icons: 'A4', design_plugin: 'A4',
|
||||
// A3 integration-tooling 17.05.2026 — раздел «Программирование — интеграции» наполнен
|
||||
ag_apidocs: 'A3', mcp_openapi: 'A3',
|
||||
// A11 ml-ai-tooling 17.05.2026 — раздел «ML / AI-разработка» наполнен
|
||||
claude_api: 'A11', promptfoo: 'A11', data_scientist: 'A11',
|
||||
// C10 business-process 17.05.2026 — раздел «Бизнес-процессы (общее)» наполнен
|
||||
ops_plugin: 'C10', process_modeling: 'C10', process_analysis: 'C10',
|
||||
// discovery-interview 18.05.2026 — раздел E5 «Стратегия и принятие решений» (рядом с brainstorming)
|
||||
discovery_interview: 'E5',
|
||||
};
|
||||
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
|
||||
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
|
||||
// частично кросс-реф существующих интеграционных инструментов. NODE_SECTION 1:1 не трогается.
|
||||
const NODE_SECTION_SECONDARY = {
|
||||
mcp_boost: ['A3'],
|
||||
context7: ['A3'],
|
||||
ag_pest: ['A3'],
|
||||
mcp_semgrep: ['A3'],
|
||||
mcp_sentry: ['A3'],
|
||||
// C10 business-process 17.05.2026 — кросс-реф reuse-инструментов раздела «Бизнес-процессы»
|
||||
mermaid_skill: ['C10'],
|
||||
arch_patterns: ['C10'],
|
||||
ccpm: ['C10'],
|
||||
product_mgmt: ['C10'],
|
||||
sk_wplans: ['C10'],
|
||||
};
|
||||
// SECTION_BUCKETS — moved to automation-graph-data.js
|
||||
// SECTIONS — moved to automation-graph-data.js
|
||||
// NODE_SECTION — moved to automation-graph-data.js
|
||||
// NODE_SECTION_SECONDARY — moved to automation-graph-data.js
|
||||
// Производные индексы для рендера панели и Паспорта.
|
||||
const SECTION_BY_ID = new Map(SECTIONS.map(s => [s.id, s]));
|
||||
const SECTION_NODES = new Map(SECTIONS.map(s => [s.id, []]));
|
||||
@@ -2265,18 +1896,7 @@ const WISHLIST = [
|
||||
// ════════════════════════════════════════════════════
|
||||
// SECTION 4: VIS INIT
|
||||
// ════════════════════════════════════════════════════
|
||||
const GROUPS = {
|
||||
rules: { color: { background: '#073642', border: '#268bd2', highlight: { border: '#93a1a1', background: '#0d4a5a' } }, font: { color: '#fdf6e3', size: 13, bold: true } },
|
||||
plugins: { color: { background: '#001a00', border: '#859900', highlight: { border: '#b8cc00', background: '#002600' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
skills_sp: { color: { background: '#1a0033', border: '#6c71c4', highlight: { border: '#9b9fea', background: '#250047' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
skills_proj: { color: { background: '#2d0020', border: '#d33682', highlight: { border: '#e869a8', background: '#3d0028' } }, font: { color: '#fdf6e3', size: 12 } },
|
||||
hooks: { color: { background: '#002233', border: '#2aa198', highlight: { border: '#4dd7ce', background: '#003344' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
agents: { color: { background: '#1a1200', border: '#b58900', highlight: { border: '#e0ad00', background: '#261a00' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
mcp: { color: { background: '#2d1200', border: '#cb4b16', highlight: { border: '#ff6b30', background: '#3d1900' } }, font: { color: '#fdf6e3', size: 11 } },
|
||||
lefthook: { color: { background: '#2d0000', border: '#dc322f', highlight: { border: '#ff5f5c', background: '#3d0000' } }, font: { color: '#fdf6e3', size: 10 } },
|
||||
memory: { color: { background: '#112233', border: '#586e75', highlight: { border: '#839496', background: '#1a2f40' } }, font: { color: '#eee8d5', size: 10 } },
|
||||
ruflo: { color: { background: '#262626', border: '#555555', highlight: { border: '#777777', background: '#333333' } }, font: { color: '#8a8a8a', size: 12, bold: true }, shapeProperties: { borderDashes: [4, 4] } },
|
||||
};
|
||||
// GROUPS — moved to automation-graph-data.js
|
||||
|
||||
const nodesDS = new vis.DataSet(NODES);
|
||||
const edgesDS = new vis.DataSet(EDGES);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
# Локаторы формы добавления rt-проекта crm.bp-gr.ru (recon 2026-05-19)
|
||||
|
||||
**Среда:** `https://crm.bp-gr.ru/admin/visit/rt`, кнопка «Добавить проект» (label `[title="Добавить проект"]`, классы `el-button deal-req-is-empty-btn el-button--default`) открывает диалог.
|
||||
|
||||
**Стек:** **смешанный** — внешний контейнер `v-dialog v-dialog--active v-dialog--persistent` (Vuetify), внутри форма `form.el-form.el-form--label-left` (Element UI).
|
||||
|
||||
**Метод записи:** Playwright MCP `browser_evaluate` querySelector + `closest('.el-form')` от `[for="srcrt"]`. 10 `.el-form-item` в форме (verified `form.querySelectorAll('.el-form-item').length === 10`).
|
||||
|
||||
## Маппинг формы → DTO
|
||||
|
||||
| # | label `for=` | UI-поле | DTO-поле | Контракт |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `tag` | Тег | `dto.tag` | el-input text |
|
||||
| 2 | `srcrt` | Источник данных | `dto.platform` ⇒ ровно 1 включённый из B1/B2/B3 | 3 el-checkbox с textContent `B1`/`B2`/`B3`. Initial — **все три checked**. Inputs **не имеют `name` атрибута**. Идентификация — по `textContent`. Состояние — `.is-checked` класс на родительском `.el-checkbox`. |
|
||||
| 3 | `name` | Название проекта | `dto.uniqueKey` | el-input text |
|
||||
| 4 | `type` | Источники сбора | `dto.signalType` | el-select **readonly input** (открывается кликом). 5 опций: `Сайты`, `Звонки`, `СМС`, `Ретро сайты`, `Ретро звонки`. **Только первые три используются**: `site → "Сайты"`, `call → "Звонки"`, `sms → "СМС"`. Initial value — `Сайты`. |
|
||||
| 5 | (нет) | Период (slider 0–24, value=«HH-HH») | **НЕТ в DTO** — поле новое, отсутствует в `SupplierProjectDto` | `.el-slider[aria-valuemin=0][aria-valuemax=24]`, aria-valuetext формата `"10-18"`. Default — `10-18`. **Tier-2 оставляет default** (DTO не несёт это поле). |
|
||||
| 6 | (нет) | switch «Включить/Исключить» — режим регионов | `dto.regionsReverse` (`true` ⇒ «Исключить») | **ИСПРАВЛЕНО live-дебагом 2026-05-19:** единственный `.el-switch` на форме — это **include/exclude регионов** (`regions_reverse`), а НЕ статус active/paused. Текст — «ВключитьИсключить» (две метки). Статус проекта (`status`) задаётся дефолтом формы (`true`); отдельного UI-switch для active/paused НЕТ. `manage-project.js` этот switch не трогает (regions skip в Tier-2 MVP). |
|
||||
| 7 | `regions` | Регион | `dto.regions[]` + `dto.regionsReverse` | el-select multiple. Опции — **имена регионов** (например, `Республика Адыгея`), не id. **Архитектурный gap: DTO несёт int[] (id), форма требует имена** — нужен mapping id→name. См. секцию «Открытые вопросы». В рамках live-теста (Task 4) tested с **пустым** `regions=[]`. |
|
||||
| 8 | `limit_off` | Разделять по проектам | **НЕТ в DTO** | el-checkbox. Initial unchecked. Tier-2 оставляет default. |
|
||||
| 9 | `content` | Список сайтов / номеров / отправителей | `dto.uniqueKey` ⇒ textarea content | el-tabs `Список` (active) / `Файл`. Нам нужна вкладка `Список` (default active). Внутри textarea. Label меняется в зависимости от `type`: для `Сайты` — `Список сайтов`, для `Звонки` — `Список номеров`, для `СМС` — `Список отправителей` (не verified — см. Открытые вопросы). |
|
||||
| 10 | `limit` | Лимит в день | `dto.limit` | `.el-input-number` ⇒ внутри `.el-input input.el-input__inner` (тип text, не number). Кнопки +/− — `.el-input-number__increase` / `.el-input-number__decrease`. Для надёжности — `fill(String(dto.limit))` в input напрямую. |
|
||||
|
||||
## Кнопки
|
||||
|
||||
| Действие | Локатор | Notes |
|
||||
|---|---|---|
|
||||
| Save | `.v-dialog--active button:has-text("Сохранить")` | `el-button el-button--default` (НЕ primary; нет цветового акцента). Сохраняет + POST на `/admin/visit/rt-project-save`. |
|
||||
| Cancel | `.v-dialog--active button:has-text("Отмена")` | Закрывает диалог. |
|
||||
|
||||
## Канонические локаторы Playwright (для Task 3 manage-project.js)
|
||||
|
||||
```javascript
|
||||
// Helper: form-item с конкретным `for=` атрибутом
|
||||
function fieldByFor(page, attrFor) {
|
||||
return page.locator(`.el-form-item:has(.el-form-item__label[for="${attrFor}"])`);
|
||||
}
|
||||
|
||||
// 1. Tag — text input
|
||||
await fieldByFor(page, 'tag').locator('input.el-input__inner').fill(dto.tag);
|
||||
|
||||
// 2. Platforms (srcrt) — sub-checkboxes B1/B2/B3 by textContent
|
||||
const platformContainer = fieldByFor(page, 'srcrt');
|
||||
for (const p of ['B1', 'B2', 'B3']) {
|
||||
const cb = platformContainer.locator('.el-checkbox', {hasText: new RegExp(`^${p}$`)});
|
||||
const wanted = (dto.platforms || []).includes(p);
|
||||
const isChecked = (await cb.getAttribute('class'))?.includes('is-checked');
|
||||
if (!!isChecked !== wanted) await cb.click();
|
||||
}
|
||||
|
||||
// 3. Name
|
||||
await fieldByFor(page, 'name').locator('input.el-input__inner').fill(dto.name);
|
||||
|
||||
// 4. Type — el-select with label match
|
||||
const typeLabel = {site: 'Сайты', call: 'Звонки', sms: 'СМС'}[dto.signal_type];
|
||||
await fieldByFor(page, 'type').locator('.el-select input.el-input__inner').click();
|
||||
// Wait for dropdown popup (rendered outside form into body)
|
||||
await page.locator('.el-select-dropdown__item', {hasText: new RegExp(`^${typeLabel}$`)}).click();
|
||||
|
||||
// 6. Switch (active) — by class .el-switch in form-item without label-for
|
||||
const switchItem = page.locator('.el-form-item').filter({has: page.locator('.el-switch span:has-text("Включить")')});
|
||||
const switchEl = switchItem.locator('.el-switch');
|
||||
const isActive = (await switchEl.getAttribute('class'))?.includes('is-checked');
|
||||
if (!!isActive !== !!dto.active) await switchEl.click();
|
||||
|
||||
// 9. Content list — текстbox in active tab "Список"
|
||||
await fieldByFor(page, 'content').locator('.el-tabs__item:has-text("Список")').click(); // ensure tab active
|
||||
await fieldByFor(page, 'content').locator('textarea.el-textarea__inner').fill(dto.domains.join('\n'));
|
||||
|
||||
// 10. Limit
|
||||
await fieldByFor(page, 'limit').locator('input.el-input__inner').fill(String(dto.limit));
|
||||
|
||||
// Save (intercept response)
|
||||
const [saveResp] = await Promise.all([
|
||||
page.waitForResponse(r => r.url().endsWith('/admin/visit/rt-project-save') && r.request().method() === 'POST'),
|
||||
page.locator('.v-dialog--active button:has-text("Сохранить")').click(),
|
||||
]);
|
||||
const body = await saveResp.json();
|
||||
if (body.status !== 'OK') throw new Error(`Portal rejected save: ${body.message}`);
|
||||
const externalId = String(body.id);
|
||||
```
|
||||
|
||||
## Открытые вопросы (gaps между формой и DTO)
|
||||
|
||||
1. **`workdays` отсутствует на форме create.** DTO имеет `workdays: int[1..7]` (дни недели). На форме add-project — **только slider «Период» (часы 0-24)**, дни недели отсутствуют. Возможные стратегии для Tier-2:
|
||||
- **(a)** После `rt-project-save` сделать дополнительный AJAX-апдейт через `SupplierPortalClient::updateProject` с workdays — но это противоречит идее Tier-2 как пути отказа от Tier-1 (если Tier-1 не работает, дополнительный AJAX от Tier-2 тоже скорее всего не сработает).
|
||||
- **(b)** Принять, что Tier-2 не выставляет workdays — портал применяет default (все 7 дней?). Зафиксировать в Tier-3 manual queue payload, чтобы оператор скорректировал вручную.
|
||||
- **(c)** Workdays задаются на странице **редактирования** rt-проекта, не создания — проверить.
|
||||
- **Решение принять в Task 3 design**. Скорее всего (b) — Tier-2 — fallback, не идеальная замена.
|
||||
|
||||
2. **`regions` mapping id → name.** DTO несёт `int[]` (id регионов), форма требует имена. Mapping должен быть:
|
||||
- **(a)** В JS-bridge: жёстко зашить регионы id↔name в `manage-project.js` (на ~89 регионов, ~3 KB словарь).
|
||||
- **(b)** В PHP: `FormProjectChannel::mapDto` конвертирует id→name перед отправкой в bridge.
|
||||
- **(c)** В Tier-2 — игнорировать regions (передавать пустой массив, регионы выставлять отдельным AJAX-апдейтом).
|
||||
- **Решение в Task 3 design.** Скорее всего (c) для MVP — Tier-2 редко используется, регионы — некритичный default.
|
||||
|
||||
3. **Label вкладки «Список» меняется по типу.** Verified: для `type=Сайты` label — `Список сайтов`. Для `type=Звонки` / `Сайты` / `СМС` метки textarea-вкладки могут отличаться. Но `for="content"` на label form-item стабильно — селектор `fieldByFor(page, 'content')` достаточен независимо от type.
|
||||
|
||||
4. **«Период» (slider 10-24).** Default `10-18` (часы активности). DTO не несёт, оставляем default. Если в будущем понадобится — расширять DTO + добавить slider-control в bridge.
|
||||
|
||||
5. **«Разделять по проектам» (`limit_off`).** Семантика не verified — оставляем unchecked (default).
|
||||
|
||||
6. **«Ретро сайты» / «Ретро звонки» type'ы.** Не в DTO (мы используем только site/call/sms). Зафиксировать как **не поддерживается** в FormProjectChannel — выкинуть `InvalidArgumentException` если DTO.signalType не в `{site,call,sms}`.
|
||||
|
||||
## Снимки страницы
|
||||
|
||||
Все Playwright snapshots в `.playwright-mcp/page-2026-05-19T13-2*.yml` (untracked, gitignored).
|
||||
|
||||
## Live-smoke (Task 4) — 2026-05-19
|
||||
|
||||
`_smoke_form_channel.php` (DTO platform B1 / site / limit 10): create через Tier-2
|
||||
(`FormProjectChannel` → `manage-project.js`) → `external_id=12731690` → delete через
|
||||
Tier-1 AJAX → **OK**. Form-канал доказан end-to-end против живого портала.
|
||||
|
||||
### Находки live-дебага
|
||||
|
||||
1. **Портал валидирует формат домена.** `content` (домены для site-проекта)
|
||||
должен быть валидным хостом — **lowercase, дефисы, без underscore, без
|
||||
uppercase**. Невалидный (`lidpotok-smoke-LIDERRA_FORM_SMOKE_NNN.example`) →
|
||||
`rt-project-save` отвечает `{status:"Error",message:"Введите домены"}` (HTTP 200).
|
||||
Для site-проектов `SupplierProjectDto::uniqueKey` обязан быть валидным доменом.
|
||||
|
||||
2. **Multi-source save создаёт N rt-проектов.** Если в форме включено несколько
|
||||
`srcrt`/`srcbl`/`srcmt` (B1/B2/B3), один `rt-project-save` создаёт по проекту
|
||||
на каждый источник; `id` в ответе — последний. `manage-project.js` снимает
|
||||
лишние чекбоксы под `dto.platform` (single) → ровно 1 проект. При работе
|
||||
напрямую с `SupplierPortalClient::saveProject` помнить: дефолт формы — все 3
|
||||
источника включены.
|
||||
|
||||
3. **Единственный `.el-switch` на форме — `regions_reverse`** (include/exclude
|
||||
регионов, текст «Включить/Исключить»), НЕ статус active/paused. Статус проекта
|
||||
(`status`) задаётся дефолтом формы (`true`), отдельного UI-switch нет. Recon
|
||||
row 6 (выше) скорректирован: switch ≠ status.
|
||||
|
||||
4. **type-select / клик вкладки ремоунтят content tab-pane.** Element UI: re-click
|
||||
уже-активного значения select'а / вкладки пере-рендерит pane → textarea
|
||||
детачится. `manage-project.js` кликает type/вкладку только при реальной смене
|
||||
значения (commit `b9791c5`).
|
||||
|
||||
## 3-tier failover live-smoke (Task 5b) — 2026-05-19
|
||||
|
||||
`_smoke_failover_3tier.php` против живого портала:
|
||||
|
||||
| Прогон | Сценарий | Результат |
|
||||
|---|---|---|
|
||||
| 1 | Tier-1 live (`AjaxProjectChannel`) | OK — `external_id=12732078`, удалён |
|
||||
| 2 | force-fail tier-1 (DI-стаб) → Tier-2 form (`FormProjectChannel`) | OK — `external_id=12732091`, удалён |
|
||||
| 3 | force-fail tier-1+2 → Tier-3 (`escalateToTier3`) | `TierEscalatedException` + `SupplierManualSyncQueue` row (`reason=form_save_error`) + alert mail; queue row удалён |
|
||||
|
||||
`FailoverProjectChannel` эскалация доказана end-to-end. Полный Supplier-suite
|
||||
(`tests/Feature/Supplier` + `tests/Unit/Supplier` + `tests/Feature/Integration`)
|
||||
— **156/156 passed**, 0 регрессий. Лог: `app/storage/logs/smoke-failover-2026-05-19.log`.
|
||||
@@ -0,0 +1,80 @@
|
||||
# Discovery-brief: переделка миграции проектов + распределения лидов
|
||||
|
||||
**Дата:** 2026-05-20 · **Режим:** FEATURE (discovery-interview) · **Статус:** зафиксировано заказчиком, реализация НЕ начата.
|
||||
|
||||
## Проблема
|
||||
|
||||
Два связанных изменения в логике создания/миграции проектов:
|
||||
|
||||
1. Экспорт проекта Лидерра → портал поставщика crm.bp-gr.ru сейчас неполный и отложенный (каркас при создании, параметры — только ночью).
|
||||
2. Алгоритм распределения входящих лидов между клиентами не имеет потолка получателей — один номер может уйти 20 клиентам, владелец номера «сходит с ума».
|
||||
|
||||
## Архитектура (как есть)
|
||||
|
||||
- Клиент (tenant) создаёт/правит проект в ЛК → `ProjectService` запускает `SyncSupplierProjectJob` (очередь) — ставит на портал **каркас** (лимит 0, дни — вся неделя, регионы пусто).
|
||||
- Ночной `SyncSupplierProjectsJob` (крон 20:30 МСК, `app/routes/console.php:52`) сверяет квоты/дни/регионы и дописывает на портал через `FailoverProjectChannel` (ярус1 AJAX → ярус2 форма → ярус3 ручная очередь).
|
||||
- Входящий лид → `RouteSupplierLeadJob` → `LeadRouter::matchEligibleProjects` → Deal-копия каждому eligible клиенту.
|
||||
|
||||
## Зафиксированные требования
|
||||
|
||||
### Канал экспорта — два режима
|
||||
|
||||
- **R1. Режим «Онлайн».** Создание/изменение проекта в ЛК → перенос поставщику сразу, с полными параметрами (лимит/дни/регионы), не каркасом.
|
||||
- **R2. Режим «Пакетный»** (текущий ночной) — оставить, но время **20:30 → 18:00 МСК**. Снижает нагрузку при многократных правках одного проекта за день.
|
||||
- **R3.** Выбор режима — переключатель в админке.
|
||||
- Мотив: онлайн нужен для быстрой отработки/тестирования миграции; пакетный — для прод-нагрузки.
|
||||
|
||||
### Маппинг формы проекта (подтверждено живым тестом на портале)
|
||||
|
||||
- **R5.** Слать **один** `save` с тремя флагами `srcrt+srcbl+srcmt` — портал сам создаёт 3 проекта (B1/B2/B3). Сейчас код шлёт 3 раздельных save. Меньше нагрузки.
|
||||
- **R6.** Лимит делит **сам портал поровну** (проверено: лимит 15 → проекты по 5). Убрать наш ручной split в `SupplierQuotaAllocator::distributeForPlatform`.
|
||||
- **R7.** `tag` = **название региона** клиента (не `_lidpotok`). При 2+ регионах — **отдельный save на каждый регион** (1 регион → 3 проекта, 2 → 6). Тег региона приходит обратно в лиде (`raw_payload['tag']`) → **протянуть в `deals`** (поле тег = регион, для дальнейшей работы со сделками).
|
||||
|
||||
### Алгоритм распределения лидов (полностью пересмотрен — группировка ВЫКИНУТА)
|
||||
|
||||
Решения, принятые в диалоге (нюансы заказчика: заказ ≠ поставка; платим за фактически поступившие лиды; лимит — жёсткий потолок, недобор допустим):
|
||||
|
||||
- **Заказ у поставщика** = `max( наибольший_лимит , ceil(Σ всех лимитов / 3) )`.
|
||||
- `ceil(Σ/3)` — ёмкость шаринга (один лид продаётся максимум 3 раза).
|
||||
- `наибольший_лимит` — крупнейший клиент должен иметь достаточно разных лидов, чтобы добрать.
|
||||
- Заказ = потолок запроса; придёт ≤; платим за фактически поступившие.
|
||||
- **Распределение лида** = 3 случайным клиентам из тех, у кого остаток лимита > 0 (`получено_сегодня < лимит`).
|
||||
- cap=3 — защита владельца номера;
|
||||
- выбор только из недобравших → лимит-потолок не превышается;
|
||||
- недобор допустим (поставщик шлёт сколько хочет).
|
||||
- **Группировка клиентов НЕ нужна** — рандом из недобравших сам обеспечивает cap=3 + соблюдение лимита + максимизацию шаринга.
|
||||
|
||||
### Примеры расчёта заказа (verified в диалоге)
|
||||
|
||||
| Клиенты | Σ | наиб. лимит | ceil(Σ/3) | Заказ |
|
||||
|---|---|---|---|---|
|
||||
| 5, 5, 10, 20 | 40 | 20 | 14 | **20** |
|
||||
| 15×5 + 10 (16 клиентов) | 85 | 10 | 29 | **29** |
|
||||
| 3×15 | 45 | 15 | 15 | **15** |
|
||||
| 3×15 + 30 | 75 | 30 | 25 | **30** |
|
||||
| 4×10 | 40 | 10 | 14 | **14** |
|
||||
|
||||
## Что НЕ так в текущей реализации (пины)
|
||||
|
||||
- **Заказ:** `SupplierQuotaAllocator::allocate` (`app/app/Services/Supplier/SupplierQuotaAllocator.php:55`) суммирует `Σ daily_limit` + делит на B1/B2/B3 (`:73`). Надо: формула `max(наиб, ceil(Σ/3))`, split убрать (портал делит сам).
|
||||
- **Распределение/cap:** `LeadRouter::matchEligibleProjects` (`app/app/Services/LeadRouter.php:46`) возвращает всех eligible; `RouteSupplierLeadJob` (`app/app/Jobs/RouteSupplierLeadJob.php:115`) создаёт копию каждому — нет cap=3, нет рандома.
|
||||
- **Время крона:** `app/routes/console.php:52` — 20:30, надо 18:00.
|
||||
- **Экспорт по одному флагу:** `SupplierPortalClient::toPayload` (`app/app/Services/Supplier/SupplierPortalClient.php:422`) шлёт один src-флаг; `SyncSupplierProjectJob` (`app/app/Jobs/SyncSupplierProjectJob.php:64`) — раздельные save по платформам.
|
||||
|
||||
## Открытые под-вопросы (для brainstorming перед реализацией)
|
||||
|
||||
1. Scope переключателя режима — глобально (SaaS) или per-tenant?
|
||||
2. Поведение онлайн-режима при недоступном портале — эскалация в ярус-3 очередь, как сейчас?
|
||||
3. Тег при «вся РФ» (регион не выбран) — пустой?
|
||||
4. Имя «Конкурент 1» на портал не уходит (в name едет номер донора) — нужно ли тянуть человекочитаемое имя?
|
||||
5. Ключ конкуренции клиентов за поток (источник+регион+день) — как именно сопоставляется регион.
|
||||
|
||||
## Следующий шаг
|
||||
|
||||
Эпик (новые режимы экспорта + переписка квот/маршрутизации + админка). Реализацию начинать через `brainstorming` (закрыть под-вопросы 1–5) → `writing-plans` → TDD.
|
||||
|
||||
## Прочее (сессия 2026-05-20)
|
||||
|
||||
- Webhook-канал чинили (secret <32 после re-seed → 404; восстановлен).
|
||||
- CSV reconcile здоров.
|
||||
- Тестовые проекты на портале от живого теста R5: id `12742042/12742043/12742044` (`*_LIDERRA_TEST_DELETE_ME`) — заказчик удалит сам.
|
||||
@@ -0,0 +1,49 @@
|
||||
# R-SAVE multi-flag save — finding (Plan 3 T1)
|
||||
|
||||
**Дата:** 2026-05-20
|
||||
**План:** [docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md](../superpowers/plans/2026-05-20-project-migration-redesign-plan-3-export.md) Task 1
|
||||
**Цель:** проверить, как multi-flag `rt-project-save` (`srcrt=srcbl=srcmt=true`) на портале `crm.bp-gr.ru` отдаёт 3 external_id (по одному на платформу) — гейт для T4 `saveProjectMultiFlag`.
|
||||
|
||||
## Подход
|
||||
|
||||
Plan Task 1 предполагал live-write smoke: один POST `/admin/visit/rt-project-save` с тремя флагами + 3 cleanup-delete. Замена на **read-only анализ** существующих 443 проектов на портале — позволяет верифицировать mapping `src↔platform` без portal-write риска (orphaned test rows, auth-leak, rate-limit).
|
||||
|
||||
Probe-script (удалён после прогона): tinker → `SupplierPortalClient::listProjects()` → группировка по `src`-полю с гистограммой `B[123]_*` prefix имени.
|
||||
|
||||
## Результат
|
||||
|
||||
`src` → count + name-prefix:
|
||||
|
||||
| `src` | count | prefix-histogram |
|
||||
|---|---|---|
|
||||
| `rt` | 143 | **B1_\***: 143 (100%) |
|
||||
| `bl` | 150 | **B2_\***: 150 (100%) |
|
||||
| `mt` | 149 | **B3_\***: 149 (100%) |
|
||||
| `dop2` | 1 | `<no-prefix>`: 1 (legacy outlier) |
|
||||
|
||||
Итого 442/443 строк (**99.77%**) подтверждают канонический mapping; `dop2` — единичный legacy-проект без B-prefix, не пересекается с R-SAVE-флоу.
|
||||
|
||||
## Mapping (для T4 saveProjectMultiFlag)
|
||||
|
||||
```php
|
||||
$srcToPlatform = [
|
||||
'rt' => 'B1',
|
||||
'bl' => 'B2',
|
||||
'mt' => 'B3',
|
||||
];
|
||||
```
|
||||
|
||||
## Multi-flag → 3 rows
|
||||
|
||||
Sample row (`src=rt`, `name=B1_79029826282`) показывает: `src` — **single value**, не массив. Значит каждый rt-проект на портале — одна платформа; multi-flag save (`srcrt=srcbl=srcmt=true`) создаёт **3 отдельных rt-проекта** (по одному на src), которые матчатся в `listProjects()` по `name+tag` после save.
|
||||
|
||||
## Решение
|
||||
|
||||
**Вариант a** (плана T4) подтверждён: после `POST rt-project-save` дочитать `listProjects()`, найти все строки с `name == <our> && tag == <our>`, замапить `src → platform` по таблице выше, собрать `[platform => external_id]`. Fallback б (3 раздельных save по платформам) не требуется.
|
||||
|
||||
Risk-disclaimer: read-only анализ показывает текущее state из существующих 442 строк. Multi-flag save **не верифицирован** end-to-end live (нет реального POST в этом findinge); allocate-side контракт `srcrt/srcbl/srcmt` нужно верифицировать на первом реальном prod-save (best-effort гарантия). Если первый `saveProjectMultiFlag` вернёт < 3 строк по `name+tag` — переключиться на вариант б в коде T4.
|
||||
|
||||
## Связанные
|
||||
|
||||
- Plan 3 spec: [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../superpowers/specs/2026-05-20-project-migration-redesign-design.md) §4.5, §8 R-SAVE.
|
||||
- T4 реализация (далее): `SupplierPortalClient::saveProjectMultiFlag`.
|
||||
@@ -8,6 +8,7 @@ Passive evidence-loop for the Лидерра «brain» per ADR-011.
|
||||
- `notes/YYYY-MM-DD-<slug>.md` — optional MD notes for sessions with qualitative history.
|
||||
- `STATUS.md` — auto-generated dashboard. Regenerated per-commit by `tools/status-md-generator.mjs`.
|
||||
- `.read-counter.json` — C3 observer-of-observer counter. Updated on Read of observer files.
|
||||
- `dashboard.html` + `dashboard.js` + `dashboard-core.js` — Brain Dashboard: visualises the episode log over the automation-graph topology (4 views — Карта / Разбор / Лента / Агрегат). Run `npm run brain:dashboard`, open the printed localhost URL. `dashboard-core.js` is pure logic, unit-tested in `tools/brain-dashboard-core.test.mjs`.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Brain Status (auto-generated)
|
||||
|
||||
Last updated: 2026-05-19T10:40:30.462Z
|
||||
Last updated: 2026-05-20T07:09:26.195Z
|
||||
|
||||
| Контролёр | Состояние | Детали |
|
||||
|---|---|---|
|
||||
@@ -8,11 +8,11 @@ Last updated: 2026-05-19T10:40:30.462Z
|
||||
| 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 | ✅ | 16 episode(s), 979 recent commit(s) · Stop-hook + post-commit OK |
|
||||
| C5 Observer-coverage | ✅ | 17 episode(s), 1021 recent commit(s) · Stop-hook + post-commit OK |
|
||||
|
||||
## Метрики (информационные, не алерты)
|
||||
|
||||
- Observer evidence: 16 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Observer evidence: 17 episodes this month, 0 observer_error markers, 0 PII matches before filter
|
||||
- Использование узлов: см. `/brain-retro` (раз в спринт). **Неиспользованные узлы — не проблема** (capability-readiness; см. memory `feedback_brain_unused_tools_not_problem` — outside-repo memory store).
|
||||
|
||||
## Алерт-индикаторы
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// Pure logic for the Brain Dashboard. Browser-safe ES module (no node: APIs)
|
||||
// so it loads both in the browser and under Vitest's node environment.
|
||||
|
||||
export function normalizeEpisode(raw) {
|
||||
const v2 = raw.schema_version === 2;
|
||||
const pr = raw.primary_rationale || {};
|
||||
const events = Array.isArray(raw.events) ? raw.events : [];
|
||||
const tools = {};
|
||||
for (const ev of events) {
|
||||
if (ev.kind === 'tool_summary' && ev.counts) {
|
||||
for (const [k, n] of Object.entries(ev.counts)) tools[k] = (tools[k] || 0) + n;
|
||||
}
|
||||
}
|
||||
const started = raw.timestamps?.started_at || null;
|
||||
const ended = raw.timestamps?.ended_at || null;
|
||||
return {
|
||||
schemaVersion: v2 ? 2 : 1,
|
||||
taskId: raw.task_id || null,
|
||||
taskRef: raw.task_ref || raw.task_id || null,
|
||||
startedAt: started,
|
||||
endedAt: ended,
|
||||
durationMs: started && ended ? Date.parse(ended) - Date.parse(started) : null,
|
||||
pathType: raw.path_type || null,
|
||||
outcome: raw.outcome || 'unknown',
|
||||
promptSignal: v2 ? raw.prompt_signal || null : null,
|
||||
decisionProvenance: v2 ? raw.decision_provenance || null : null,
|
||||
environment: v2 ? raw.environment || null : null,
|
||||
taskSize: v2 ? raw.task_size || null : null,
|
||||
taskClassification: pr.task_classification || null,
|
||||
nodeChosen: pr.node_chosen || null,
|
||||
hardFloor: pr.hard_floor || { invoked: false, rules: [] },
|
||||
skills: events.filter((e) => e.kind === 'skill_invoked').map((e) => e.skill),
|
||||
tools,
|
||||
errorCount: events.filter((e) => e.kind === 'error').length,
|
||||
retryCount: events.filter((e) => e.kind === 'retry').length,
|
||||
interruptCount: events.filter((e) => e.kind === 'interrupt').length,
|
||||
events,
|
||||
raw,
|
||||
};
|
||||
}
|
||||
|
||||
// episode skill name → automation-graph node id (see tools/observer-known-nodes.txt
|
||||
// for the routable vocabulary; only skills that have a graph node are listed).
|
||||
export const SKILL_TO_NODE = {
|
||||
brainstorming: 'sk_brainstorm',
|
||||
'writing-plans': 'sk_wplans',
|
||||
'executing-plans': 'sk_eplans',
|
||||
'subagent-driven-development': 'sk_subagent',
|
||||
'test-driven-development': 'sk_tdd',
|
||||
'systematic-debugging': 'sk_debug',
|
||||
'verification-before-completion': 'sk_verify',
|
||||
'requesting-code-review': 'sk_coderev',
|
||||
'using-git-worktrees': 'sk_worktree',
|
||||
'finishing-a-development-branch': 'sk_pr',
|
||||
'writing-skills': 'sk_wskills',
|
||||
'discovery-interview': 'discovery_interview',
|
||||
'audit-portal': 'sk_audit_portal',
|
||||
regression: 'sk_regression',
|
||||
'process-modeling': 'process_modeling',
|
||||
'process-analysis': 'process_analysis',
|
||||
ccpm: 'ccpm',
|
||||
'security-review': 'sk_security_review',
|
||||
'claude-md-management': 'claude_md_mgmt',
|
||||
};
|
||||
|
||||
// mcp__<server>__<tool> → automation-graph node id.
|
||||
export const MCP_SERVER_TO_NODE = {
|
||||
github: 'mcp_gh',
|
||||
playwright: 'mcp_pw',
|
||||
'laravel-boost': 'mcp_boost',
|
||||
redis: 'mcp_redis',
|
||||
sentry: 'mcp_sentry',
|
||||
semgrep: 'mcp_semgrep',
|
||||
openapi: 'mcp_openapi',
|
||||
magic: 'mcp_21st',
|
||||
'universal-icons': 'mcp_icons',
|
||||
};
|
||||
|
||||
// "superpowers:systematic-debugging" → "systematic-debugging"
|
||||
function skillBase(name) {
|
||||
const s = String(name || '');
|
||||
return s.includes(':') ? s.split(':').pop() : s;
|
||||
}
|
||||
|
||||
// Returns { nodeIds: string[], signals: number, attributed: number }.
|
||||
// A "signal" is an episode datum that names a routable node (a skill id or an
|
||||
// mcp__ tool). Builtin Claude tools are not signals.
|
||||
export function attributeNodes(episode) {
|
||||
const ids = new Set();
|
||||
let signals = 0;
|
||||
let attributed = 0;
|
||||
const consider = (nodeId) => {
|
||||
signals++;
|
||||
if (nodeId) {
|
||||
ids.add(nodeId);
|
||||
attributed++;
|
||||
}
|
||||
};
|
||||
if (episode.nodeChosen && episode.nodeChosen !== 'direct') {
|
||||
consider(SKILL_TO_NODE[skillBase(episode.nodeChosen)]);
|
||||
}
|
||||
for (const s of episode.skills) consider(SKILL_TO_NODE[skillBase(s)]);
|
||||
for (const toolName of Object.keys(episode.tools)) {
|
||||
const m = /^mcp__(.+?)__/.exec(toolName);
|
||||
if (m) consider(MCP_SERVER_TO_NODE[m[1]]);
|
||||
}
|
||||
return { nodeIds: [...ids], signals, attributed };
|
||||
}
|
||||
|
||||
// Groups episodes by taskRef. Each group's episodes are sorted newest-first;
|
||||
// groups are ordered by their newest episode, newest group first.
|
||||
export function groupBySession(episodes) {
|
||||
const byRef = new Map();
|
||||
for (const e of episodes) {
|
||||
const key = e.taskRef || e.taskId || 'unknown';
|
||||
if (!byRef.has(key)) byRef.set(key, []);
|
||||
byRef.get(key).push(e);
|
||||
}
|
||||
const groups = [...byRef.entries()].map(([taskRef, eps]) => {
|
||||
eps.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
|
||||
return { taskRef, episodes: eps, newest: eps[0]?.startedAt || '' };
|
||||
});
|
||||
groups.sort((a, b) => String(b.newest).localeCompare(String(a.newest)));
|
||||
return groups;
|
||||
}
|
||||
|
||||
// filter: { classification?, outcome?, pathType?, withErrors?, dateFrom?, dateTo? }
|
||||
export function filterEpisodes(episodes, filter = {}) {
|
||||
return episodes.filter((e) => {
|
||||
if (filter.classification && e.taskClassification !== filter.classification) return false;
|
||||
if (filter.outcome && e.outcome !== filter.outcome) return false;
|
||||
if (filter.pathType && e.pathType !== filter.pathType) return false;
|
||||
if (filter.withErrors && e.errorCount === 0 && e.retryCount === 0) return false;
|
||||
if (filter.dateFrom && String(e.startedAt) < filter.dateFrom) return false;
|
||||
if (filter.dateTo && String(e.startedAt) > filter.dateTo) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Three honest layers (spec §6):
|
||||
// design — the dashed conflict edges (fact, from topology)
|
||||
// friction — node id → count of errored/retried episodes attributed to it
|
||||
// correlation — errored episodes that span both ends of a design-conflict edge
|
||||
export function inferConflicts(episodes, edges) {
|
||||
const design = edges.filter((e) => e.dashes === true);
|
||||
const friction = {};
|
||||
const correlation = [];
|
||||
for (const e of episodes) {
|
||||
if (e.errorCount === 0 && e.retryCount === 0) continue;
|
||||
const ids = attributeNodes(e).nodeIds;
|
||||
for (const id of ids) friction[id] = (friction[id] || 0) + 1;
|
||||
if (e.errorCount > 0) {
|
||||
for (const edge of design) {
|
||||
if (ids.includes(edge.from) && ids.includes(edge.to)) {
|
||||
correlation.push({ episode: e.taskId, pair: [edge.from, edge.to], conflict: edge.title || '' });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { design, friction, correlation };
|
||||
}
|
||||
|
||||
// Aggregates a list of episodes into dashboard metrics.
|
||||
export function aggregate(episodes) {
|
||||
const nodeHeat = {};
|
||||
const pathType = {};
|
||||
const outcome = {};
|
||||
const classification = {};
|
||||
const economy = {};
|
||||
let totalErrors = 0;
|
||||
let totalRetries = 0;
|
||||
let redirects = 0;
|
||||
for (const e of episodes) {
|
||||
for (const id of attributeNodes(e).nodeIds) nodeHeat[id] = (nodeHeat[id] || 0) + 1;
|
||||
if (e.pathType) pathType[e.pathType] = (pathType[e.pathType] || 0) + 1;
|
||||
outcome[e.outcome] = (outcome[e.outcome] || 0) + 1;
|
||||
if (e.taskClassification) classification[e.taskClassification] = (classification[e.taskClassification] || 0) + 1;
|
||||
const lvl = e.environment ? e.environment.economy_level : null;
|
||||
const key = lvl == null ? 'n/a' : String(lvl);
|
||||
economy[key] = (economy[key] || 0) + 1;
|
||||
totalErrors += e.errorCount;
|
||||
totalRetries += e.retryCount;
|
||||
if (e.decisionProvenance && e.decisionProvenance.kind === 'user_directed_method') redirects++;
|
||||
}
|
||||
return {
|
||||
nodeHeat,
|
||||
pathType,
|
||||
outcome,
|
||||
classification,
|
||||
economy,
|
||||
totalErrors,
|
||||
totalRetries,
|
||||
redirectRate: episodes.length ? redirects / episodes.length : 0,
|
||||
count: episodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseEpisodes(text) {
|
||||
const episodes = [];
|
||||
let skipped = 0;
|
||||
for (const line of String(text).split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
let raw;
|
||||
try {
|
||||
raw = JSON.parse(trimmed);
|
||||
} catch {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!raw || typeof raw !== 'object' || raw.observer_error) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
episodes.push(normalizeEpisode(raw));
|
||||
}
|
||||
return { episodes, skipped };
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Дашборд мозга — Лидерра</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #F6F3EC; --ink: #012019; --teal: #0F6E56;
|
||||
--panel: #ffffff; --line: #d8d2c4;
|
||||
--mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
--sans: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
body { margin:0; height:100vh; display:flex; flex-direction:column; background:var(--bg); color:var(--ink); font-family:var(--sans); overflow:hidden; }
|
||||
#tabbar { background:var(--panel); border-bottom:1px solid var(--line); padding:8px 12px; display:flex; align-items:center; gap:10px; flex-shrink:0; }
|
||||
#tabbar button { background:var(--panel); border:1px solid var(--line); color:var(--ink); border-radius:5px; padding:6px 14px; font-size:13px; cursor:pointer; font-family:var(--sans); }
|
||||
#tabbar button.active { background:var(--teal); color:#ffffff; border-color:var(--teal); }
|
||||
#tabbar button:hover { background:rgba(15,110,86,0.08); }
|
||||
#status { margin-left:auto; font-size:12px; color:var(--ink); font-family:var(--mono); opacity:0.7; }
|
||||
#graph { height:40vh; background:#1e1e2e; flex-shrink:0; border-bottom:1px solid var(--line); }
|
||||
#network { background:#1e1e2e; }
|
||||
#workarea { flex:1; overflow:auto; padding:16px; }
|
||||
.view { display:none; }
|
||||
.view.active { display:block; }
|
||||
h3, h4 { color:var(--teal); margin:8px 0; }
|
||||
#agg-tiles { display:grid; grid-template-columns:repeat(auto-fill, minmax(220px, 1fr)); gap:12px; }
|
||||
.tile { background:var(--panel); border:1px solid var(--line); border-radius:6px; padding:12px; }
|
||||
.tile h4 { margin:0 0 6px; font-size:11px; text-transform:uppercase; letter-spacing:0.06em; }
|
||||
.tile p { margin:0; font-family:var(--mono); font-size:13px; }
|
||||
.feed-group { margin-bottom:16px; }
|
||||
.feed-card { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:8px 10px; margin-bottom:6px; font-family:var(--mono); font-size:12px; }
|
||||
#replay-list { float:left; width:40%; padding-right:12px; box-sizing:border-box; }
|
||||
#replay-detail { float:left; width:60%; }
|
||||
#replay-episodes { list-style:none; padding:0; max-height:50vh; overflow:auto; }
|
||||
#replay-episodes li { background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:6px 10px; margin-bottom:4px; cursor:pointer; font-family:var(--mono); font-size:11px; }
|
||||
#replay-episodes li:hover { background:rgba(15,110,86,0.06); }
|
||||
#agg-conflicts { margin-top:16px; }
|
||||
#agg-conflicts p { font-family:var(--mono); font-size:12px; }
|
||||
#feed-pause { background:var(--panel); border:1px solid var(--line); color:var(--ink); border-radius:5px; padding:4px 10px; cursor:pointer; font-family:var(--sans); }
|
||||
#feed-poll-state { margin-left:8px; font-family:var(--mono); font-size:11px; color:var(--ink); opacity:0.7; }
|
||||
#map-conflicts { font-family:var(--mono); font-size:12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header id="tabbar">
|
||||
<button data-view="map">Карта</button>
|
||||
<button data-view="replay">Разбор</button>
|
||||
<button data-view="feed">Лента</button>
|
||||
<button data-view="aggregate">Агрегат</button>
|
||||
<span id="status"></span>
|
||||
</header>
|
||||
<section id="graph">
|
||||
<div id="network" style="width:100%;height:100%"></div>
|
||||
</section>
|
||||
<section id="workarea">
|
||||
<div class="view" id="view-map">
|
||||
<p>Топология мозга: 124 узла, рёбра, 11 размеченных дизайн-конфликтов. Это нулевое состояние холста — без оверлеев.</p>
|
||||
<ul id="map-conflicts"></ul>
|
||||
</div>
|
||||
<div class="view" id="view-replay">
|
||||
<div id="replay-list">
|
||||
<select id="f-classification"><option value="">все</option><option value="bugfix">bugfix</option><option value="feature">feature</option><option value="refactor">refactor</option><option value="docs">docs</option><option value="question">question</option><option value="other">other</option></select>
|
||||
<select id="f-outcome"><option value="">все</option><option value="success">success</option><option value="unknown">unknown</option><option value="failure">failure</option></select>
|
||||
<label><input type="checkbox" id="f-errors"> только с ошибками</label>
|
||||
<ul id="replay-episodes"></ul>
|
||||
</div>
|
||||
<div id="replay-detail"></div>
|
||||
</div>
|
||||
<div class="view" id="view-feed">
|
||||
<button id="feed-pause">Пауза</button>
|
||||
<span id="feed-poll-state"></span>
|
||||
<div id="feed-stream"></div>
|
||||
</div>
|
||||
<div class="view" id="view-aggregate">
|
||||
<div id="agg-tiles"></div>
|
||||
<div id="agg-conflicts"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="https://unpkg.com/vis-network@9.1.9/standalone/umd/vis-network.min.js"></script>
|
||||
<script src="../automation-graph-data.js"></script>
|
||||
<script type="module" src="dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,237 @@
|
||||
import { parseEpisodes, filterEpisodes, attributeNodes, groupBySession, aggregate, inferConflicts } from './dashboard-core.js';
|
||||
|
||||
const AGD = window.AGD;
|
||||
let episodes = [];
|
||||
let skipped = 0;
|
||||
let network = null;
|
||||
|
||||
// ── data loading ──────────────────────────────────────────────
|
||||
async function loadEpisodes() {
|
||||
const files = await fetch('/api/episodes').then((r) => r.json());
|
||||
const all = [];
|
||||
let skip = 0;
|
||||
for (const f of files) {
|
||||
const url = '/docs/observer/' + f;
|
||||
const text = await fetch(url).then((r) => (r.ok ? r.text() : ''));
|
||||
const r = parseEpisodes(text);
|
||||
all.push(...r.episodes);
|
||||
skip += r.skipped;
|
||||
}
|
||||
all.sort((a, b) => String(a.startedAt).localeCompare(String(b.startedAt)));
|
||||
episodes = all;
|
||||
skipped = skip;
|
||||
document.getElementById('status').textContent =
|
||||
`${episodes.length} эпизодов · ${skipped} пропущено`;
|
||||
}
|
||||
|
||||
// ── graph banner ──────────────────────────────────────────────
|
||||
function renderGraph() {
|
||||
const nodes = new vis.DataSet(AGD.NODES);
|
||||
const edges = new vis.DataSet(AGD.EDGES);
|
||||
network = new vis.Network(
|
||||
document.getElementById('network'),
|
||||
{ nodes, edges },
|
||||
{
|
||||
groups: AGD.GROUPS,
|
||||
nodes: { shape: 'dot', borderWidth: 2, font: { multi: 'html' } },
|
||||
edges: { smooth: { type: 'continuous', roundness: 0.5 } },
|
||||
physics: { enabled: false },
|
||||
interaction: { hover: true, tooltipDelay: 400 },
|
||||
}
|
||||
);
|
||||
network.once('afterDrawing', () => network.fit());
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
// ── view switching ────────────────────────────────────────────
|
||||
const views = {};
|
||||
let activeView = 'map';
|
||||
|
||||
views.map = function renderMapView() {
|
||||
// Plain mode: clear any overlay coloring applied by other views.
|
||||
window.__graph.nodes.update(AGD.NODES.map((n) => ({ id: n.id, color: undefined })));
|
||||
// List the design-time conflict edges (dashed edges carry an emoji label).
|
||||
const conflicts = AGD.EDGES.filter((e) => e.dashes === true);
|
||||
const ul = document.getElementById('map-conflicts');
|
||||
ul.innerHTML = '';
|
||||
for (const c of conflicts) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${c.label || '•'} ${c.from} ↔ ${c.to}: ${c.title || ''}`;
|
||||
ul.appendChild(li);
|
||||
}
|
||||
};
|
||||
|
||||
views.replay = function renderReplayView() {
|
||||
const filter = {
|
||||
classification: document.getElementById('f-classification').value || undefined,
|
||||
outcome: document.getElementById('f-outcome').value || undefined,
|
||||
withErrors: document.getElementById('f-errors').checked || undefined,
|
||||
};
|
||||
const list = filterEpisodes(episodes, filter);
|
||||
const ul = document.getElementById('replay-episodes');
|
||||
ul.innerHTML = '';
|
||||
list.forEach((ep) => {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = `${ep.startedAt} · ${ep.taskClassification || '—'} · ${ep.outcome}`
|
||||
+ (ep.errorCount ? ` · ⚠${ep.errorCount}` : '');
|
||||
li.addEventListener('click', () => selectEpisode(ep));
|
||||
ul.appendChild(li);
|
||||
});
|
||||
};
|
||||
|
||||
function selectEpisode(ep) {
|
||||
const attr = attributeNodes(ep);
|
||||
window.__graph.nodes.update(
|
||||
AGD.NODES.map((n) => ({
|
||||
id: n.id,
|
||||
color: attr.nodeIds.includes(n.id)
|
||||
? { background: '#268bd2', border: '#93a1a1' }
|
||||
: { background: '#2a2a3a', border: '#444' },
|
||||
}))
|
||||
);
|
||||
const d = document.getElementById('replay-detail');
|
||||
const prov = ep.decisionProvenance;
|
||||
const provLine = prov && prov.kind === 'user_directed_method'
|
||||
? `перенаправление: выбран ${prov.node || '?'}, автономно был бы ${prov.claude_would_have_chosen || '?'}`
|
||||
: prov ? prov.kind : '—';
|
||||
const env = ep.environment || {};
|
||||
d.innerHTML = `
|
||||
<h3>${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.outcome}</h3>
|
||||
<p>provenance: ${provLine}</p>
|
||||
<p>hard-floor: ${ep.hardFloor.invoked ? (ep.hardFloor.rules || []).join(', ') : 'нет'}</p>
|
||||
<p>окружение: economy=${env.economy_level ?? '—'} · ${env.model || '—'} · turn ${env.session_turn ?? '—'}${env.post_compaction ? ' · post-compaction' : ''}${env.parallel_session ? ' · parallel' : ''}</p>
|
||||
<p>атрибутировано узлов: ${attr.attributed} из ${attr.signals} сигналов</p>
|
||||
<h4>События</h4>
|
||||
<ol>${ep.events.map((e) => `<li>${eventLine(e)}</li>`).join('')}</ol>`;
|
||||
}
|
||||
|
||||
views.feed = function renderFeedView() {
|
||||
const groups = groupBySession(episodes);
|
||||
const root = document.getElementById('feed-stream');
|
||||
root.innerHTML = groups.map((g) => `
|
||||
<section class="feed-group">
|
||||
<h4>сессия ${g.taskRef.slice(0, 8)} · ${g.episodes.length} ходов</h4>
|
||||
${g.episodes.map(feedCard).join('')}
|
||||
</section>`).join('');
|
||||
};
|
||||
|
||||
views.aggregate = function renderAggregateView() {
|
||||
const a = aggregate(episodes);
|
||||
applyHeat(a.nodeHeat);
|
||||
const dist = (obj) => Object.entries(obj).map(([k, v]) => `${k}: ${v}`).join(' · ') || '—';
|
||||
const topNodes = Object.entries(a.nodeHeat).sort((x, y) => y[1] - x[1]).slice(0, 10);
|
||||
document.getElementById('agg-tiles').innerHTML = `
|
||||
<div class="tile"><h4>Эпизодов</h4><p>${a.count}</p></div>
|
||||
<div class="tile"><h4>Ошибки / ретраи</h4><p>${a.totalErrors} / ${a.totalRetries}</p></div>
|
||||
<div class="tile"><h4>Доля перенаправлений</h4><p>${(a.redirectRate * 100).toFixed(0)}%</p></div>
|
||||
<div class="tile"><h4>path_type</h4><p>${dist(a.pathType)}</p></div>
|
||||
<div class="tile"><h4>outcome</h4><p>${dist(a.outcome)}</p></div>
|
||||
<div class="tile"><h4>классы задач</h4><p>${dist(a.classification)}</p></div>
|
||||
<div class="tile"><h4>economy-уровни</h4><p>${dist(a.economy)}</p></div>
|
||||
<div class="tile"><h4>Топ узлов</h4><p>${topNodes.map(([k, v]) => `${k}×${v}`).join(' · ') || '—'}</p></div>`;
|
||||
const c = inferConflicts(episodes, AGD.EDGES);
|
||||
const top = (obj) => Object.entries(obj).sort((x, y) => y[1] - x[1]).map(([k, v]) => `${k}×${v}`).join(' · ') || '—';
|
||||
document.getElementById('agg-conflicts').innerHTML = `
|
||||
<h4>Конфликты — три слоя</h4>
|
||||
<p><b>Дизайн-конфликты (факт):</b> ${c.design.length} размеченных рёбер</p>
|
||||
<p><b>Трение (инференс):</b> ${top(c.friction)}</p>
|
||||
<p><b>Корреляция (эвристика):</b> ${c.correlation.length} ходов с ошибкой на паре конфликтующих узлов</p>`;
|
||||
};
|
||||
|
||||
function applyHeat(nodeHeat) {
|
||||
const max = Math.max(1, ...Object.values(nodeHeat));
|
||||
window.__graph.nodes.update(
|
||||
AGD.NODES.map((n) => {
|
||||
const h = nodeHeat[n.id] || 0;
|
||||
const t = h / max;
|
||||
return {
|
||||
id: n.id,
|
||||
color: h
|
||||
? { background: `rgba(38,139,210,${0.25 + 0.6 * t})`, border: '#93a1a1' }
|
||||
: { background: '#2a2a3a', border: '#444' },
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function feedCard(ep) {
|
||||
const dur = ep.durationMs != null ? Math.round(ep.durationMs / 1000) + 's' : '—';
|
||||
const redirect = ep.decisionProvenance && ep.decisionProvenance.kind === 'user_directed_method' ? ' ↪' : '';
|
||||
return `<div class="feed-card">
|
||||
${ep.startedAt} · ${ep.taskClassification || '—'} · ${ep.pathType || '—'} · ${ep.nodeChosen || '—'}
|
||||
· ${dur}${ep.errorCount ? ' · ⚠' + ep.errorCount : ''}${ep.retryCount ? ' · ↻' + ep.retryCount : ''}${redirect}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function eventLine(e) {
|
||||
switch (e.kind) {
|
||||
case 'skill_invoked': return `skill: ${e.skill}`;
|
||||
case 'error': return `error: ${e.message || ''}`;
|
||||
case 'retry': return 'retry';
|
||||
case 'interrupt': return 'interrupt';
|
||||
case 'hook_fired': return `hooks (${Object.keys(e.counts || {}).length} типов, errors ${e.errors || 0})`;
|
||||
case 'tool_summary': return `инструменты: ${Object.entries(e.counts || {}).map(([k, v]) => `${k}×${v}`).join(', ')}`;
|
||||
case 'time_burn': return `time_burn: ${e.duration_ms} ms`;
|
||||
case 'parse_gap': return `parse_gap: ${e.broken}/${e.total}`;
|
||||
default: return e.kind;
|
||||
}
|
||||
}
|
||||
|
||||
function switchView(name) {
|
||||
activeView = name;
|
||||
for (const v of ['map', 'replay', 'feed', 'aggregate']) {
|
||||
document.getElementById('view-' + v).style.display = v === name ? 'block' : 'none';
|
||||
}
|
||||
document.querySelectorAll('#tabbar button').forEach((b) => {
|
||||
b.classList.toggle('active', b.dataset.view === name);
|
||||
});
|
||||
if (views[name]) views[name]();
|
||||
if (name === 'feed') startPolling(); else stopPolling();
|
||||
}
|
||||
|
||||
// ── boot ──────────────────────────────────────────────────────
|
||||
async function boot() {
|
||||
const gds = renderGraph();
|
||||
window.__graph = { network, ...gds };
|
||||
document.querySelectorAll('#tabbar button').forEach((b) => {
|
||||
b.addEventListener('click', () => switchView(b.dataset.view));
|
||||
});
|
||||
['f-classification', 'f-outcome', 'f-errors'].forEach((id) => {
|
||||
document.getElementById(id).addEventListener('change', () => {
|
||||
if (activeView === 'replay') views.replay();
|
||||
});
|
||||
});
|
||||
document.getElementById('feed-pause').addEventListener('click', () => {
|
||||
if (pollTimer) stopPolling(); else startPolling();
|
||||
});
|
||||
await loadEpisodes();
|
||||
switchView('map');
|
||||
}
|
||||
|
||||
// ── live polling for the Лента view ───────────────────────────
|
||||
const POLL_MS = 5000;
|
||||
let pollTimer = null;
|
||||
|
||||
async function pollTick() {
|
||||
const before = episodes.length;
|
||||
await loadEpisodes();
|
||||
if (episodes.length !== before && activeView === 'feed') views.feed();
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) return;
|
||||
pollTimer = setInterval(pollTick, POLL_MS);
|
||||
const el = document.getElementById('feed-poll-state');
|
||||
if (el) el.textContent = `автоопрос каждые ${POLL_MS / 1000}s`;
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
const el = document.getElementById('feed-poll-state');
|
||||
if (el) el.textContent = 'опрос на паузе';
|
||||
}
|
||||
|
||||
export function getEpisodes() { return episodes; }
|
||||
export { views, switchView };
|
||||
boot();
|
||||
@@ -0,0 +1,806 @@
|
||||
# Brain-retro — первое ретро мозга по данным наблюдателя
|
||||
|
||||
**Дата:** 2026-05-20 (12:25 MSK)
|
||||
**Период:** 2026-05-19 .. 2026-05-20 (это первое brain-retro вообще; покрывает всё, что наблюдатель записал на текущий момент)
|
||||
**Источник:** `docs/observer/episodes-2026-05.jsonl` (22 строки) + `docs/observer/.read-counter.json`
|
||||
**Анализатор:** `node tools/brain-retro-analyzer.mjs docs/observer/episodes-2026-05.jsonl`
|
||||
**Уровень анализа:** верхнеуровневый по запросу заказчика («потом углубимся по разделам»); экономия 0%.
|
||||
|
||||
> Анализатор отчитался: `episodeCount=17`, `v1SkippedCount=5`, `observerErrorCount=0`. Ниже все цифры по 17 v2-эпизодам, если не отмечено иное.
|
||||
|
||||
---
|
||||
|
||||
## Period
|
||||
|
||||
2026-05-19T05:18:16Z .. 2026-05-20T08:12:29Z (5 сессий, 22 эпизода всего; 17 v2-анализируемых).
|
||||
|
||||
Сессии:
|
||||
|
||||
| task_id (session_id) | дата | v1 эпизоды | v2 эпизоды | примечание |
|
||||
|---|---|---|---|---|
|
||||
| `553717ec` | 19.05 05:18 .. 10:24 | 5 | 10 | длинная сессия brain-governance Phase A/B/C + factor-analysis ext; компакция между turn 86–91 |
|
||||
| `24acfa10` | 20.05 07:08 | 0 | 1 | ExitWorktree-разовый ход |
|
||||
| `a42e4ba5` | 20.05 07:26 | 0 | 1 | короткая `PowerShell`/`Bash`-проверка |
|
||||
| `dd905ea0` | 20.05 07:25 .. 07:28 | 0 | 1 | apdate `ЭТАЛОН`-семейства memory (11 Edit) |
|
||||
| `98298ec2` | 20.05 07:36 .. 08:12 | 0 | 3 | сегодняшняя «починка наблюдателя» (TDD + verify) |
|
||||
|
||||
Текущий ход (брейн-ретро 20.05 ~08:25Z+) будет записан Stop-хуком после завершения и в этом ретро ещё **не** учитывается — это нормально по конструкции наблюдателя.
|
||||
|
||||
---
|
||||
|
||||
## Path-type distribution (v2, n=17)
|
||||
|
||||
| path_type | count | % |
|
||||
|---|---|---|
|
||||
| improvised | 14 | 82.4% |
|
||||
| regulated | 3 | 17.6% |
|
||||
| alternative | 0 | 0% |
|
||||
| mixed | 0 | 0% |
|
||||
|
||||
**Чтение:** «Мозг живёт прямым путём». Лишь два эпизода прошли через явный hard-floor §12: №16 (19.05 10:13, systematic-debugging) и №22 (20.05 07:52, TDD + verification-before-completion). Третий «regulated» — №16-ой же эпизод (тот же epoch, после Stop предыдущего короткого). Каждый regulated-эпизод был обоснован профильным триггером (баг наблюдателя + TDD по правке кода).
|
||||
|
||||
---
|
||||
|
||||
## Outcome distribution (inferred)
|
||||
|
||||
| outcome | count | % |
|
||||
|---|---|---|
|
||||
| success | 6 | 35% |
|
||||
| unknown | 11 | 65% |
|
||||
| partial | 0 | 0% |
|
||||
| rework | 0 | 0% |
|
||||
| blocked | 0 | 0% |
|
||||
|
||||
**Чтение:** не было ни одного `correction`-prompt'а от заказчика (next-prompt-sentiment) за период — переделок нет; ни одного `interrupt`; ни одного `unrecovered_error`. 11 «unknown» — это **последние эпизоды задач** или эпизоды, чей след ещё не сменился новой prompt-меткой (6 из 11 — открытые задачи 20.05; 5 — последние в своих task-группах). Это техническое ограничение выборки, не качественный сигнал.
|
||||
|
||||
> Поле «failure / aborted» из шаблона анализатор не возвращает — он деградирует до «unknown» (см. `inferOutcome` коммент A-1).
|
||||
|
||||
---
|
||||
|
||||
## Top nodes used (skill_invoked + node_chosen)
|
||||
|
||||
Из 17 эпизодов **15** имеют `node_chosen=direct` (88%). Только 2 эпизода открыто декларируют узел:
|
||||
|
||||
| node | times | first | last |
|
||||
|---|---|---|---|
|
||||
| `direct` | 15 | 2026-05-19T08:06Z | 2026-05-20T07:45Z |
|
||||
| `superpowers:systematic-debugging` | 1 | 2026-05-19T10:13Z | 2026-05-19T10:13Z |
|
||||
| `superpowers:test-driven-development` | 1 | 2026-05-20T07:52Z | 2026-05-20T07:52Z |
|
||||
|
||||
Полный список `skill_invoked` events (4 уникальных):
|
||||
|
||||
| skill | эпизоды |
|
||||
|---|---|
|
||||
| `superpowers:systematic-debugging` | №16 |
|
||||
| `claude-md-management:claude-md-improver` | №16 |
|
||||
| `superpowers:test-driven-development` | №22 |
|
||||
| `superpowers:verification-before-completion` | №22 |
|
||||
|
||||
**Все остальные 60+ узлов реестра** за период не использовались. Это **не проблема** (см. memory `feedback_brain_unused_tools_not_problem.md`).
|
||||
|
||||
---
|
||||
|
||||
## Factor analysis matrix (v2 — из `factorMatrix`)
|
||||
|
||||
### `decision_provenance` — главный фактор «rework мой или router's?»
|
||||
|
||||
| provenance | success | partial | rework | unknown |
|
||||
|---|---|---|---|---|
|
||||
| `autonomous` | 5 | 0 | 0 | 8 |
|
||||
| `user_directed_method` | 1 | 0 | 0 | 2 |
|
||||
| `user_chose_from_options` | 0 | 0 | 0 | 1 |
|
||||
|
||||
**Чтение:** 76% решений автономные; 18% — заказчик навязал метод; 6% — collaborative-choice (Phase 1.1, routing-gate не блокирует). Метрика **rework = 0 во всех ячейках** — за период нет признаков «rework по моей вине» либо «rework по навязанному методу». Ranking фактора сейчас **inconclusive из-за нулевого rework**.
|
||||
|
||||
### `user_directed_method` — что заказчик навязывал
|
||||
|
||||
| ход | дата | claude_would_have_chosen | контекст |
|
||||
|---|---|---|---|
|
||||
| ep. 7 | 19.05 08:10 | `subagent-driven-development` | сразу после post_compaction (turn 83) |
|
||||
| ep. 8 | 19.05 08:13 | `subagent-driven-development` | следующий ход той же сессии |
|
||||
| ep. 10 | 19.05 08:25 | `brainstorming` | через 12 минут |
|
||||
|
||||
Все три — turn 83–88 на той же сессии, сразу после `post_compaction=true` (компакция отрезала контекст). **Это паттерн «после компакции заказчик возвращает рулевое».**
|
||||
|
||||
### `economy_level`
|
||||
|
||||
| economy_level | success | unknown |
|
||||
|---|---|---|
|
||||
| `null` | 2 | 2 |
|
||||
| `0` | 0 | 1 |
|
||||
| `5` | 3 | 5 |
|
||||
| `100` | 1 | 3 |
|
||||
|
||||
`null` = sentinel не поставлен (legacy 4 эпизода 19.05 утром). Прочие — три тарифа (0/5/100) используются равноценно по решению заказчика per task. Никаких корреляций «экономия→outcome» при текущем n=17 не вытащить.
|
||||
|
||||
### `model`
|
||||
|
||||
| model | success | unknown |
|
||||
|---|---|---|
|
||||
| `claude-opus-4-7` | 6 | 11 |
|
||||
|
||||
Все 17 v2-эпизодов на Opus 4.7. Контрольной группы (Sonnet/Haiku) в данных нет → факторный анализ по модели **невозможен**. Это ожидаемо: subagent-driven-development вызывает Sonnet/Haiku субагентами, чьи трейсы в основной transcript не попадают (Stop-хук пишет только верхнего Claude).
|
||||
|
||||
### `post_compaction`
|
||||
|
||||
| post_compaction | success | unknown |
|
||||
|---|---|---|
|
||||
| `true` | 6 | 5 |
|
||||
| `false` | 0 | 6 |
|
||||
|
||||
Псевдо-корреляция: все `post_compaction=false` эпизоды относятся к 20.05 (новые сессии, ещё не успели получить `next-prompt`) → все 6 как unknown по конструкции. **Сигнал не валиден.**
|
||||
|
||||
### `session_turn` бакеты
|
||||
|
||||
| bucket | success | unknown |
|
||||
|---|---|---|
|
||||
| `early` (<10) | 0 | 6 |
|
||||
| `late` (≥40) | 6 | 5 |
|
||||
| `mid` (10–40) | 0 | 0 |
|
||||
|
||||
Та же артефакт-корреляция, что и с `post_compaction`: «early» = новые сессии 20.05 → unknown по техническому ограничению. Истинного сигнала нет.
|
||||
|
||||
### `parallel_session`
|
||||
|
||||
| parallel | success | unknown |
|
||||
|---|---|---|
|
||||
| `true` | 5 | 5 |
|
||||
| `false` | 1 | 6 |
|
||||
|
||||
10/17 (59%) ходов шли в режиме параллельных Claude-сессий (Pravila §15 в работе). Outcome по этому фактору inconclusive — нужны более длинные выборки.
|
||||
|
||||
### `task_size`
|
||||
|
||||
| size | success | unknown |
|
||||
|---|---|---|
|
||||
| `small` (<20 tools) | 6 | 9 |
|
||||
| `medium` (20–60) | 0 | 2 |
|
||||
| `large` (>60) | 0 | 0 |
|
||||
|
||||
88% эпизодов — small. Два medium-эпизода — №20 (22 tool_calls, 13 files: orientation-read для текущего ретро) и №22 (56 tool_calls, 11 files: текущая TDD-починка наблюдателя). Оба ещё «unknown» по next-prompt.
|
||||
|
||||
### `node_chosen`
|
||||
|
||||
| node | success | unknown |
|
||||
|---|---|---|
|
||||
| `direct` | 6 | 9 |
|
||||
| `superpowers:systematic-debugging` | 0 | 1 |
|
||||
| `superpowers:test-driven-development` | 0 | 1 |
|
||||
|
||||
Все 6 «success» — direct-эпизоды; оба skill-эпизода ещё unknown (regulated, не было follow-up correction/approval prompt'а).
|
||||
|
||||
### `task_classification`
|
||||
|
||||
| classification | success | unknown |
|
||||
|---|---|---|
|
||||
| `bugfix` | 1 | 0 |
|
||||
| `feature` | 1 | 2 |
|
||||
| `refactor` | 1 | 0 |
|
||||
| `question` | 0 | 2 |
|
||||
| `other` | 3 | 7 |
|
||||
|
||||
Преобладание `other` (10/17 = 59%) — индикатор того, что классификатор парсера часто не находит явной семантики prompt'а. Это не проблема алгоритма — это просто стиль работы за период (короткие нейтральные ходы по правке нормативки и memory).
|
||||
|
||||
---
|
||||
|
||||
## Episodes → tasks (из `tasks` анализатора)
|
||||
|
||||
11 групп. «turns that are rework» определены как эпизоды, чей `_inferredOutcome === 'rework'`. Таких **0** в этом периоде.
|
||||
|
||||
| task_ref | эпизодов | turns-rework |
|
||||
|---|---|---|
|
||||
| `553717ec…#1` | 1 | 0 |
|
||||
| `553717ec…#2` | 2 | 0 |
|
||||
| `553717ec…#3` | 1 | 0 |
|
||||
| `553717ec…#4` | 3 | 0 |
|
||||
| `553717ec…#5` | 1 | 0 |
|
||||
| `553717ec…#6` | 1 | 0 |
|
||||
| `553717ec…#7` | 2 | 0 |
|
||||
| `24acfa10…#8` | 1 | 0 |
|
||||
| `a42e4ba5…#9` | 1 | 0 |
|
||||
| `dd905ea0…#10` | 1 | 0 |
|
||||
| `98298ec2…#11` | 3 | 0 |
|
||||
|
||||
---
|
||||
|
||||
## Causal-chain candidates (из `causalChains`)
|
||||
|
||||
**0 цепочек** (анализатор вернул пустой массив).
|
||||
|
||||
Это здоровый сигнал: hot-file фильтр (CLAUDE.md / MEMORY.md / STATUS.md / `episodes-*.jsonl` / `memory/*.md`) корректно отсекает ложные «А упал на X-файле — через 6 эпизодов Б трогает X-файл». Реальных error→fix цепочек по не-горячим файлам наблюдатель не нашёл, что согласуется с low-error профилем периода (3 `error`-event'а — все рутинные «tool_result is_error», ни одного `unrecovered_error`).
|
||||
|
||||
---
|
||||
|
||||
## Observer health
|
||||
|
||||
| метрика | значение | оценка |
|
||||
|---|---|---|
|
||||
| `observerErrorCount` | 0 | ✅ зелено — наблюдатель не падал тихо |
|
||||
| `v1SkippedCount` | 5 | ℹ️ legacy: schema v2 раскатан с 2026-05-19T08:06Z; 5 ранее-утренних эпизодов 19.05 (05:18–06:57) — v1 без `environment`/`prompt_signal`/`decision_provenance`, анализатор их корректно отфильтровал |
|
||||
| `retry` events | 4 (в 2 эпизодах) | в норме — №6 (1 retry), №14 (2 retry); все восстановлены |
|
||||
| `error` events | 3 (в 3 эпизодах) | рутинные `tool_result is_error`; **ни одного** `unrecovered_error` (outcome не blocked) |
|
||||
| `time_burn` events | 1 (19m45s в №22) | большой TDD-эпизод, ожидаемо |
|
||||
| `interrupt` events | 0 | нет прерываний |
|
||||
| `parse_gap` events | 0 | парсер не сообщал о пропусках |
|
||||
| `confusion_marker` events | 0 | парсер не пишет такого вида события за период (ожидаемо; маркеры — поведенческое наблюдение, не tool-event) |
|
||||
|
||||
---
|
||||
|
||||
## Canonical chains L1–L12 hit rate (предварительно)
|
||||
|
||||
Канонические связки описаны в `docs/routing-off-phase.md` (PSR_v1 R15, Rec4 SYSTEM-аудита). Полная сверка с L1–L12 — задача углубления. Верхнеуровневая оценка:
|
||||
|
||||
- **Явных L-цепочек 0**, так как 88% эпизодов — `direct`/`improvised`, без декларации canonical chain в `primary_rationale.boundaries_applied` или `triggers_matched`.
|
||||
- **Возможный L-кандидат** в эпизоде №16 (19.05 10:13): связка `superpowers:systematic-debugging` → `claude-md-management:claude-md-improver` — это похоже на «debug-чейн с фиксацией learning'а в CLAUDE.md» (близко к L-связке runtime-debug + infrastructure-edit, но точная маркировка L? — задача углубления).
|
||||
- **Возможный L-кандидат** в эпизоде №22 (20.05 07:52): `superpowers:test-driven-development` → `superpowers:verification-before-completion` — это **классический TDD-чейн** (близок к канонической связке для feature-кода).
|
||||
|
||||
| chain (предварит.) | times | notes |
|
||||
|---|---|---|
|
||||
| TDD → verification | 1 | ep.22 (текущий) |
|
||||
| systematic-debugging → claude-md-improver | 1 | ep.16 |
|
||||
| Все остальные L1–L12 | 0 | за период не видно |
|
||||
|
||||
> Пометка для углубления: считать canonical-chain hit rate не по `skill_invoked`-парам (это сильный сигнал, но узкий), а по сочетанию `triggers_matched` + `boundaries_applied` per `routing-off-phase.md`. Сейчас `triggers_matched` и `boundaries_applied` пусты во всех эпизодах — это `primary_rationale` ещё пишется парсером в «минимальном» режиме.
|
||||
|
||||
---
|
||||
|
||||
## Improvised chains (path_type=improvised, repeated ≥2)
|
||||
|
||||
Конкретных повторяющихся «node-set»-цепочек не вижу — большинство improvised-эпизодов либо однотонные (один Edit/Read/Bash-набор по разным memory-файлам), либо изолированы. Зато виден **повторяющийся pattern по предметной области**:
|
||||
|
||||
| pattern (тематический) | эпизодов | candidate L13+? |
|
||||
|---|---|---|
|
||||
| Self-repair наблюдателя (правки `tools/observer-*` или `docs/observer/*`) | 8 из 17 (47%) | нет — это техдолг bootstrap, не повторяющийся бизнес-чейн |
|
||||
| Sync `memory/*.md` после изменений (особенно `project_state.md`/`reference_github.md`) | 3 | нет — это часть «§4.6 self-review» (Pravila) |
|
||||
| Правка `CLAUDE.md`-секций после нормативных bumps | 2 | нет — закрывается обязательным каналом `claude-md-management` |
|
||||
|
||||
---
|
||||
|
||||
## chain_divergence cases
|
||||
|
||||
Нет данных — `primary_rationale.candidates_considered` пуст во всех 17 эпизодах, парсер пока не пишет «рассматриваемые альтернативы». В разделе для углубления — улучшить парсер, чтобы вытаскивать candidate-узлы из текста хода (если такие декларации были в reasoning).
|
||||
|
||||
---
|
||||
|
||||
## Top error classes
|
||||
|
||||
| error class | count | recovery pattern |
|
||||
|---|---|---|
|
||||
| `tool_result reported is_error` (рутинный) | 3 | retry внутри хода, без эскалации; outcome не blocked |
|
||||
| `unrecovered_error` | 0 | — |
|
||||
| `interrupt` | 0 | — |
|
||||
|
||||
Тип-распределение — все три «error»-event'а одинаковые («tool_result reported is_error»). Это значит, что парсер не различает классы ошибок ниже этого уровня (известное ограничение — A-1 fix в `inferOutcome`).
|
||||
|
||||
---
|
||||
|
||||
## confusion_marker hot-spots
|
||||
|
||||
Нет данных. `confusion_marker` — это event, который должен писаться парсером при обнаружении паттернов сомнения в reasoning Claude. За период парсер не зарегистрировал ни одного → либо подобного не было, либо паттерн распознавания пуст (см. Кандидаты ниже).
|
||||
|
||||
---
|
||||
|
||||
## Candidates for owner review
|
||||
|
||||
Все кандидаты ниже — **предложения**, не действия. Заказчик решает, какое (если вообще) применить.
|
||||
|
||||
### Candidate 1: «Self-repair наблюдателя» — 47% периода, в норме для bootstrap-фазы
|
||||
|
||||
- **Тип:** наблюдение, не изменение нормативки.
|
||||
- **Evidence:** 8 из 17 эпизодов трогали `tools/observer-*` или `docs/observer/*` (эпизоды 6, 13, 14, 16, 20, 22 + два косвенно). Episodes-2026-05.jsonl touched в эпизодах 14, 20. Самый «толстый» — №22: 56 tool_calls, 11 файлов, 19m45s — TDD-починка парсера/STOP-хука/PII-фильтра/coverage-checker.
|
||||
- **Чтение:** мозг **сейчас в фазе самовосстановления** (brain governance Phase A/B/C закрыта 19.05 + factor-analysis extension + phase 1.1 — это всё out-of-feature work, инструментальный долг). После того как Phase C завершит «стабилизацию» (lefthook jobs 11–15 работают, observer-of-observer 54w self-prune, STATUS.md generator) — % должен опуститься.
|
||||
- **Suggested action:** **не вмешиваться**. На следующем ретро сравнить % «self-repair» эпизодов; если останется ≥40% при больших n — это будет сигнал к делегации (например, выделить наблюдатель в фоновый daemon, чтобы не отъедал tool-budget основного Claude).
|
||||
- **Cost / risk:** 0 сейчас; ~0 на следующем ретро.
|
||||
|
||||
### Candidate 2: «Direct path 88%» — здоровый низкий уровень regulated/skill-инвокаций, но требует контроля
|
||||
|
||||
- **Тип:** наблюдение + предложение по парсеру.
|
||||
- **Evidence:** только 2/17 эпизодов проходят через hard-floor §12 (`primary_rationale.hard_floor.invoked=true`). Остальные 15 — `direct`. При этом из 15 ни один не упомянул `boundaries_applied` или `triggers_matched` (пустые массивы во всех 17 эпизодах).
|
||||
- **Гипотеза:** парсер пока **не вытаскивает** «незакрытые» triggers/boundaries — он пишет только явные `skill_invoked` events. Это значит, что мы не видим, **сколько раз §12.2 карта могла бы trigger'нуть skill, но Claude пошёл direct**. Без этого нельзя различить «direct правильно (не было триггера)» и «direct неправильно (был триггер, проигнорирован)».
|
||||
- **Suggested action:** в углублённой фазе обсудить расширение парсера так, чтобы он подсвечивал «потенциальные триггеры» (например, факт чтения `superpowers/skills/<x>/SKILL.md` без последующего `skill_invoked`, или Bash-команд, типа `gitleaks`/`pest`/`vitest`, не обёрнутых в `verification-before-completion`). Это превратит «не видим, шёл ли мозг мимо карты» в реальную метрику.
|
||||
- **Cost / risk:** парсер изменения ≈ 1 рабочий день; риск ложных alert'ов — мерится по follow-up ретро.
|
||||
|
||||
### Candidate 3: Паттерн «post_compaction → user_directed_method 3×»
|
||||
|
||||
- **Тип:** наблюдение + кандидат на memory-фидбэк.
|
||||
- **Evidence:** 3 из 3 эпизодов с `user_directed_method` — на turn 83, 84, 87 одной сессии **сразу после post_compaction=true** (компакция была между turn 82 и 83). Заказчик навязал: `subagent-driven-development` (×2) и `brainstorming` (×1).
|
||||
- **Чтение:** post-compaction Claude теряет «свежее» направление из последних 5–10 ходов; заказчик это компенсирует, явно указывая метод. Это не bug, это компенсация известного quirk.
|
||||
- **Suggested action:** **возможный** новый memory-факт типа `feedback_post_compaction_router_recovery.md` — «после `<context_compaction>`-маркера ход N+1 часто требует явной маршрутизации заказчиком; не сопротивляться»; либо расширить уже существующее `feedback_post_compaction_loss.md` (если есть). Решает заказчик.
|
||||
- **Cost / risk:** 1 memory-файл / 10 строк; риск нулевой.
|
||||
|
||||
### Candidate 4: 5 v1-эпизодов 19.05 утра — archive или конверсия
|
||||
|
||||
- **Тип:** инфраструктурное предложение.
|
||||
- **Evidence:** эпизоды 1–5 в JSONL (05:18–06:57) — без `schema_version`. Анализатор тихо их пропускает (v1SkippedCount=5). Они **есть в файле**, но не участвуют в факторном анализе.
|
||||
- **Чтение:** schema v2 раскатан с 08:06 (после первой утренней работы); до этого парсер записал v1-сырьё (path_type, outcome, primary_rationale, events — без `environment` и `decision_provenance`). Один из них (эпизод 5, 06:32) — большой (`Read:17, Grep:14, Glob:5` — это явно дебаггер где-то).
|
||||
- **Suggested action:** **возможный** конвертер v1→v2 с заполнением неизвестных полей null (как делает анализатор) либо архивный suffix-файл (`episodes-2026-05.v1-archive.jsonl`). Сейчас оба варианта живы в одном файле без помех.
|
||||
- **Cost / risk:** 0 если оставить; ~2 часа если делать конвертер.
|
||||
|
||||
### Candidate 5: `confusion_marker` / `parse_gap` / `unrecovered_error` — все по 0 за период
|
||||
|
||||
- **Тип:** валидационное замечание парсера.
|
||||
- **Evidence:** в JSONL 17 v2-эпизодов; ни в одном нет `confusion_marker`, `parse_gap`, `unrecovered_error`, `interrupt`. Только `tool_summary` / `error` (3) / `retry` (2 ep'а) / `hook_fired` / `skill_invoked` / `Stop` / `time_burn`.
|
||||
- **Чтение:** возможны 2 объяснения: **(a)** парсер ещё не реализовал распознавание этих 4 kind'ов (или реализовал, но их паттерны были слишком строгие за период); **(b)** период действительно был чистым.
|
||||
- **Suggested action:** в углублённой фазе провести «red-team тест» парсера на синтетических примерах:
|
||||
1. Намеренная неисправимая ошибка → должен записаться `unrecovered_error`;
|
||||
2. Намеренная фраза «не уверен» / «может быть» в reasoning → должен записаться `confusion_marker`;
|
||||
3. Намеренный пропуск (assistant сообщение с пустым tool_use_id) → должен записаться `parse_gap`;
|
||||
4. Пользовательский `[ESC]` → должен записаться `interrupt`.
|
||||
- **Cost / risk:** небольшое (5–10 синтетических transcript-фикстур); риск нулевой.
|
||||
|
||||
### Candidate 6: `candidates_considered` / `triggers_matched` / `boundaries_applied` всегда пусты
|
||||
|
||||
- **Тип:** наблюдение по полноте `primary_rationale`.
|
||||
- **Evidence:** во всех 17 v2-эпизодах эти три массива — `[]`. Только `node_chosen` и `task_classification` заполнены.
|
||||
- **Чтение:** парсер пишет только финальное решение, не аргументацию. Это известный gap (см. spec §6 «routing_decision события»).
|
||||
- **Suggested action:** в углублённой фазе спроектировать парсер-расширение, которое:
|
||||
1. Извлекает declared triggers из preamble assistant-сообщения (например, «триггеры: TDD на код / debug-задача»);
|
||||
2. Извлекает candidates_considered из текстов вида «варианты: brainstorming / subagent-driven» или AskUserQuestion-options;
|
||||
3. Извлекает boundaries_applied из явных упоминаний ADR/Pravila §N.
|
||||
- **Cost / risk:** среднее; есть риск false-positives — закрывается тестами на zachrest-фикстурах.
|
||||
|
||||
### Candidate 7: NLP-фактор «task_classification=other 59%»
|
||||
|
||||
- **Тип:** наблюдение по классификатору.
|
||||
- **Evidence:** 10 из 17 (59%) — `other`. `bugfix` 1, `refactor` 2, `feature` 3, `question` 2, остальные `other`.
|
||||
- **Чтение:** классификатор парсера задаёт grobые корзины. «Other» становится свалкой для «короткие memory-edit'ы», «диалоговые ответы», «нормативные правки», «обновление эталона», «проверка состояния» и т.п.
|
||||
- **Suggested action:** в углублённой фазе подумать о расширении classifier-словаря (например, добавить `docs-edit`, `memory-sync`, `state-check`, `regulatory-bump`). Это уменьшит «other»-bucket и сделает факторный анализ острее.
|
||||
- **Cost / risk:** 1 рабочий день парсер-правок; риск ошибок классификации — мерится по retro.
|
||||
|
||||
---
|
||||
|
||||
## Informational metrics (NOT alerts)
|
||||
|
||||
- **Узлов реестра использовано как минимум 1 раз за период:** 4 (`superpowers:systematic-debugging`, `superpowers:test-driven-development`, `superpowers:verification-before-completion`, `claude-md-management:claude-md-improver`) из 60+ формализованных позиций (канон счётчиков — `docs/Tooling_v8_3.md` §0).
|
||||
- **Узлов реестра не использовано ни разу:** ~56+ из 60+. **Это не проблема** per [feedback_brain_unused_tools_not_problem](../../../memory/feedback_brain_unused_tools_not_problem.md). Capability-readiness — осознанная стратегия заказчика.
|
||||
- **Hot-files** (по `task_size.files`, с дедупом): `CLAUDE.md` (3 раза), `project_brain_governance_design.md` (3), `reference_github.md` (3), `episodes-2026-05.jsonl` (2), `2026-05-19-observer-factor-analysis-design.md` (2), `tools/observer-transcript-parser.mjs` (2), tools/observer-transcript-parser.test.mjs (2). Остальные 30+ файлов — один раз.
|
||||
- **Самый длинный эпизод:** №22 (20.05 07:52 → 08:12, 19m45s, 56 tool_calls, 11 файлов, TDD-починка наблюдателя, regulated path).
|
||||
- **Самый короткий эпизод:** №2 (19.05 06:07–06:08, 1m, 0 tool_calls — диалоговый ответ).
|
||||
|
||||
---
|
||||
|
||||
## Что **не** делалось в этом ретро (явные ограничения)
|
||||
|
||||
1. **Не сверял с `docs/routing-off-phase.md` L1–L12 канонические связки** — пометил для углубления.
|
||||
2. **Не читал v1-эпизоды для извлечения качественных уроков** — анализатор их пропускает по конструкции, но сами факты в них есть (особенно ep.5 — большой Read+Grep дебаг-эпизод 19.05 06:32).
|
||||
3. **Не оценивал latency хуков** — данные есть (`hook_fired.counts`), но требуют отдельной агрегации с переводом в миллисекунды (нет timestamp'ов on per-hook basis в JSONL).
|
||||
4. **Не запускал других контролёров C1–C5** для cross-check'а (l1-watcher / cross-ref-checker / observer-of-observer / coverage-checker) — это lefthook-jobs 11–15, не /brain-retro responsibility.
|
||||
5. **Не правил никакую нормативку** — read-only, как требует procedure step 7.
|
||||
6. **Не делал predictions** — данных n=17 категорически мало для статистически значимых выводов; всё, что я выше отметил как «корреляция» — это либо артефакт выборки, либо описательное наблюдение.
|
||||
|
||||
---
|
||||
|
||||
## Подпись и провенанс ретро
|
||||
|
||||
Анализатор — `tools/brain-retro-analyzer.mjs` (commit `e6d6bab` в текущем checkout).
|
||||
Шаблон — `.claude/skills/brain-retro/references/aggregation-template.md`.
|
||||
Read-counter — обновлён до `last_read_at=2026-05-20T11:25:00+03:00`, `read_count_last_period=1`.
|
||||
Эта нота — read-only вывод; никакая нормативная правка не выполнена.
|
||||
|
||||
---
|
||||
|
||||
# АДДЕНДУМ — углубления по 5 разделам (по запросу заказчика, экономия 5%)
|
||||
|
||||
> Закрытие 5 явных gaps, отмеченных в верхнеуровневом отчёте. Read-only,
|
||||
> никаких правок нормативки. Данные собраны 2026-05-20 ≈11:35 MSK.
|
||||
|
||||
## A. L1–L12/L13 классификация двух regulated-эпизодов
|
||||
|
||||
Источник истины — [docs/routing-off-phase.md](../routing-off-phase.md) v1.2 (20.05.2026,
|
||||
12 канонических + L13 finance-tooling).
|
||||
|
||||
**Эпизод №16 (2026-05-19T10:13Z, 19m, regulated, opus-4-7):**
|
||||
|
||||
- `skill_invoked`: `superpowers:systematic-debugging` + `claude-md-management:claude-md-improver`.
|
||||
- Файлы: `tools/observer-transcript-parser.{mjs,test.mjs}` + `CLAUDE.md` + transcript-jsonl сессии.
|
||||
- **Сверка с L1–L13:**
|
||||
- L8 (`systematic-debugging + Sentry + Redis`) — **частично**: `systematic-debugging`
|
||||
был invoked, но Sentry/Redis MCP не использовались (баг был в локальном tool-коде,
|
||||
не в production runtime).
|
||||
- L12 (`claude-md-management + revise-claude-md`) — **частично**: `claude-md-improver`
|
||||
invoked. Это один из двух entry-skill'ов L12. `/claude-md-management:revise-claude-md`
|
||||
в этом эпизоде не вызывался (improver делал targeted update CLAUDE.md, не capture
|
||||
session-learnings).
|
||||
- **Вердикт:** эпизод **не маппится 1:1** на каноническую L-связку. Это **гибрид
|
||||
Pravila §12.2 hard-floor** (systematic-debugging при unexpected behavior) **+ §5 п.10
|
||||
обязательного канала** (правка CLAUDE.md только через claude-md-management). Оба
|
||||
правила вне off-phase routing → L1–L13 не покрывают такую цепочку **by design**
|
||||
(off-phase routing не дублирует Pravila §12.2/§5 п.10 hard-rules — это явно в
|
||||
routing-off-phase §11.5 «Hard-rules перевешивают routing-аид»).
|
||||
|
||||
**Эпизод №22 (2026-05-20T07:52Z, 19m45s, regulated, opus-4-7):**
|
||||
|
||||
- `skill_invoked`: `superpowers:test-driven-development` + `superpowers:verification-before-completion`.
|
||||
- Файлы: 11 `tools/observer-*` + `.gitleaks.toml` (test-fixture seam с false-positive).
|
||||
- **Сверка с L1–L13:** **ни одна** L-связка off-phase routing не покрывает
|
||||
«TDD + verification». L1–L13 — это off-phase инструменты (#31–#63), TDD/verify —
|
||||
это Pravila §12.2 hard-floor (Superpowers). Тоже не дублируется в routing-аиде.
|
||||
- **Вердикт:** **чистый Pravila §12.2 hard-floor двух skill'ов в одной задаче**
|
||||
(«TDD на любой код» + «verification перед claim 'готово'»). L-маркировка
|
||||
неприменима.
|
||||
|
||||
**Сводный вывод по A:** Из 17 v2-эпизодов **0 канонических L-связок** off-phase
|
||||
сработало. Все 2 regulated-эпизода прошли через **Pravila §12.2 hard-floor** (skill
|
||||
из обязательной карты §12.2), не через каноническую цепочку L1–L13. **Это здоровый
|
||||
сигнал**: за период не было задач, попадающих в off-phase кейсы (ML-эпик, security-аудит,
|
||||
финансы, BPMN, ADR — ни одного). Все turn'ы — самообслуживание наблюдателя и
|
||||
правки нормативки/memory.
|
||||
|
||||
| chain | times | вердикт |
|
||||
|---|---|---|
|
||||
| L1 discovery→brainstorm→writing-plans→subagent-driven | 0 | за период не было фич, требующих full discovery loop |
|
||||
| L2 discovery (SYSTEM) + audit-portal | 0 | не был audit-режим |
|
||||
| L3 process-analysis ↔ process-modeling | 0 | не было процесс-задач |
|
||||
| L4 mermaid ← adr-kit/process-modeling/operations | 0 | не было новых диаграмм |
|
||||
| L5 adr-kit + architecture-patterns + deptrac | 0 | не было новых ADR |
|
||||
| L6 security слой 4 узла | 0 | не было security-эпиков |
|
||||
| L7 openapi-mcp + api-docs + Boost | 0 | не было API-интеграций |
|
||||
| L8 systematic-debugging + Sentry + Redis | 0 (но partial в ep.16) | debug по локальному tool-коду; Sentry pending Б-1 |
|
||||
| L9 CCPM + product-management + GitHub MCP | 0 | не было новых эпиков |
|
||||
| L10 promptfoo + Data Scientist + claude-api | 0 | не было LLM-фич |
|
||||
| L11 skill-creator + hookify + plugin-dev | 0 | не было новых скилов/хуков |
|
||||
| L12 claude-md-management + revise-claude-md | 0 (partial в ep.16) | improver, не revise |
|
||||
| L13 billing-audit + Pest + Boost + Sentry/Redis → ru-tax | 0 | finance-tooling узлы введены только 20.05 |
|
||||
|
||||
---
|
||||
|
||||
## B. Уроки из 5 v1-эпизодов 19.05 утра (05:18–06:57)
|
||||
|
||||
Анализатор пропускает v1 (нет `schema_version=2`), но фактический контент в JSONL
|
||||
есть. Сводка (по содержанию эпизодов 1–5 из episodes-2026-05.jsonl):
|
||||
|
||||
| ep | старт | длит. | path | outcome | task_class | tool-нагрузка | сигнал |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| 1 | 05:18:16 | **47m 39s** ⚠️ | improvised | success | refactor | AskUserQuestion×5, TodoWrite×2 | «refactor planning через множественные ветки выбора с заказчиком». Самый длинный эпизод всего периода. |
|
||||
| 2 | 06:07:06 | 1m 15s | improvised | success | other | **0 tool_calls** | диалоговый турн (ответ без действий). |
|
||||
| 3 | 06:10:13 | 5m 58s | improvised | success | other | Write×1, Bash×2, Edit×3, TodoWrite×1, **1 error** | мелкая правка с одной recovered ошибкой. |
|
||||
| 4 | 06:20:40 | 2m 28s | improvised | success | other | Bash×2, Read×1, Edit×2 | чистая short-edit задача. |
|
||||
| 5 | 06:32:15 | **24m 47s** ⚠️ | improvised | success | **bugfix** | Read×17, Grep×14, Glob×5, ToolSearch×1, TodoWrite×4, Write×1 | **debug-эпизод через интенсивную навигацию** без skill_invoked. |
|
||||
|
||||
**Качественные уроки:**
|
||||
|
||||
1. **Ep.1 (47m, refactor planning)** — массовое использование AskUserQuestion (5 раз
|
||||
за один турн). Это classic-паттерн «дерево вариантов с заказчиком» **без
|
||||
`superpowers:brainstorming` skill'а** — Pravila §16 (brain governance) с
|
||||
обязательным routing-gate был ещё **не enforced** на этот момент (Phase A/B/C
|
||||
закрыты commit'ами `2ef4ac4` / `4382de3` / `a70d5a4` именно 19.05). Поэтому
|
||||
§12.2 ещё не флагировал «brainstorm-задачу без skill'а». **Не violation**
|
||||
по правилам **на момент турна**.
|
||||
|
||||
2. **Ep.5 (24m, bugfix через 17 Read + 14 Grep + 5 Glob)** — **самый показательный
|
||||
урок**: классический debug-эпизод (поиск по огромному файловому surface) прошёл
|
||||
`direct`, без `superpowers:systematic-debugging`. Сейчас (после раскатки v2
|
||||
schema + Stop-хук routing-gate с 08:06 того же дня) **такой эпизод флагировался
|
||||
бы** как `decision_provenance.kind=autonomous` + `node_chosen=direct` при
|
||||
bugfix-классификации → именно тот сигнал, который Phase B/C поднимала. В v1
|
||||
мы видим **факт без сигнала**.
|
||||
|
||||
3. **Ep.2 (1m, 0 tool_calls)** — короткий диалоговый turn'а полезен как **baseline
|
||||
minimum** для калибровки «что значит epizod с 0 tools»: это просто текстовый
|
||||
ответ. Это согласуется с фактом, что парсер пишет эпизод **на каждый Stop**, не
|
||||
только на «значимые» (важно для статистики покрытия).
|
||||
|
||||
4. **Все v1 outcome=success написаны парсером напрямую**, не inferred next-prompt
|
||||
sentiment'ом (как делает анализатор для v2). Это значит: **v1 outcome
|
||||
ненадёжен** для сравнения с v2 — там «success» это «не упал», а в v2
|
||||
«success» это «next prompt был approval/new_task». Разные семантики.
|
||||
|
||||
5. **Историческая важность:** ep.1 + ep.5 показывают, что **brain governance был
|
||||
реальным улучшением, а не косметикой** — два паттерна (5×AskUserQuestion без
|
||||
brainstorm + bugfix без systematic-debugging) теперь **видимы** в v2-данных.
|
||||
|
||||
**Кандидат-наблюдение (НЕ действие):** не конвертировать v1→v2 ретроактивно — это
|
||||
введёт ложные `decision_provenance=autonomous` метки в данные периода, когда
|
||||
routing-gate ещё не работал. Лучше оставить v1 как `v1SkippedCount=5` для
|
||||
**прозрачности bootstrap-эпохи**.
|
||||
|
||||
---
|
||||
|
||||
## C. Latency-замер: tool round-trip из транскрипта 553717ec
|
||||
|
||||
Источник: `~/.claude/projects/.../553717ec-bf55-43dc-8b9c-b9812711023a.jsonl`
|
||||
(22 400 строк, 68.7 МБ; вся сессия 19.05 — 5h+, 24-25 турнов).
|
||||
|
||||
**Метод:** для каждого `tool_use_id` в `assistant.content` нашёл соответствующий
|
||||
`tool_result` в следующих `user.content` блоках; разница `timestamp` = round-trip
|
||||
latency (включает hook overhead + network + tool execution + Claude reply parsing).
|
||||
Outliers ≥600 000ms отброшены. **N=2857 tool-roundtrip'ов** по 14 разным tools.
|
||||
|
||||
| tool | n | p50 (ms) | p90 (ms) | p95 (ms) | p99 (ms) | max (ms) | чтение |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Bash | 932 | 2491 | 13 489 | 17 251 | 48 746 | 114 335 | включает время самих команд (compose/pest/lefthook) |
|
||||
| Read | 586 | **444** | 736 | 876 | 11 873 | 11 873 | IO-only, близко к hook floor |
|
||||
| Edit | 554 | **1401** | 1516 | 1674 | 5173 | 48 927 | **+1s vs Read** — тяжёлый PostToolUse-pipeline |
|
||||
| TodoWrite | 237 | **400** | 459 | 526 | 602 | 731 | in-memory list-state, **тоньше всех** — почти чистый hook floor |
|
||||
| Write | 125 | 1400 | 1661 | 5401 | 31 531 | 31 531 | тот же PostToolUse-pipeline что Edit |
|
||||
| Grep | 119 | 492 | 605 | 998 | 2914 | 2914 | ripgrep IO + parse |
|
||||
| Agent | 107 | 135 883 | 213 723 | 263 840 | 387 336 | 387 336 | время субагентов, не hook overhead |
|
||||
| AskUserQuestion | 69 | 50 064 | 323 325 | 534 449 | 534 449 | 534 449 | время реакции заказчика |
|
||||
| Skill | 52 | 485 | 825 | 875 | 941 | 941 | скил-load |
|
||||
| Glob | 32 | 12 690 | 20 265 | 20 265 | 20 265 | 20 265 | неожиданно медленно (большие patterns?) |
|
||||
| ToolSearch | 27 | **394** | 457 | 457 | 518 | 518 | in-memory схема-резолв |
|
||||
| PowerShell | 9 | 2365 | 2600 | 2600 | 2600 | 2600 | время команды PS |
|
||||
| EnterPlanMode | 4 | 436 | 436 | 436 | 436 | 436 | вход в plan-mode |
|
||||
| ExitPlanMode | 4 | 92 988 | 92 988 | 92 988 | 92 988 | 92 988 | время согласования плана заказчиком |
|
||||
| **ALL** | **2857** | **1317** | 13 515 | 50 064 | 204 784 | 534 449 | overall |
|
||||
|
||||
**Выводы по latency:**
|
||||
|
||||
1. **Hook floor ≈ 400–500ms** — это p50 для «легчайших» tools (TodoWrite 400, ToolSearch
|
||||
394, EnterPlanMode 436, Read 444, Grep 492, Skill 485). Эти tools делают минимум
|
||||
работы своих, поэтому 400–500ms — это network round-trip (~150ms) + hook chain
|
||||
(~250–350ms). Конкретнее: CLAUDE.md шапка v2.17 упоминала «B4 latency-замер хуков
|
||||
закрыт (~34 мс median/хук)» (memory `feedback_superpowers_hard_rule.md`); при
|
||||
~6–8 хуков на tool × 34ms ≈ 200ms — совпадает с порядком оценки.
|
||||
2. **Edit/Write — самые «дорогие» легковесные tools (p50 ~1400ms)**, то есть +~1s
|
||||
к hook floor TodoWrite (400ms). Этот **+1s** — это PostToolUse-цепочка для
|
||||
write-операций: `markdownlint --fix` (если .md), `gitleaks protect --staged` (если
|
||||
staged), плюс возможно l1-watcher (если settings.json/Tooling). Это **информативно**,
|
||||
не алерт.
|
||||
3. **Bash p50 = 2.5s, p99 = 49s** — нормально для команд с собственной работой
|
||||
(composer/pest/lefthook). Hook overhead — лишь ~500ms из этого.
|
||||
4. **Glob p50 = 12.7s** — **аномалия**: ripgrep-glob делается быстро (msec); 12s
|
||||
на p50 указывает на тяжёлые patterns (`**/*` через большие subagent-каталоги
|
||||
в `~/.claude/projects/`). См. кандидат C-1 ниже.
|
||||
5. **AskUserQuestion / ExitPlanMode** — медианы 50s и 93s — это реальное время
|
||||
реакции заказчика, не hook'и. Это **полезная метрика UX-задержки**: за период
|
||||
~7% времени Claude тратится в ожидании ответа заказчика.
|
||||
|
||||
**Кандидат C-1 (НЕ действие):** Glob с p50=12.7s — посмотреть, не запускается ли
|
||||
Glob на огромных subagent-логах `~/.claude/projects/.../subagents/*/*.jsonl`
|
||||
(их там 100+). При следующем углублении — отфильтровать patterns по
|
||||
содержанию и проверить.
|
||||
|
||||
**Кандидат C-2 (НЕ действие):** Edit p99 = 48s, max 48.9s — один эпизод где
|
||||
PostToolUse-цепочка зависла (вероятно gitleaks при огромном diff). Точечная
|
||||
investigation потенциально полезна, но **n=1**, можем ждать повторения.
|
||||
|
||||
---
|
||||
|
||||
## D. N=17 — план «когда меряем фактор-анализ серьёзно»
|
||||
|
||||
**Текущий лимит:** 17 v2-эпизодов недостаточно для статистически значимых
|
||||
заключений. «Корреляции» в верхнеуровневом отчёте (`post_compaction` / `session_turn` /
|
||||
`parallel_session`) — артефакты выборки.
|
||||
|
||||
**Минимально достаточно для serious factor-analysis:**
|
||||
|
||||
| фактор | минимальная выборка для p<0.05 (одна ячейка матрицы) | оценка времени накопления |
|
||||
|---|---|---|
|
||||
| `decision_provenance` (3 значения × 4 outcome) | n≥120 эпизодов (≥10 в каждой ячейке) | при текущем темпе ~17 эп/неделя — **~7 недель** |
|
||||
| `economy_level` (4 × 4) | n≥160 | **~9 недель** |
|
||||
| `model` (нужно ≥2 модели, сейчас только Opus) | требует Sonnet/Haiku-эпизодов | возникнет с subagent-driven-development в `claude_would_have_chosen` |
|
||||
| `post_compaction` (2 × 4) | n≥80 | **~5 недель** |
|
||||
| `parallel_session` (2 × 4) | n≥80 | **~5 недель** |
|
||||
| `node_chosen` (тонкий хвост) | n≥300 | **~17 недель** |
|
||||
|
||||
**Практическая рекомендация:** следующее brain-retro имеет смысл проводить
|
||||
**не раньше чем через 4–6 недель** (или ~80–100 v2-эпизодов). До этого
|
||||
аккумулировать данные. Если заказчик хочет интервалы — еженедельные «лёгкие»
|
||||
ретро (только Observer health + path-type + L-chains) без полной матрицы.
|
||||
|
||||
**Текущее накопление 1× /brain-retro — это baseline-замер, не factor-analysis.**
|
||||
|
||||
---
|
||||
|
||||
## E. Cross-check контролёров C1–C5 — все GREEN
|
||||
|
||||
Запущено вручную параллельно (lefthook jobs 11–15 + post-commit job 14):
|
||||
|
||||
| контролёр | команда | exit | результат |
|
||||
|---|---|---|---|
|
||||
| **C1 l1-watcher** (job 11) | `node tools/l1-watcher.mjs` | 0 | `OK — 0 drift` (settings.json ↔ Tooling Прил. Н sync) |
|
||||
| **C2 cross-ref-checker** (job 12) | `node tools/cross-ref-checker.mjs` | 0 | `OK — 0 drift in 4 files` (Pravila / PSR_v1 / Tooling / CLAUDE.md / MEMORY.md cross-refs) |
|
||||
| **C3 observer-of-observer** (job 13) | `node tools/observer-of-observer.mjs check` | 0 | `OK — last read 0 week(s) ago` (я только что обновил read-counter; 54-week pre-prune защита не сработает) |
|
||||
| **C4 status-md** (post-commit) | `node tools/status-md-generator.mjs` | 0 | пересоздал `docs/observer/STATUS.md` (новый timestamp `2026-05-20T08:31:22.723Z`) |
|
||||
| **C5 observer-coverage** (job 15) | `node tools/observer-coverage-checker.mjs` | 0 | `OK — 24 episode(s) this month · Stop-hook + post-commit OK` |
|
||||
|
||||
**Расхождение с anal'изером:** C5 сейчас видит **24 эпизода**, мой анализатор
|
||||
(запущенный в начале ретро) обработал **22**. Дельта = 2 — это эпизоды, которые
|
||||
Stop-хук записал ЗА ВРЕМЯ выполнения брейн-ретро (моя текущая сессия 98298ec2,
|
||||
turns 1–2 после начала ретро). **Не дрейф**: это нормально (наблюдатель пишет
|
||||
в реальном времени).
|
||||
|
||||
STATUS.md обновлён и **показывает все 5 контролёров ✅**. Если бы один из них
|
||||
flag'нул — после следующего git commit (post-commit hook job 14) STATUS.md
|
||||
auto-обновился бы; обходного канала не нужно.
|
||||
|
||||
**Кандидат E-1 (НЕ действие):** rerun `node tools/brain-retro-analyzer.mjs
|
||||
docs/observer/episodes-2026-05.jsonl` через несколько турнов / в конце сессии —
|
||||
факторная матрица станет на 1–2 эпизода полнее. Имеет смысл встроить в
|
||||
сам /brain-retro skill «final pass перед save» — кандидат для **углублённой
|
||||
итерации скила**.
|
||||
|
||||
---
|
||||
|
||||
## Итог по углублениям
|
||||
|
||||
| # | Раздел | Status | Главное наблюдение |
|
||||
|---|---|---|---|
|
||||
| A | L1–L12/L13 hit rate | ✅ verified | 0 канонических связок отработало; оба regulated-эпизода — чисто Pravila §12.2 hard-floor |
|
||||
| B | v1-эпизоды (5 шт.) | ✅ verified | brain governance enforcement добавляет видимость — два showcased паттерна (ep.1 без brainstorm, ep.5 без systematic-debugging) теперь были бы флагированы |
|
||||
| C | latency-замер | ✅ verified | hook floor ≈ 400–500ms; Edit/Write +~1s; Bash p50=2.5s; Glob аномально 12.7s (потенциальная investigation) |
|
||||
| D | N=17 → план серьёзного анализа | ✅ verified | следующее полное ретро через ~5–7 недель (80–100 v2-эпизодов) |
|
||||
| E | C1–C5 cross-check | ✅ verified | все 5 контролёров GREEN, STATUS.md обновлён, расхождение «22 vs 24» — Stop-хук пишет в реальном времени |
|
||||
|
||||
**Файлы, тронутые в этом углублении:** только `docs/observer/notes/2026-05-20-brain-retro.md`
|
||||
(этот же файл, аддендум) + `docs/observer/.read-counter.json` (на step 4 базовой части) +
|
||||
`docs/observer/STATUS.md` (через C4 status-md generator). Никаких изменений в нормативке,
|
||||
коде, тестах.
|
||||
|
||||
---
|
||||
|
||||
# АДДЕНДУМ B — пре-имплементационные верификации (по запросу заказчика, экономия 5%)
|
||||
|
||||
> Закрытие 3 actionable «не верифицировал» из ответа-обзора 19 пунктов «что улучшить
|
||||
> в наблюдателе». Read-only verification над реальными артефактами;
|
||||
> никакого кода не правил; никакая нормативка не тронута.
|
||||
|
||||
## B1. `message.usage` в Claude Code transcript — присутствует, формат подтверждён
|
||||
|
||||
**Метод:** `grep -c '"usage"'` + sample первых 3 occurrences по реальному transcript
|
||||
`~/.claude/projects/.../553717ec-bf55-43dc-8b9c-b9812711023a.jsonl` (сессия 19.05, 22 400 строк, 68.7 МБ).
|
||||
|
||||
**Результат:**
|
||||
|
||||
- **6 372 occurrences** в одной сессии.
|
||||
- **Формат фиксированный** (наблюдался идентичный во всех 3 samples):
|
||||
```json
|
||||
"usage": {
|
||||
"input_tokens": 2,
|
||||
"cache_creation_input_tokens": 107333,
|
||||
"cache_read_input_tokens": 32087,
|
||||
"output_tokens": 4518,
|
||||
"server_tool_use": {
|
||||
"web_search_requests": 0,
|
||||
"web_fetch_requests": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Импликации для рекомендации #2 «token-usage capture»:**
|
||||
|
||||
- Все **4 поля**, которые я предлагал захватывать (`input_tokens` / `output_tokens` /
|
||||
`cache_read_input_tokens` / `cache_creation_input_tokens`) — **подтверждены присутствующими**.
|
||||
Реализация безопасна.
|
||||
- **Bonus-фактор обнаружен:** `server_tool_use.web_search_requests` +
|
||||
`server_tool_use.web_fetch_requests` — счётчики использования server-side
|
||||
инструментов (WebSearch / WebFetch). Можно добавить в `task_cost` как доп.
|
||||
фактор «использовал ли web-инструменты».
|
||||
|
||||
**Уточнённая спецификация #2:**
|
||||
|
||||
```js
|
||||
export function extractTokenUsage(turn) {
|
||||
let input=0, output=0, cache_read=0, cache_creation=0, web_search=0, web_fetch=0;
|
||||
for (const e of turn) {
|
||||
const u = e?.message?.usage;
|
||||
if (!u) continue;
|
||||
input += u.input_tokens || 0;
|
||||
output += u.output_tokens || 0;
|
||||
cache_read += u.cache_read_input_tokens || 0;
|
||||
cache_creation += u.cache_creation_input_tokens || 0;
|
||||
web_search += u?.server_tool_use?.web_search_requests || 0;
|
||||
web_fetch += u?.server_tool_use?.web_fetch_requests || 0;
|
||||
}
|
||||
return {
|
||||
input_tokens: input,
|
||||
output_tokens: output,
|
||||
cache_read_input_tokens: cache_read,
|
||||
cache_creation_input_tokens: cache_creation,
|
||||
web_search_requests: web_search,
|
||||
web_fetch_requests: web_fetch,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## B2. `attachment.type` структура — `hook_success` подтверждён, `hook_error` 0 occur
|
||||
|
||||
**Метод:** `grep` по `"attachment":{...}` + по полям `type` / `hookName`.
|
||||
|
||||
**Результат:**
|
||||
|
||||
- **11 685 occurrences** `"attachment"` в той же сессии.
|
||||
- **Виды type, встреченные:** `hook_success` ✅ + `hook_additional_context` + `deferred_tools_delta` + `queue-operation`.
|
||||
- **`hook_error` = 0 occurrences** во всей сессии 5h+.
|
||||
- Структура `hook_success`:
|
||||
|
||||
```json
|
||||
"attachment": {
|
||||
"type": "hook_success",
|
||||
"hookName": "SessionStart:startup",
|
||||
"toolUseID": "12a033f0-...",
|
||||
"hookEvent": "SessionStart",
|
||||
"content": "",
|
||||
"stdout": "{ ... }"
|
||||
}
|
||||
```
|
||||
|
||||
**Импликации для парсера ([parser.mjs:295-300](../../tools/observer-transcript-parser.mjs#L295-L300)):**
|
||||
|
||||
- Код парсера **корректен** — он матчит на `attachment.type === 'hook_success' || 'hook_error'`.
|
||||
`hook_success` приходит; `hook_error` имеет 0 occurrences, потому что хуки
|
||||
**физически не падали** за период. Парсер пишет `errors: 0` в `hook_fired` event —
|
||||
это **здоровый сигнал**, не bug.
|
||||
- **Уточнение к будущей правке per-hook timing:** в `attachment.hook_success` нет
|
||||
полей `started_at`/`ended_at` — per-hook timing **не извлекаем** из transcript
|
||||
напрямую. Требуется инструментация самих хуков (записывать timestamp в `stdout`
|
||||
или внешний log). Это **vendoring или fork** хука, нетривиально. Рекомендация:
|
||||
оставить per-hook timing **out-of-scope** для текущего раунда; ограничиться
|
||||
tool-round-trip latency (как делал в C-аддендуме первой части ноты через timestamp
|
||||
diff между `tool_use` и `tool_result`).
|
||||
- **Дополнительные attachment-типы** (`hook_additional_context` / `deferred_tools_delta` /
|
||||
`queue-operation`) — НЕ обрабатываются парсером, и это OK: они информационные,
|
||||
не connect'ятся к hook execution.
|
||||
|
||||
**Дополнительная находка:** `attachment.toolUseID` присутствует в `hook_success` —
|
||||
связывает hook с конкретным tool_use. Если эта связь сохранится и в `hook_error`
|
||||
(когда таковой появится), можно атрибутировать ошибку хука к конкретному tool, не
|
||||
только к hook-name. Это **future capability**, не текущая правка.
|
||||
|
||||
## B3. tools/observer-*.test.mjs — 232/232 GREEN
|
||||
|
||||
**Метод:** `cd app && npx vitest run --config vitest.config.tools.mjs --reporter=verbose`.
|
||||
Раннер найден в [app/vitest.config.tools.mjs](../../app/vitest.config.tools.mjs):
|
||||
|
||||
```js
|
||||
include: ['../tools/*.test.mjs'],
|
||||
exclude: ['../tools/ruflo-*.test.mjs', '../tools/subagent-prompt-prefix.test.mjs'],
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
|
||||
```
|
||||
Test Files 13 passed (13)
|
||||
Tests 232 passed (232)
|
||||
Start at 11:49:45
|
||||
Duration 1.21s (transform 1.11s, setup 0ms, import 1.82s, tests 350ms, environment 4ms)
|
||||
```
|
||||
|
||||
**13 тестовых файлов:**
|
||||
|
||||
| Файл | Покрывает |
|
||||
|---|---|
|
||||
| `brain-dashboard-core.test.mjs` | Brain Dashboard UI logic (19 тестов) |
|
||||
| `brain-retro-analyzer.test.mjs` | Анализатор этого скила (15) |
|
||||
| `cross-ref-checker.test.mjs` | C2 контролёр (14) |
|
||||
| `l1-watcher.test.mjs` | C1 контролёр (12) |
|
||||
| `observer-coverage-checker.test.mjs` | C5 контролёр (8) |
|
||||
| `observer-of-observer.test.mjs` | C3 контролёр (5) |
|
||||
| `observer-pii-filter.test.mjs` | PII фильтр (15) |
|
||||
| `observer-routing-detector.test.mjs` | Routing-gate детектор (8) |
|
||||
| `observer-stop-hook.test.mjs` | Stop-hook + appendEpisode + routingGateDecision (18) |
|
||||
| `observer-transcript-parser.test.mjs` | Парсер транскрипта — главный (53) |
|
||||
| `status-md-generator.test.mjs` | C4 генератор (5) |
|
||||
| + 2 ещё через include glob | ~60 (subtotal до 232) |
|
||||
|
||||
**Импликации:**
|
||||
|
||||
- Текущая observer-инфраструктура **стабильна**: 232 теста за 1.21 сек, 0 регрессий.
|
||||
- Любая правка из рекомендаций #1–#19 имеет **прочную регрессионную сеть** —
|
||||
добавлять тесты на расширения, не трогая существующие.
|
||||
- Раннер закрепляется как канонический способ запуска tools-тестов:
|
||||
`cd app && npx vitest run --config vitest.config.tools.mjs`.
|
||||
|
||||
**Кандидат B3-1 (новая микро-правка):** добавить в корневой [package.json](../../package.json) одну строку:
|
||||
|
||||
```json
|
||||
"test:tools": "cd app && npx vitest run --config vitest.config.tools.mjs"
|
||||
```
|
||||
|
||||
Это **отдельная** микро-правка, не часть рекомендаций #1–#19; стоимость 1 минута;
|
||||
ROI — улучшение DX (тесты `tools/*` найти теперь сложно без чтения source-кода
|
||||
конфига).
|
||||
|
||||
---
|
||||
|
||||
## Сводка по верификациям
|
||||
|
||||
| # | Что проверял | Результат | Влияние на рекомендации |
|
||||
|---|---|---|---|
|
||||
| B1 | `message.usage` структура | ✅ 6372 occur, формат подтверждён + bonus `server_tool_use` фактор | #2 token-usage capture — реализуема, спека уточнена с web_search/web_fetch |
|
||||
| B2 | `attachment.type='hook_success'`/`hook_error` | ✅ hook_success есть; hook_error 0 occur (хуки не падают) | парсер корректен; per-hook timing требует инструментации хуков (out of scope) |
|
||||
| B3 | `tools/observer-*.test.mjs` запуск | ✅ 232/232 GREEN за 1.21s | прочная регрессионная сеть; новые правки безопасны |
|
||||
|
||||
**0 регрессий, 0 правок кода/нормативки.** Готов реализовать рекомендации #1–#19
|
||||
(любые) — заказчик решает, какие применить и в каком порядке.
|
||||
@@ -70,3 +70,4 @@ Every turn — implicitly by Claude at session start, explicitly when routing is
|
||||
## Changelog
|
||||
|
||||
- **v1.0 (2026-05-19)** — initial fixation. Replaces implicit-scattered routing. ADR-011.
|
||||
- **v1.1 (2026-05-20)** — finance-tooling узлы #61-#63 добавлены в реестр Tooling §4.36-§4.38 (читаются step 3) и routing-off-phase.md (+3 строки routing + связка L13). Структурных правок процедуры нет. ADR-012.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Routing-аид: задача → off-phase узел
|
||||
|
||||
> **Назначение.** Quick-reference: триггер задачи → какой off-phase узел тулчейна
|
||||
> взять (Tooling §4.11–§4.35). Закрывает пробел SYSTEM-аудита 18.05.2026 (Rec3):
|
||||
> взять (Tooling §4.11–§4.38). Закрывает пробел SYSTEM-аудита 18.05.2026 (Rec3):
|
||||
> 30 off-phase инструментов регулировались плоским 3-блочным реестром PSR_v1 R10.1
|
||||
> без матрицы «задача → узел».
|
||||
>
|
||||
@@ -12,7 +12,7 @@
|
||||
> **Источник истины.** Tooling §4.X (детальное описание каждого узла), Pravila §13.2
|
||||
> (категоризация off-phase), PSR_v1 R10.1 (3-блочный реестр ролей).
|
||||
>
|
||||
> **Версия.** 1.1 (18.05.2026 вечер — аудит дисциплины R15: +строка «диагностика
|
||||
> **Версия.** 1.2 (20.05.2026 — finance-tooling: +3 строки routing #61-#63 + связка L13 + scope §4.11→§4.38, ADR-012. v1.1 18.05.2026 вечер — аудит дисциплины R15: +строка «диагностика
|
||||
> конверсии» → process-analysis #53 (M3); +note про UI-пул #31/#32 как делегирующие
|
||||
> строки, не R15-routed (M1). v1.0 — Rec3 SYSTEM-аудита). Триггеры — формулировки
|
||||
> заказчика или явные ключевые слова в промпте.
|
||||
@@ -55,6 +55,9 @@
|
||||
| Создать хук на повторяющуюся ошибку | **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 для библиотек |
|
||||
| Аудит денежной корректности биллинга (списание/тариф/баланс/дрейф/charge_source) | **billing-audit** (project-скил) | #62 | finance-tooling | C6; ≠ process-*/D3/ru-tax (ADR-012) |
|
||||
| РСБУ/НК РФ контекст: НДС/УСН, налоговая база, выгрузка бухгалтеру | **ru-tax-accounting** (project-скил) | #63 | finance-tooling | C7; ≠ finance plugin/D1/D2 (ADR-012) |
|
||||
| Сверка счетов / variance-анализ / US-GAAP-отчётность / проводки | **finance plugin** | #61 | finance-tooling | C7; SOX not-applicable, warehouse-MCP DEFERRED (ADR-012) |
|
||||
| Отладка 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 — единственный канал |
|
||||
@@ -90,6 +93,7 @@
|
||||
| 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. |
|
||||
| L13 | `billing-audit` (#62) + `Pest` (#18) + `Boost` (#10) + `Sentry`/`Redis` (#34/#35) → `ru-tax-accounting` (#63) | Финансовая цепочка: аудит денежных инвариантов кода (billing-audit) тестами (Pest) на моделях (Boost) с runtime-фактами (Sentry/Redis) → перевод выверенной выручки в учётно-налоговый контекст (ru-tax). C6→C7. Граница — ADR-012. |
|
||||
|
||||
**Anti-pattern связок** (не комбинировать в одной задаче):
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -99,7 +99,7 @@ cd app && composer test -- --parallel
|
||||
|
||||
- [ ] **Step 1.3: Зафиксировать наблюдения в комментариях `SupplierPortalClient.php`.**
|
||||
|
||||
Заменить placeholder-докблок [SupplierPortalClient.php:24-28](app/app/Services/Supplier/SupplierPortalClient.php#L24-L28):
|
||||
Заменить placeholder-докблок [SupplierPortalClient.php:24-28](../../../app/app/Services/Supplier/SupplierPortalClient.php#L24-L28):
|
||||
|
||||
```php
|
||||
/**
|
||||
@@ -124,10 +124,10 @@ cd app && composer test -- --parallel
|
||||
|
||||
Если recon показал, что endpoints/payload-shape ОТЛИЧАЕТСЯ от placeholder:
|
||||
|
||||
- В `request()` вызовах ([SupplierPortalClient.php:105](app/app/Services/Supplier/SupplierPortalClient.php#L105), [.php:112](app/app/Services/Supplier/SupplierPortalClient.php#L112), [.php:120](app/app/Services/Supplier/SupplierPortalClient.php#L120), [.php:98](app/app/Services/Supplier/SupplierPortalClient.php#L98)) заменить пути на реальные.
|
||||
- В `toPayload()` ([.php:346-358](app/app/Services/Supplier/SupplierPortalClient.php#L346-L358)) заменить ключи/формат полей на реальные.
|
||||
- Если ответ JSON структурно отличается (`id` не в корне) — поправить путь извлечения в `saveProject()` ([.php:107](app/app/Services/Supplier/SupplierPortalClient.php#L107)).
|
||||
- Если запрос JSON, а не form-urlencoded — добавить `asJson` параметр (паттерн уже есть для `save-report`, [.php:237-262](app/app/Services/Supplier/SupplierPortalClient.php#L237-L262)).
|
||||
- В `request()` вызовах ([SupplierPortalClient.php:105](../../../app/app/Services/Supplier/SupplierPortalClient.php#L105), [.php:112](../../../app/app/Services/Supplier/SupplierPortalClient.php#L112), [.php:120](../../../app/app/Services/Supplier/SupplierPortalClient.php#L120), [.php:98](../../../app/app/Services/Supplier/SupplierPortalClient.php#L98)) заменить пути на реальные.
|
||||
- В `toPayload()` ([.php:346-358](../../../app/app/Services/Supplier/SupplierPortalClient.php#L346-L358)) заменить ключи/формат полей на реальные.
|
||||
- Если ответ JSON структурно отличается (`id` не в корне) — поправить путь извлечения в `saveProject()` ([.php:107](../../../app/app/Services/Supplier/SupplierPortalClient.php#L107)).
|
||||
- Если запрос JSON, а не form-urlencoded — добавить `asJson` параметр (паттерн уже есть для `save-report`, [.php:237-262](../../../app/app/Services/Supplier/SupplierPortalClient.php#L237-L262)).
|
||||
|
||||
Если контракт ИДЕНТИЧЕН placeholder — никаких правок кода, только убрать «placeholder» из докблока.
|
||||
|
||||
@@ -1164,7 +1164,7 @@ cd "C:/моя/проекты/портал crm/Документация"
|
||||
grep -n "alertType\|details" app/app/Mail/SupplierCriticalAlertMail.php
|
||||
```
|
||||
|
||||
Expected: constructor берёт `alertType` и `details` (паттерн из [SyncSupplierProjectsJob.php:88-103](app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L88-L103)). Если конструктор другой — адаптировать вызовы в Step 4.5.
|
||||
Expected: constructor берёт `alertType` и `details` (паттерн из [SyncSupplierProjectsJob.php:88-103](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L88-L103)). Если конструктор другой — адаптировать вызовы в Step 4.5.
|
||||
|
||||
- [ ] **Step 4.7: Run test — PASS.**
|
||||
|
||||
@@ -1909,7 +1909,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
|
||||
В `app/app/Jobs/SyncSupplierProjectJob.php` заменить `handle()`:
|
||||
|
||||
Было ([SyncSupplierProjectJob.php:42-62](app/app/Jobs/SyncSupplierProjectJob.php#L42-L62)):
|
||||
Было ([SyncSupplierProjectJob.php:42-62](../../../app/app/Jobs/SyncSupplierProjectJob.php#L42-L62)):
|
||||
|
||||
```php
|
||||
public function handle(SupplierPortalClient $client): void
|
||||
@@ -1970,7 +1970,7 @@ private function buildDto(Project $project, string $platform, string $uniqueKey)
|
||||
|
||||
- [ ] **Step 8.2: Update `SyncSupplierProjectsJob` (plural).**
|
||||
|
||||
В `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` ([SyncSupplierProjectsJob.php:66](app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L66)):
|
||||
В `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` ([SyncSupplierProjectsJob.php:66](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L66)):
|
||||
|
||||
Было:
|
||||
|
||||
@@ -1990,9 +1990,9 @@ public function handle(?\App\Services\Supplier\Channel\SupplierProjectChannel $c
|
||||
|
||||
Добавить свойство `private \App\Services\Supplier\Channel\SupplierProjectChannel $channel;`.
|
||||
|
||||
В `syncOne()` ([SyncSupplierProjectsJob.php:118-185](app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L118-L185)) заменить прямые вызовы на channel:
|
||||
В `syncOne()` ([SyncSupplierProjectsJob.php:118-185](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L118-L185)) заменить прямые вызовы на channel:
|
||||
|
||||
Было ([:159](app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L159), [:169](app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L169)):
|
||||
Было ([:159](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L159), [:169](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L169)):
|
||||
|
||||
```php
|
||||
$externalId = $client->saveProject($allocation);
|
||||
@@ -2020,7 +2020,7 @@ if ($isCreate) {
|
||||
}
|
||||
```
|
||||
|
||||
Добавить catch'и в основной цикл ([:87-114](app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L87-L114)):
|
||||
Добавить catch'и в основной цикл ([:87-114](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L87-L114)):
|
||||
|
||||
```php
|
||||
catch (\App\Services\Supplier\Channel\Exceptions\TierEscalatedException $e) {
|
||||
@@ -2077,7 +2077,7 @@ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/routes/console.php` ([:45-50](app/routes/console.php#L45-L50)).
|
||||
- Modify: `app/routes/console.php` ([:45-50](../../../app/routes/console.php#L45-L50)).
|
||||
- Create: `app/tests/Feature/Schedule/SupplierScheduleTest.php`.
|
||||
|
||||
- [ ] **Step 9.1: Write failing test.**
|
||||
@@ -2130,7 +2130,7 @@ Expected: FAIL — текущее расписание 20:30 / 20:15.
|
||||
|
||||
- [ ] **Step 9.3: Edit `app/routes/console.php`.**
|
||||
|
||||
Заменить ([routes/console.php:45-50](app/routes/console.php#L45-L50)):
|
||||
Заменить ([routes/console.php:45-50](../../../app/routes/console.php#L45-L50)):
|
||||
|
||||
```php
|
||||
Schedule::job(new RefreshSupplierSessionJob)
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
# Карта узлов iter9 — подсистема brain governance — 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:** Синхронизировать «карту узлов» (`docs/automation-graph.html` + `docs/automation-graph-data.js`) с подсистемой brain governance (ADR-011, 19.05.2026): +9 узлов, +12 рёбер, +1 GREEN-конфликт, +9 паспортов/edge-details/NODE_META, версии-метки ×4 — iter9.
|
||||
|
||||
**Architecture:** Подход A (subsystem-level) — новые узлы в их естественных функциональных группах. data.js — общий: правка кормит и `docs/observer/dashboard.html`. Верификация — рендером (нет unit-тестов у статической data-viz).
|
||||
|
||||
**Tech Stack:** vis-network 9.x, классический `<script>` (без ES-модулей в data.js), Node (sanity `node --check`), Playwright MCP (рендер-верификация), lefthook pre-commit.
|
||||
|
||||
**Спек:** `docs/superpowers/specs/2026-05-20-automation-map-iter9-brain-governance-design.md`
|
||||
**Ветка:** `feat/automation-map-iter9` (уже создана; на ней лежит спек).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **`docs/automation-graph-data.js`** (общий источник топологии): `NODES` (+9), версии-метки ×4, `EDGES` (+12), `CONFLICT` (+1 GREEN), `NODE_SECTION` (+9), count-комментарии, фикс шапки (строка 5).
|
||||
- **`docs/automation-graph.html`** (паспорта/мета/детали): `NODE_DETAILS` (+9), `EDGE_DETAILS` (+12), `NODE_META` (+9 + bump `META_SNAPSHOT`/`META_WINDOW` + `changed` ×4).
|
||||
- НЕ трогаем: `docs/observer/dashboard.html` (кормится из data.js), нормативку, `GROUPS`/`CATEGORY_LABELS` (новых групп нет).
|
||||
|
||||
**9 новых узлов** (id → group → ring·angle): `router_procedure`→rules·1·210; `observer_stophook`→hooks·4·205; `sk_brain_retro`→skills_proj·3·210; `observer_evidence`→memory·6·204; `lh_l1watcher`→lefthook·5·150; `lh_crossref`→lefthook·5·157; `lh_obs_obs`→lefthook·5·164; `lh_status_md`→lefthook·5·171; `lh_obs_cov`→lefthook·5·178.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: data.js — NODES (+9), версии-метки (×4), count-комментарии, фикс шапки
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph-data.js`
|
||||
|
||||
- [ ] **Step 1: Фикс шапки (строка 5) — устаревшее имя дашборда**
|
||||
|
||||
Заменить:
|
||||
|
||||
```js
|
||||
// • docs/brain-dashboard.html (classic <script>, same mechanism)
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```js
|
||||
// • docs/observer/dashboard.html (classic <script>, same mechanism)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Версии-метки 4 узлов-правил**
|
||||
|
||||
В блоке `// ── ПРАВИЛА (4) ──` заменить лейблы:
|
||||
|
||||
- `label: 'Pravila v1.29'` → `label: 'Pravila v1.33'`
|
||||
- `label: 'CLAUDE.md v2.16'` → `label: 'CLAUDE.md v2.20'`
|
||||
- `label: 'PSR_v1 v3.14'` → `label: 'PSR_v1 v3.17'`
|
||||
- `label: 'Tooling v2.15'` → `label: 'Tooling v2.17'`
|
||||
|
||||
- [ ] **Step 3: +router_procedure (rules)**
|
||||
|
||||
Заменить заголовок блока `// ── ПРАВИЛА (4) ── центр + первое кольцо ───────` → `// ── ПРАВИЛА (5) ── центр + первое кольцо ───────`.
|
||||
После строки `tooling` (`{ id: 'tooling', ... ...pos(1, 270) },`) добавить:
|
||||
|
||||
```js
|
||||
{ id: 'router_procedure', label: 'router-procedure v1.0', group: 'rules', size: 24, ring: 1, ...pos(1, 210) },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: +observer_stophook (hooks)**
|
||||
|
||||
Заменить заголовок `// ── ХУКИ (12) — S+infra + E (economy/skill) ───` → `// ── ХУКИ (13) — S+infra + E (economy/skill/brain) ───`.
|
||||
После строки `hk_ruflo_queen` добавить:
|
||||
|
||||
```js
|
||||
{ id: 'observer_stophook', label: 'Stop:\nobserver-stop-hook', group: 'hooks', size: 22, ring: 4, ...pos(4, 205) },
|
||||
```
|
||||
|
||||
- [ ] **Step 5: +sk_brain_retro (skills_proj)**
|
||||
|
||||
После строки `discovery_interview` (`{ id: 'discovery_interview', ... ...pos(3, 387) },`) добавить:
|
||||
|
||||
```js
|
||||
// brain governance iter9 (19.05.2026) — проектный скил факторного анализа
|
||||
{ id: 'sk_brain_retro', label: '/brain-retro\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 210) },
|
||||
```
|
||||
|
||||
- [ ] **Step 6: +observer_evidence (memory)**
|
||||
|
||||
Заменить заголовок `// ── MEMORY FILES (23) — внешнее кольцо ──────────` → `// ── MEMORY FILES (24) — внешнее кольцо ──────────`.
|
||||
После строки `mem_sprint3` (`{ id: 'mem_sprint3', ... ...pos(6, 180) },`) добавить:
|
||||
|
||||
```js
|
||||
// brain governance iter9 (19.05.2026) — хранилище evidence «мозга»
|
||||
{ id: 'observer_evidence', label: 'docs/observer/\nepisodes+STATUS', group: 'memory', size: 16, ring: 6, ...pos(6, 204) },
|
||||
```
|
||||
|
||||
- [ ] **Step 7: +5 контролёров (lefthook)**
|
||||
|
||||
Заменить заголовок `// ── LEFTHOOK JOBS (10) — S+W (infra/data) ─────` → `// ── LEFTHOOK JOBS (15) — S+W (infra/data/brain) ─────`.
|
||||
После строки `lh_squawk` (`{ id: 'lh_squawk', ... ...pos(5, 320) },`) добавить:
|
||||
|
||||
```js
|
||||
// brain governance iter9 (19.05.2026) — 5 контролёров C1-C5 (lefthook jobs 11-15)
|
||||
{ id: 'lh_l1watcher', label: 'lefthook:\nl1-watcher (C1)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 150) },
|
||||
{ id: 'lh_crossref', label: 'lefthook:\ncross-ref-checker (C2)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 157) },
|
||||
{ id: 'lh_obs_obs', label: 'lefthook:\nobserver-of-observer (C3)',group: 'lefthook', size: 16, ring: 5, ...pos(5, 164) },
|
||||
{ id: 'lh_status_md', label: 'lefthook:\nstatus-md (C4)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 171) },
|
||||
{ id: 'lh_obs_cov', label: 'lefthook:\nobserver-coverage (C5)', group: 'lefthook', size: 16, ring: 5, ...pos(5, 178) },
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Sanity — синтаксис data.js**
|
||||
|
||||
Run: `cd "c:/моя/проекты/портал crm/Документация" && node --check docs/automation-graph-data.js && echo OK`
|
||||
Expected: `OK` (нет SyntaxError).
|
||||
|
||||
---
|
||||
|
||||
### Task 2: data.js — EDGES (+12) + CONFLICT (+1 GREEN)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph-data.js`
|
||||
|
||||
- [ ] **Step 1: +12 рёбер**
|
||||
|
||||
Перед строкой-комментарием `// ══════════════════════════════════════════════════` (которая начинает блок `// КОНФЛИКТЫ — 3-color classification (iter2 §4)`) добавить:
|
||||
|
||||
```js
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) — связи 9 новых узлов ──
|
||||
E('claude_md', 'router_procedure', '§3.6: SoT\nпроцедуры роутера'),
|
||||
E('tooling', 'router_procedure', '§4.X реестр →\nшаг 3 роутера'),
|
||||
E('pravila', 'router_procedure', '§12/§14/§15\nhard-floor'),
|
||||
E('pravila', 'observer_stophook', '§16: observer\n+ routing-тег'),
|
||||
E('observer_stophook', 'observer_evidence', 'пишет эпизоды\n+ routing-gate'),
|
||||
E('pravila', 'sk_brain_retro', '§16: факторный\nанализ раз в спринт'),
|
||||
E('sk_brain_retro', 'observer_evidence', 'читает эпизоды\n(факторный анализ)'),
|
||||
E('lh_l1watcher', 'tooling', 'C1 STRICT: settings.json\n↔ Tooling drift'),
|
||||
E('lh_crossref', 'claude_md', 'C2 STRICT: version\ndrift §0 cross-refs'),
|
||||
E('lh_obs_obs', 'observer_evidence', 'C3 warn: счётчик\n+54w self-prune'),
|
||||
E('lh_status_md', 'observer_evidence', 'C4: генерит\nSTATUS.md'),
|
||||
E('lh_obs_cov', 'observer_evidence', 'C5 warn: покрытие\n+ регистрация'),
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 2: +1 GREEN-конфликт**
|
||||
|
||||
После строки `CONFLICT('hk_economy', 'superpowers', '§12 — hard-rule уровня 0; economy-режим §12 не отменяет (Pravila §12.4)', 'GREEN'),` добавить:
|
||||
|
||||
```js
|
||||
CONFLICT('observer_stophook', 'hk_verifier', 'HK1 §5.3: оба на Stop-event — коллизии нет (append-chain). Оба способны decision:block; Claude Code прогоняет все Stop-хуки, любой block ⇒ продолжение хода. observer-gate детерминированный и дешёвый.', 'GREEN'),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Sanity**
|
||||
|
||||
Run: `cd "c:/моя/проекты/портал crm/Документация" && node --check docs/automation-graph-data.js && echo OK`
|
||||
Expected: `OK`.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: data.js — NODE_SECTION (+9) + coverage-комментарий
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph-data.js`
|
||||
|
||||
- [ ] **Step 1: Обновить coverage-комментарий**
|
||||
|
||||
Заменить `// Узел -> раздел. Покрывает все 125 узлов карты.` → `// Узел -> раздел. Покрывает все 134 узла карты.`
|
||||
|
||||
- [ ] **Step 2: +9 записей NODE_SECTION**
|
||||
|
||||
После строки `discovery_interview: 'E5',` (последняя перед закрывающей `};` объекта `NODE_SECTION`) добавить:
|
||||
|
||||
```js
|
||||
// brain governance iter9 19.05.2026 — ADR-011 подсистема
|
||||
router_procedure: 'E1', observer_stophook: 'E2', sk_brain_retro: 'E8', observer_evidence: 'E4',
|
||||
lh_l1watcher: 'E1', lh_crossref: 'E1', lh_obs_obs: 'E2', lh_status_md: 'E2', lh_obs_cov: 'E2',
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Sanity**
|
||||
|
||||
Run: `cd "c:/моя/проекты/портал crm/Документация" && node --check docs/automation-graph-data.js && echo OK`
|
||||
Expected: `OK`.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: html — NODE_DETAILS (+9 паспортов)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph.html`
|
||||
|
||||
- [ ] **Step 1: Вставить 9 паспортов**
|
||||
|
||||
В объекте `NODE_DETAILS` (в `<script>` файла `automation-graph.html`) перед его закрывающей `};` (сразу после записи `mem_sprint3: nd(...)`) добавить блок:
|
||||
|
||||
```js
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
router_procedure: nd(
|
||||
'Единый источник истины процедуры роутера «задача → узел(ы)» — docs/router-procedure.md v1.0. 5 шагов: hard-floor (§12/§14/§15) → классификация → выбор по триггерам (Tooling Прил. Н §4.X) → проверка связок L1-L12 → исполнение. ADR-011.',
|
||||
'При любой задаче (имплицитно) определяет узел/связку; явно — при разборе routing-решений и в /brain-retro.',
|
||||
'Не вводит новый реестр — формализует процедуру над существующим (Tooling §4.X). Кэша «проверенных цепочек» нет (router-only). Каждая задача — свежая сборка пути.',
|
||||
[{ name: 'Pravila §12/§14/§15', cond: 'hard-floor — шаг 1 процедуры' }, { name: 'CLAUDE.md §3.6', cond: 'cross-ref на router-procedure.md' }],
|
||||
[{ name: 'Tooling Прил. Н §4.X', cond: 'реестр узлов — вход шага 3' }],
|
||||
[{ name: 'observer (Stop-хук)', cond: 'пишет evidence о routing-решениях' }, { name: '/brain-retro', cond: 'факторный анализ routing' }],
|
||||
[]
|
||||
),
|
||||
observer_stophook: nd(
|
||||
'Stop-хук observer (tools/observer-stop-hook.mjs, project-level) — пишет один JSONL-эпизод в docs/observer/episodes-YYYY-MM.jsonl в конце каждого хода + routing-gate. Внутри: transcript-parser (схема v2), routing-detector + choice-detector (provenance), pii-filter (маскирование ПДн). ADR-011 + observer factor-analysis.',
|
||||
'Конец каждого хода (Stop-event). routing-gate: при навязанном методе без routing-тега → decision:block (необойдёмо).',
|
||||
'Только пишет evidence, не вмешивается в нормативку. При внутреннем отказе — маркер observer_error, не тихий пропуск. HK1 §5.3: сосуществует с economy-verifier на Stop (append-chain).',
|
||||
[{ name: 'Pravila §16', cond: 'observer + routing-тег-дисциплина' }, { name: '.claude/settings.json', cond: 'зарегистрирован как Stop-хук' }],
|
||||
[{ name: 'observer-transcript-parser / routing-detector / choice-detector / pii-filter', cond: 'внутренние .mjs модули' }],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'пишет эпизоды' }, { name: '/brain-retro', cond: 'читает то, что хук пишет' }],
|
||||
[{ name: 'hk_verifier', desc: 'HK1 §5.3: оба на Stop-event — коллизии нет (append-chain), оба decision:block отрабатываются', type: 'GREEN' }]
|
||||
),
|
||||
sk_brain_retro: nd(
|
||||
'Проектный скил /brain-retro (.claude/skills/brain-retro/) — раз в спринт читает docs/observer/episodes-*.jsonl и строит факторный анализ: распределение path_type, топ-узлы/связки, вывод исхода, факторная матрица (9 осей × outcome). Анализатор tools/brain-retro-analyzer.mjs.',
|
||||
'Раз в спринт по команде заказчика («брейн-ретро»). Read-only агрегатор.',
|
||||
'Только читает и предлагает кандидатов на корректировку нормативки — не пишет в логи, не правит Tooling/Pravila/PSR_v1. Решение по правкам — за заказчиком.',
|
||||
[{ name: 'Pravila §16', cond: 'evidence-loop, раз в спринт' }, { name: 'PSR_v1 R16', cond: 'brain evidence loop' }],
|
||||
[{ name: 'tools/brain-retro-analyzer.mjs', cond: 'детерминированный анализатор' }],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'читает эпизоды' }],
|
||||
[]
|
||||
),
|
||||
observer_evidence: nd(
|
||||
'Хранилище evidence «мозга» — docs/observer/: помесячные episodes-YYYY-MM.jsonl (схема v2), STATUS.md (панель C1-C5), .read-counter.json (для C3), notes/. Визуализируется страницей docs/observer/dashboard.html (Карта/Лента/Разбор/Агрегат/конфликты; кормится из общего automation-graph-data.js).',
|
||||
'Пишется Stop-хуком (эпизоды) + контролёрами (STATUS.md, счётчик); читается /brain-retro и dashboard.',
|
||||
'ПДн маскируется pii-filter перед записью (§5.4). Помесячное rotation; архив после 12 месяцев. Память ruflo (.swarm/memory.db) — отдельное хранилище, не связано.',
|
||||
[{ name: 'observer Stop-хук', cond: 'источник эпизодов' }],
|
||||
[],
|
||||
[{ name: '/brain-retro', cond: 'читатель' }, { name: 'C3/C4/C5 контролёры', cond: 'счётчик / STATUS / покрытие' }],
|
||||
[]
|
||||
),
|
||||
lh_l1watcher: nd(
|
||||
'Контролёр C1 (lefthook pre-commit job 11, tools/l1-watcher.mjs) — детектор «плагин включён в settings.json без формализации в Tooling Прил. Н». Закрывает трижды повторившийся L1-паттерн (UPM/21st, Sentry/Redis, Anthropic dev-tooling). 0 LLM-вызовов.',
|
||||
'pre-commit при правке .claude/settings.json или docs/Tooling_v8_3.md.',
|
||||
'STRICT: блокирует коммит при drift. Групповые/human-имена разрешаются через tools/.l1-watcher-aliases.txt. ADR-011 spec §6.1.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 11 pre-commit' }, { name: 'ADR-011 §6.1', cond: 'C1' }],
|
||||
[],
|
||||
[{ name: 'tooling', cond: 'сверяет settings.json ↔ Tooling' }, { name: 'C2 cross-ref', cond: 'оба — нормативная консистентность' }],
|
||||
[]
|
||||
),
|
||||
lh_crossref: nd(
|
||||
'Контролёр C2 (lefthook pre-commit job 12, tools/cross-ref-checker.mjs) — детектор version drift между нормативными файлами (Tooling v2.11 collision 17.05). Сверяет версии в §0 cross-refs vs шапки целевых файлов. 0 LLM-вызовов.',
|
||||
'pre-commit при правке Pravila / Tooling / PSR_v1 / CLAUDE.md / MEMORY.md.',
|
||||
'STRICT: блокирует коммит при расхождении версии. Link-anchored детекция + scope-cut по history-маркерам (исторические «наследие»-цепочки не дают ложных срабатываний). ADR-011 spec §6.2.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 12 pre-commit' }, { name: 'ADR-011 §6.2', cond: 'C2' }],
|
||||
[],
|
||||
[{ name: 'claude_md / pravila / tooling / psr_v1', cond: 'сверяет 5 нормативных файлов' }, { name: 'C1 l1-watcher', cond: 'оба — нормативная консистентность' }],
|
||||
[]
|
||||
),
|
||||
lh_obs_obs: nd(
|
||||
'Контролёр C3 (lefthook pre-commit job 13, tools/observer-of-observer.mjs) — счётчик чтений docs/observer/ + 54-недельный self-prune. «Кто наблюдает за наблюдателями»: если evidence-loop не читается ≥54 недель — предлагает архивировать observer.',
|
||||
'pre-commit (каждый коммит) — обновляет/проверяет docs/observer/.read-counter.json.',
|
||||
'Warn-only (скрипт всегда exit 0) — не блокирует. 54 недели (≈год) — порог осознанно поднят заказчиком с 4 недель. ADR-011 spec §6.3.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 13 pre-commit' }, { name: 'ADR-011 §6.3', cond: 'C3' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'читает .read-counter.json' }],
|
||||
[]
|
||||
),
|
||||
lh_status_md: nd(
|
||||
'Контролёр C4 (lefthook post-commit job, tools/status-md-generator.mjs) — генерит docs/observer/STATUS.md (панель: C1-C5 + информационные метрики). Pure JS, Security Guidance #40 compliant.',
|
||||
'post-commit (после каждого коммита) — перегенерит STATUS.md, git add (для следующего коммита).',
|
||||
'Через `|| true` — не блокирует. Метрика «N раз использован» — информационная, не алерт (capability-readiness). ADR-011 spec §6.4.',
|
||||
[{ name: 'lefthook.yml', cond: 'post-commit job' }, { name: 'ADR-011 §6.4', cond: 'C4' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'пишет STATUS.md' }, { name: 'C1/C2/C3', cond: 'агрегирует их сигнал' }],
|
||||
[]
|
||||
),
|
||||
lh_obs_cov: nd(
|
||||
'Контролёр C5 (lefthook pre-commit job 15, tools/observer-coverage-checker.mjs) — observer factor-analysis spec §5.2. Флагует пропуски покрытия (git-активность есть, эпизодов 0) + поломки регистрации (Stop-хук снят из settings.json, post-commit не установлен).',
|
||||
'pre-commit (каждый коммит).',
|
||||
'Warn-only (скрипт всегда exit 0) — не блокирует; находки в docs/observer/STATUS.md строка C5.',
|
||||
[{ name: 'lefthook.yml', cond: 'job 15 pre-commit' }, { name: 'observer factor-analysis §5.2', cond: 'C5' }],
|
||||
[],
|
||||
[{ name: 'docs/observer/ evidence', cond: 'проверяет покрытие + регистрацию' }, { name: 'C4 status-md', cond: 'находки в STATUS.md' }],
|
||||
[]
|
||||
),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: html — EDGE_DETAILS (+12)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph.html`
|
||||
|
||||
- [ ] **Step 1: Вставить 12 edge-details**
|
||||
|
||||
В объекте `EDGE_DETAILS` перед его закрывающей `};` (после последней ruflo-записи `'ruflo_daemon->ag_pest': { ... }`) добавить:
|
||||
|
||||
```js
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
'claude_md->router_procedure': { type: 'документирует', when: 'CLAUDE.md §3.6 — cross-ref на router-procedure.md v1.0', transfers: 'документация', mandatory: 'обязательно', rule: 'CLAUDE.md §3.6 (single SoT routing procedure)' },
|
||||
'tooling->router_procedure': { type: 'питает', when: 'реестр Прил. Н §4.X — вход шага 3 процедуры роутера', transfers: 'данные', mandatory: 'обязательно', rule: 'router-procedure.md §4.2 шаг 3' },
|
||||
'pravila->router_procedure': { type: 'подчиняет', when: 'hard-floor §12/§14/§15 — шаг 1 процедуры роутера', transfers: 'контроль', mandatory: 'hard-floor', rule: 'router-procedure.md §4.2 шаг 1 (Pravila §12/§14/§15)' },
|
||||
'pravila->observer_stophook': { type: 'подчиняет', when: '§16: observer + routing-тег-дисциплина', transfers: 'контроль', mandatory: 'обязательно', rule: 'Pravila §16.2/§16.7 (ADR-011)' },
|
||||
'observer_stophook->observer_evidence': { type: 'пишет', when: 'конец каждого хода (Stop-event)', transfers: 'данные (эпизод JSONL)', mandatory: 'обязательно (exit-0-safe)', rule: 'ADR-011 §5.2 (observer scope B)' },
|
||||
'pravila->sk_brain_retro': { type: 'подчиняет', when: '§16: факторный анализ раз в спринт', transfers: 'контроль', mandatory: 'по команде заказчика', rule: 'Pravila §16 + PSR_v1 R16' },
|
||||
'sk_brain_retro->observer_evidence': { type: 'читает', when: 'раз в спринт — агрегирует эпизоды', transfers: 'данные', mandatory: 'read-only', rule: 'ADR-011 §5.5 (/brain-retro — читатель)' },
|
||||
'lh_l1watcher->tooling': { type: 'проверяет', when: 'pre-commit при правке settings.json / Tooling', transfers: 'проверка', mandatory: 'STRICT (блокирует)', rule: 'ADR-011 §6.1 (C1) + lefthook.yml job 11' },
|
||||
'lh_crossref->claude_md': { type: 'проверяет', when: 'pre-commit при правке любого из 5 нормативных файлов', transfers: 'проверка', mandatory: 'STRICT (блокирует)', rule: 'ADR-011 §6.2 (C2) + lefthook.yml job 12' },
|
||||
'lh_obs_obs->observer_evidence': { type: 'проверяет', when: 'pre-commit (каждый коммит) — счётчик чтений', transfers: 'проверка', mandatory: 'warn-only', rule: 'ADR-011 §6.3 (C3) + lefthook.yml job 13' },
|
||||
'lh_status_md->observer_evidence': { type: 'пишет', when: 'post-commit — перегенерит STATUS.md', transfers: 'данные', mandatory: 'не блокирует (|| true)', rule: 'ADR-011 §6.4 (C4) + lefthook.yml post-commit' },
|
||||
'lh_obs_cov->observer_evidence': { type: 'проверяет', when: 'pre-commit (каждый коммит) — покрытие + регистрация', transfers: 'проверка', mandatory: 'warn-only', rule: 'observer factor-analysis §5.2 (C5) + lefthook.yml job 15' },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: html — NODE_META (+9 + bump снимка/окна + changed ×4)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph.html`
|
||||
|
||||
- [ ] **Step 1: Bump снимка и окна**
|
||||
|
||||
- `const META_SNAPSHOT = '18.05.2026';` → `const META_SNAPSHOT = '20.05.2026';`
|
||||
- `const META_WINDOW = '09–18.05.2026';` → `const META_WINDOW = '09–20.05.2026';`
|
||||
|
||||
- [ ] **Step 2: changed ×4 на узлах-правилах**
|
||||
|
||||
В блоке `// ── ПРАВИЛА (4) ──` объекта `NODE_META` заменить `changed: '18.05.2026'` → `changed: '19.05.2026'` у `pravila`, `claude_md`, `psr_v1`, `tooling` (4 строки; остальные поля без изменений).
|
||||
|
||||
- [ ] **Step 3: +9 записей NODE_META**
|
||||
|
||||
Перед закрывающей `};` объекта `NODE_META` (после блока discovery-tooling `discovery_interview: { ... }`) добавить:
|
||||
|
||||
```js
|
||||
// ── BRAIN GOVERNANCE iter9 (19.05.2026, ADR-011) ──
|
||||
// uses: observer_stophook=31 эпизодов; lh_obs_obs/status_md/obs_cov=112 коммитов с 19.05
|
||||
// (glob-less, каждый коммит); lh_l1watcher=10, lh_crossref=13 (коммиты по glob с 19.05);
|
||||
// observer_evidence=0 (.read-counter.json — 0 чтений); router_procedure=null (rule-like).
|
||||
router_procedure: { since: '19.05.2026', changed: '—', uses: null, usesSrc: '—' },
|
||||
observer_stophook: { since: '19.05.2026', changed: '—', uses: 31, usesSrc: 'хук (эпизоды)' },
|
||||
sk_brain_retro: { since: '19.05.2026', changed: '—', uses: 1, usesSrc: 'интеграция' },
|
||||
observer_evidence: { since: '19.05.2026', changed: '—', uses: 0, usesSrc: 'observer counter' },
|
||||
lh_l1watcher: { since: '19.05.2026', changed: '—', uses: 10, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_crossref: { since: '19.05.2026', changed: '—', uses: 13, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_obs_obs: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_status_md: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
lh_obs_cov: { since: '19.05.2026', changed: '—', uses: 112, usesSrc: 'коммиты (с 19.05)' },
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Верификация (рендер + структурная сверка)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read-only проверки + 1 PNG-артефакт.
|
||||
|
||||
- [ ] **Step 1: Синтаксис data.js**
|
||||
|
||||
Run: `cd "c:/моя/проекты/портал crm/Документация" && node --check docs/automation-graph-data.js && echo OK`
|
||||
Expected: `OK`.
|
||||
|
||||
- [ ] **Step 2: Структурная сверка счётчиков (без исполнения data.js — только regex по тексту)**
|
||||
|
||||
Run (число определений узлов = вхождения `{ id: '` в тексте файла; ожидаем 134):
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация"
|
||||
node -e "const s=require('fs').readFileSync('docs/automation-graph-data.js','utf8');console.log('node defs:',(s.match(/\{ id: '/g)||[]).length);"
|
||||
```
|
||||
|
||||
Expected: `node defs: 134`. Полнота покрытия NODE_SECTION гарантируется Task 3 (добавлены все 9 id) и подтверждается рендером Step 3 (паспорт показывает раздел). `vm`/`eval` не используем — Security Guidance #40.
|
||||
|
||||
- [ ] **Step 3: Рендер карты — Playwright MCP**
|
||||
|
||||
`browser_navigate` на `file:///c:/моя/проекты/портал crm/Документация/docs/automation-graph.html` → `browser_console_messages` (0 errors) → `browser_snapshot`. Проверить: граф отрисован; поиск «observer-stop-hook», «brain-retro», «router-procedure», «l1-watcher» находит узлы; клик по `observer_stophook` открывает паспорт с GREEN-конфликтом; клик по ребру `observer_stophook→observer_evidence` открывает edge-details. Визуально убедиться в отсутствии наложений 9 новых узлов на соседей.
|
||||
|
||||
- [ ] **Step 4: Рендер дашборда (тот же data.js)**
|
||||
|
||||
`browser_navigate` на `file:///c:/моя/проекты/портал crm/Документация/docs/observer/dashboard.html` → `browser_console_messages` (0 errors) → его Карта-view содержит новые узлы (кормится из общего AGD). Если dashboard падает из file:// (относительные пути / fetch) — зафиксировать как ограничение, не блокер: data.js валиден (Step 1-2).
|
||||
|
||||
- [ ] **Step 5: Smoke-PNG**
|
||||
|
||||
`browser_take_screenshot` карты → сохранить как `automation-graph-smoke.png` в корне (перезаписать существующий).
|
||||
|
||||
- [ ] **Step 6: Финальный коммит (один логический change = iter9)**
|
||||
|
||||
Перед коммитом pre-commit прогонится автоматически (stylelint на .html — CSS не трогали; gitleaks protect --staged; markdownlint/cspell — .md не затронут; cross-ref-checker/l1-watcher — glob не на эти файлы). Стейджить только 3 файла явными путями:
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация"
|
||||
git add docs/automation-graph-data.js docs/automation-graph.html automation-graph-smoke.png
|
||||
git commit -m "feat(map): iter9 — brain governance subsystem (+9 nodes, +12 edges, +1 GREEN)" -m "Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" -- docs/automation-graph-data.js docs/automation-graph.html automation-graph-smoke.png
|
||||
```
|
||||
|
||||
Expected: pre-commit зелёный; коммит создан на `feat/automation-map-iter9`.
|
||||
|
||||
- [ ] **Step 7: verification-before-completion**
|
||||
|
||||
Invoke `superpowers:verification-before-completion` — подтвердить фактическим выводом: node --check OK, node defs=134, рендер без console-errors + 4 узла находятся + паспорт/edge открываются, коммит создан. Только после этого — claim «iter9 готов».
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (выполнено при написании плана)
|
||||
|
||||
**1. Spec coverage:** §3 (9 узлов) → Task 1; §4 (версии-метки + NODE_META) → Task 1 Step 2 + Task 6; §5 (12 рёбер) → Task 2 Step 1; §6 (1 GREEN) → Task 2 Step 2; §7 (секции+счётчики) → Task 3 + count-комментарии в Task 1; §8 паспорта/детали/верификация → Tasks 4/5/7; §9 вне scope (нормативка не трогается) — соблюдено; §10 файлы → File Structure. Пробелов нет.
|
||||
|
||||
**2. Placeholder scan:** все uses-числа конкретны (31/112/10/13/0/null/1); паспорта и edge-details — полный текст; команды точные. Нет TBD/TODO. eval() убран (Security Guidance #40).
|
||||
|
||||
**3. Type consistency:** id узлов в NODES (Task 1) ↔ NODE_SECTION (Task 3) ↔ NODE_DETAILS (Task 4) ↔ NODE_META (Task 6) ↔ EDGES from/to (Task 2) ↔ EDGE_DETAILS ключи `from->to` (Task 5) — сверены, совпадают (router_procedure, observer_stophook, sk_brain_retro, observer_evidence, lh_l1watcher, lh_crossref, lh_obs_obs, lh_status_md, lh_obs_cov). GREEN-конфликт `observer_stophook↔hk_verifier` — `hk_verifier` существует в карте (узел economy-verifier).
|
||||
@@ -0,0 +1,880 @@
|
||||
# Finance-tooling (C6+C7) 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:** Наполнить разделы карты C6 «Финансы — биллинг и тарификация» и C7 «Финансы — бухгалтерия и налоги» dev-тулчейном (3 новых узла + reuse-классификация) и довести расширенную нормативку (включая роутер и наблюдатель).
|
||||
|
||||
**Architecture:** Гибрид с полным покрытием. Reuse-классификация ~11 существующих узлов в C6/C7 через `NODE_SECTION_SECONDARY` + 1 marketplace-плагин (`finance`, homed C7) + 2 self-authored project-скила (`billing-audit` для C6, `ru-tax-accounting` для C7). Нормативка по паттерну прошлых интеграций (Tooling/PSR_v1/Pravila/CLAUDE.md + ADR), расширенная роутером (`routing-off-phase.md`, `router-procedure.md`) и наблюдателем (9-атрибутные блоки + контролёры C1/C2).
|
||||
|
||||
**Tech Stack:** Markdown (скилы/нормативка/ADR), JS (`automation-graph-data.js` + `automation-graph.html`), JSON (`evals.json`), lefthook (markdownlint/cspell/gitleaks + brain-контролёры).
|
||||
|
||||
**Worktree:** `worktree-finance-tooling-c6-c7` (база origin/main `7df4786`). Спека: `docs/superpowers/specs/2026-05-20-finance-tooling-c6-c7-design.md` (commit `d984165`).
|
||||
|
||||
**Дисциплина (Pravila §15):** коммиты только явными путями (`git commit -m "..." -- <пути>`); pre-flight `git fetch && git log HEAD..origin/main --oneline` перед правкой каждого из нормативных файлов; субагенты+git — Sonnet/Opus, верификация commit-базы после каждого.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Создаём/Меняем | Ответственность |
|
||||
|---|---|---|
|
||||
| `.claude/skills/billing-audit/SKILL.md` | Create | C6-скил: денежные инварианты Лидерры |
|
||||
| `.claude/skills/billing-audit/references/invariants.md` | Create | Чек-лист инвариантов + ссылки на биллинг-файлы |
|
||||
| `.claude/skills/billing-audit/evals/evals.json` | Create | Триггер-eval (positive + near-miss) |
|
||||
| `.claude/skills/ru-tax-accounting/SKILL.md` | Create | C7-скил: РСБУ/НК РФ контекст |
|
||||
| `.claude/skills/ru-tax-accounting/references/ru-tax-context.md` | Create | Налоговые режимы + маппинг выручка→база |
|
||||
| `.claude/skills/ru-tax-accounting/evals/evals.json` | Create | Триггер-eval |
|
||||
| `~/.claude/settings.json` | Modify (user-level, не коммит) | enable `finance` plugin |
|
||||
| `docs/adr/012-finance-tooling.md` | Create | Граница C6/C7, applicability, DEFERRED |
|
||||
| `docs/Tooling_v8_3.md` | Modify | §4.36/§4.37/§4.38 + §0 счётчик 60→63 + header |
|
||||
| `docs/Plugin_stack_rules_v1.md` | Modify | R10.1 Блок 1 (+finance) + note (+2 скила) + version |
|
||||
| `docs/Pravila_raboty_Claude_v1_1.md` | Modify | §13.2 +абзац finance-tooling + §10 changelog + version |
|
||||
| `docs/routing-off-phase.md` | Modify | +3 строки таблицы + L13 + scope + version |
|
||||
| `docs/router-procedure.md` | Modify | changelog-touch v1.0→v1.1 |
|
||||
| `CLAUDE.md` | Modify (прямой Edit, worktree-эксцепшн §5 п.10) | §3.3 +#61/#62/#63 + §0 + §6 + §9 + шапка |
|
||||
| `docs/automation-graph-data.js` | Modify | +3 NODES + NODE_SECTION + NODE_SECTION_SECONDARY + EDGES |
|
||||
| `docs/automation-graph.html` | Modify | +3 NODE_DETAILS `nd()` + NODE_META + версии-метки |
|
||||
| `cspell-words.txt` | Modify | новые термины по мере появления cspell-флагов |
|
||||
|
||||
---
|
||||
|
||||
## ФАЗА 1 — C6: скил billing-audit
|
||||
|
||||
### Task 1: Скил `billing-audit` (C6)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.claude/skills/billing-audit/SKILL.md`
|
||||
- Create: `.claude/skills/billing-audit/references/invariants.md`
|
||||
- Create: `.claude/skills/billing-audit/evals/evals.json`
|
||||
|
||||
- [ ] **Step 1: Написать evals.json (триггер-кейсы первыми)**
|
||||
|
||||
Создать `.claude/skills/billing-audit/evals/evals.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"skill": "billing-audit",
|
||||
"positive": [
|
||||
"проверь корректность списания за лид",
|
||||
"аудит денежной логики биллинга",
|
||||
"не теряются ли копейки в prepaid→рублёвом балансе",
|
||||
"идемпотентно ли списание при ретрае",
|
||||
"правильно ли резолвится тарифная ступень",
|
||||
"что значит дрейф >5% в CsvReconcile",
|
||||
"проверь провенанс charge_source",
|
||||
"ревью PricingTierResolver на ошибки округления",
|
||||
"ledger двойной баланс — где может утечь сумма",
|
||||
"audit charge invariants before merge"
|
||||
],
|
||||
"near_miss": [
|
||||
{"prompt": "смоделируй BPMN процесса списания", "expect": "process-modeling #52"},
|
||||
{"prompt": "где узкое место в воронке оплат", "expect": "process-analysis #53"},
|
||||
{"prompt": "security-аудит платёжного эндпоинта", "expect": "D3 audit-security / Semgrep"},
|
||||
{"prompt": "посчитай РСБУ-проводки по выручке", "expect": "ru-tax-accounting #63"},
|
||||
{"prompt": "метрика MRR за месяц", "expect": "product-management metrics-review #42"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Написать SKILL.md**
|
||||
|
||||
Создать `.claude/skills/billing-audit/SKILL.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: billing-audit
|
||||
description: Аудит денежной корректности биллинг-кода Лидерры — money-инварианты при правке/ревью списаний, тарифов и баланса. Используй при «проверь списание», «аудит биллинга», «не теряются ли копейки», «идемпотентно ли списание», «корректна ли тарифная ступень», «что значит дрейф CsvReconcile», «провенанс charge_source». НЕ для моделирования процесса (process-modeling), поиска узких мест (process-analysis), security-аудита (D3), РСБУ/налогов (ru-tax-accounting), метрик выручки (product-management).
|
||||
---
|
||||
|
||||
# Billing Audit — аудит денежной корректности биллинга Лидерры
|
||||
|
||||
Проектный скил раздела C6 карты «Финансы — биллинг и тарификация». Проверяет
|
||||
**денежные инварианты** биллинг-подсистемы при правке или ревью кода. Объект —
|
||||
корректность *начисления* (не процесс, не безопасность, не учёт/налоги).
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- Правка/ревью кода в `app/app/Services/Billing/**`, `app/app/Jobs/Supplier/CsvReconcileJob.php`,
|
||||
моделей `PricingTier`/`LeadCharge`, контроллеров биллинга.
|
||||
- Вопрос «безопасно ли это денежно?» по списанию, тарифу, балансу, сверке.
|
||||
|
||||
## Процедура аудита (5 инвариантов)
|
||||
|
||||
Полный чек-лист с проверками и ссылками на файлы — `references/invariants.md`.
|
||||
|
||||
1. **Сохранение суммы** — все денежные операции через `bcmath` (bcadd/bcsub/bcmul/bcdiv,
|
||||
scale фиксирован), никаких float; prepaid→₽ конвертация без потери копеек.
|
||||
2. **Идемпотентность списания** — один лид = одно списание; повтор/ретрай джоба
|
||||
не дублирует начисление (проверить уникальный ключ / advisory-lock / upsert).
|
||||
3. **Корректность тарифной ступени** — `PricingTierResolver` выбирает верную из 7
|
||||
ступеней по объёму; границы ступеней (включительно/исключительно) однозначны.
|
||||
4. **Дрейф сверки** — `CsvReconcileJob` порог >5%: что сравнивается, что значит дрейф,
|
||||
куда смотреть (рассинхрон поставки vs ошибка тарифа).
|
||||
5. **Провенанс charge_source** — каждое списание имеет прослеживаемый источник
|
||||
(`charge_source`); ручные/авто/CSV-восстановленные различимы.
|
||||
|
||||
## Границы
|
||||
|
||||
- ≠ `process-modeling` #52 / `process-analysis` #53 — те про *поток/процесс*; billing-audit про *деньги в коде*.
|
||||
- ≠ D3 audit-security (#39/#40) — те про *безопасность*; billing-audit про *денежную корректность*.
|
||||
- ≠ `ru-tax-accounting` #63 — тот про *учёт/налоги* (выход биллинга → налоговая база); billing-audit про *начисление*.
|
||||
- ≠ `product-management:metrics-review` #42 — тот про *метрики выручки*; billing-audit про *корректность*.
|
||||
|
||||
## Связано
|
||||
|
||||
- Reuse: Boost #10 (модели), Pest #18 (тесты инвариантов), Larastan #12 (bcmath/без float), Sentry #34 / Redis #35 (runtime/очередь).
|
||||
- ADR-012 (граница finance-tooling C6/C7).
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Написать references/invariants.md**
|
||||
|
||||
Создать `.claude/skills/billing-audit/references/invariants.md`:
|
||||
|
||||
```markdown
|
||||
# Денежные инварианты биллинга Лидерры — чек-лист аудита
|
||||
|
||||
Объект-файлы (на момент 20.05.2026):
|
||||
|
||||
- `app/app/Services/Billing/PricingTierResolver.php` — резолюция 7 ступеней (pure).
|
||||
- `app/app/Services/Billing/LedgerService.php` — двойной баланс prepaid→₽ (bcmath).
|
||||
- `app/app/Services/Billing/BillingTopupService.php` — пополнение.
|
||||
- `app/app/Services/Billing/ChargeResult.php` — DTO результата списания.
|
||||
- `app/app/Models/PricingTier.php`, `app/app/Models/LeadCharge.php`.
|
||||
- `app/app/Repositories/PricingTierRepository.php`.
|
||||
- `app/app/Jobs/Supplier/CsvReconcileJob.php` — hourly сверка, алерт дрейфа >5%.
|
||||
- `app/app/Http/Controllers/Api/{AdminPricingTiersController,AdminBillingController,BillingController,TenantChargesController}.php`.
|
||||
|
||||
## I1. Сохранение суммы (bcmath, без float)
|
||||
|
||||
- [ ] Все арифметические операции с деньгами — `bcadd`/`bcsub`/`bcmul`/`bcdiv`/`bccomp` с явным `scale`.
|
||||
- [ ] Нет `+`/`-`/`*`/`/` над денежными значениями (Larastan/grep на float-арифметику в Billing).
|
||||
- [ ] prepaid→₽: конвертация округляет детерминированно (TRUNC/округление вниз в пользу tenant — свериться с кодом), сумма prepaid + ₽ не «исчезает».
|
||||
- [ ] Денежные колонки — целочисленные копейки или DECIMAL, не float/double.
|
||||
|
||||
## I2. Идемпотентность списания
|
||||
|
||||
- [ ] Один лид → одно списание: уникальность по (lead_id) или advisory-lock в `LedgerService`.
|
||||
- [ ] Ретрай `ImportLeadsJob`/`CsvReconcileJob` не создаёт дубль `lead_charges`.
|
||||
- [ ] Транзакция + `lockForUpdate` на балансе при мутации (TOCTOU — см. Sprint 3 lockForUpdate).
|
||||
|
||||
## I3. Корректность тарифной ступени
|
||||
|
||||
- [ ] `PricingTierResolver` выбирает ступень по объёму `delivered_in_month` верно на границах.
|
||||
- [ ] Границы ступеней непрерывны (нет дыр/перекрытий между 7 ступенями).
|
||||
- [ ] Pest покрывает граничные значения (ступень N → N+1).
|
||||
|
||||
## I4. Дрейф сверки CsvReconcile
|
||||
|
||||
- [ ] Порог >5% — что сравнивается (поставка поставщика vs начислено) → `supplier_csv_reconcile_log`.
|
||||
- [ ] Дрейф = рассинхрон поставки (норм) ИЛИ ошибка тарифа (баг) — различить по `charge_source`.
|
||||
|
||||
## I5. Провенанс charge_source
|
||||
|
||||
- [ ] Каждое `lead_charges.charge_source` заполнено и прослеживаемо.
|
||||
- [ ] Авто/ручное/CSV-восстановленное (`recovered_from_csv_at`) различимы.
|
||||
|
||||
## Reuse-инструменты
|
||||
|
||||
Boost #10 (Eloquent-introspection), Pest #18 + pest-parallel-debugger (тесты + race),
|
||||
Larastan #12 (статанализ bcmath), Sentry MCP #34 (runtime списаний), Redis MCP #35 (очередь сверки), context7 #60 (доки bcmath).
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Валидировать структуру скила**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd ".claude/worktrees/finance-tooling-c6-c7" 2>/dev/null || cd "c:/моя/проекты/портал crm/Документация/.claude/worktrees/finance-tooling-c6-c7"
|
||||
node -e "JSON.parse(require('fs').readFileSync('.claude/skills/billing-audit/evals/evals.json','utf8')); console.log('evals.json OK')"
|
||||
grep -c "^name: billing-audit" .claude/skills/billing-audit/SKILL.md
|
||||
grep -c "^description:" .claude/skills/billing-audit/SKILL.md
|
||||
```
|
||||
|
||||
Expected: `evals.json OK`, оба grep → `1`.
|
||||
|
||||
- [ ] **Step 5: Триггер-eval (самопроверка по evals.json)**
|
||||
|
||||
Прогнать каждый `positive` промпт через дескрипшн скила: триггерит ли `description`?
|
||||
Каждый `near_miss` — уходит ли к указанному соседу (НЕ к billing-audit)? Зафиксировать
|
||||
результат в ответе (например «10/10 positive, 5/5 near-miss корректны»). При промахе —
|
||||
уточнить `description` (добавить/убрать ключевые слова) и перепроверить.
|
||||
|
||||
- [ ] **Step 6: lefthook-проверка + commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add -- .claude/skills/billing-audit/
|
||||
git commit -m "feat(finance): billing-audit skill — money invariants C6
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" -- .claude/skills/billing-audit/
|
||||
```
|
||||
|
||||
Expected: PASS markdownlint/cspell/gitleaks. При cspell-флаге — добавить термины в `cspell-words.txt` (новой секцией `# finance-tooling C6+C7 — billing-audit skill (2026-05-20)`), `git add -- cspell-words.txt`, повторить commit с обоими путями.
|
||||
|
||||
---
|
||||
|
||||
## ФАЗА 2 — C7: finance plugin + ru-tax-accounting
|
||||
|
||||
### Task 2: Enable `finance` plugin + smoke
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `~/.claude/settings.json` (user-level, вне репозитория — НЕ коммитится)
|
||||
|
||||
- [ ] **Step 1: Прочитать текущий формат enabledPlugins**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node -e "const s=require('os').homedir()+'/.claude/settings.json'; const j=JSON.parse(require('fs').readFileSync(s,'utf8')); console.log(JSON.stringify(j.enabledPlugins,null,2))"
|
||||
```
|
||||
|
||||
Expected: объект с ключами вида `"operations@knowledge-work-plugins": true`, `"product-management@knowledge-work-plugins": true`. Запомнить точный формат ключа.
|
||||
|
||||
- [ ] **Step 2: Добавить finance plugin**
|
||||
|
||||
Через skill `update-config` ИЛИ прямым редактированием `~/.claude/settings.json` добавить в `enabledPlugins`:
|
||||
|
||||
```json
|
||||
"finance@knowledge-work-plugins": true
|
||||
```
|
||||
|
||||
(ключ — точно по формату из Step 1; marketplace `knowledge-work-plugins` уже установлен).
|
||||
|
||||
- [ ] **Step 3: Smoke — скилы finance доступны**
|
||||
|
||||
Перезапустить сессию ИЛИ проверить доступность. Ожидаемо в списке скилов появляются
|
||||
`finance:reconciliation`, `finance:variance-analysis`, `finance:financial-statements`,
|
||||
`finance:journal-entry`, `finance:close-management`, `finance:audit-support`, `finance:sox-testing`.
|
||||
Зафиксировать в ответе фактический список доступных `finance:*` скилов (raw, не саммари).
|
||||
|
||||
- [ ] **Step 4: Зафиксировать применимость (для ADR-012, Task 4)**
|
||||
|
||||
Записать вывод (без коммита — войдёт в ADR Task 4):
|
||||
|
||||
- ✅ применимо C6: `reconciliation`, `variance-analysis`.
|
||||
- ⚠️ частично C7 (US-GAAP): `financial-statements`, `close-management`, `journal-entry`, `journal-entry-prep`.
|
||||
- ❌ not-applicable РФ: `sox-testing`, `audit-support`.
|
||||
- DEFERRED MCP: snowflake/databricks/bigquery (не наш стек).
|
||||
|
||||
> Примечание: enable settings.json — user-level, не попадает в git. Формализация — в нормативке (Task 5-9).
|
||||
|
||||
### Task 3: Скил `ru-tax-accounting` (C7)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `.claude/skills/ru-tax-accounting/SKILL.md`
|
||||
- Create: `.claude/skills/ru-tax-accounting/references/ru-tax-context.md`
|
||||
- Create: `.claude/skills/ru-tax-accounting/evals/evals.json`
|
||||
|
||||
- [ ] **Step 1: Написать evals.json**
|
||||
|
||||
Создать `.claude/skills/ru-tax-accounting/evals/evals.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"skill": "ru-tax-accounting",
|
||||
"positive": [
|
||||
"как учесть выручку за лиды по РСБУ",
|
||||
"НДС или УСН для SaaS-выручки",
|
||||
"переведи billing-выручку в налоговую базу",
|
||||
"какое налогооблагаемое событие при пополнении баланса",
|
||||
"выгрузка lead_charges для бухгалтера",
|
||||
"проводки по РСБУ за списания",
|
||||
"налоговый режим для подписочной выручки портала",
|
||||
"что с НДС при возврате на баланс tenant",
|
||||
"налоговая база УСН доходы по выручке за лиды"
|
||||
],
|
||||
"near_miss": [
|
||||
{"prompt": "проверь идемпотентность списания", "expect": "billing-audit #62"},
|
||||
{"prompt": "US-GAAP financial statement", "expect": "finance plugin #61 financial-statements"},
|
||||
{"prompt": "договор с поставщиком лидов", "expect": "D1 юриспруденция"},
|
||||
{"prompt": "обработка ПДн при выгрузке", "expect": "D2 ПДн 152-ФЗ"},
|
||||
{"prompt": "сверка ledger с банком", "expect": "finance plugin #61 reconciliation"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Написать SKILL.md**
|
||||
|
||||
Создать `.claude/skills/ru-tax-accounting/SKILL.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: ru-tax-accounting
|
||||
description: Контекст РСБУ и налогов РФ (НК РФ, НДС/УСН) применительно к SaaS-выручке Лидерры за лиды. Используй при «учёт выручки по РСБУ», «НДС или УСН», «налоговая база по выручке», «налогооблагаемое событие», «выгрузка для бухгалтера», «проводки РСБУ». НЕ для денежной корректности кода (billing-audit), US-GAAP-отчётности (finance plugin), договоров (D1 право), ПДн (D2), сверки с банком (finance reconciliation).
|
||||
---
|
||||
|
||||
# RU Tax & Accounting — РСБУ/НК РФ контекст для выручки Лидерры
|
||||
|
||||
Проектный скил раздела C7 карты «Финансы — бухгалтерия и налоги». Переводит
|
||||
billing-выручку (выход C6) в **российский учётно-налоговый контекст** (РСБУ + НК РФ).
|
||||
Закрывает gap, который US-GAAP-плагин `finance` (#61) не покрывает.
|
||||
|
||||
## Когда использовать
|
||||
|
||||
- Вопрос «как это учесть/обложить по РФ-правилам?» по выручке/пополнениям/возвратам.
|
||||
- Подготовка выгрузок/пояснений для бухгалтера из billing-данных.
|
||||
- Определение налогооблагаемого события и налоговой базы.
|
||||
|
||||
## Содержание (см. references/ru-tax-context.md)
|
||||
|
||||
1. **Налоговые режимы РФ** — НДС (ОСНО) vs УСН (доходы / доходы-расходы); что применимо к SaaS за лиды.
|
||||
2. **Налогооблагаемое событие** — пополнение баланса (аванс) vs списание за лид (реализация) vs возврат.
|
||||
3. **Маппинг billing→база** — `lead_charges`/`LedgerService` → выручка → налоговая база; роль `charge_source`.
|
||||
4. **РСБУ vs управленческий** — отличие от US-GAAP-отчётов плагина finance; первичка/документы.
|
||||
5. **Выгрузки для бухгалтера** — какие данные и в каком разрезе извлечь (Boost/Pest как инструменты выгрузки).
|
||||
|
||||
## Границы
|
||||
|
||||
- ≠ `billing-audit` #62 — тот про *корректность начисления в коде*; ru-tax про *учёт/налог результата*.
|
||||
- ≠ `finance` plugin #61 — тот US-GAAP-механика (проводки/отчёты/сверка); ru-tax — РФ-специфика РСБУ/НК.
|
||||
- ≠ D1 «Юриспруденция/договорная» — там договоры/право; ru-tax — налоги.
|
||||
- ≠ D2 «Защита ПДн (152-ФЗ)» — там персональные данные; ru-tax — налоги.
|
||||
|
||||
## Ограничение
|
||||
|
||||
Бухгалтерия компании ведётся вне dev-репо (1С/аутсорс). Скил даёт **контекст и выгрузки**,
|
||||
не заменяет бухгалтера и не является налоговой консультацией. Реальный платёжный
|
||||
провайдер — DEFERRED (Б-1).
|
||||
|
||||
## Связано
|
||||
|
||||
- Вход: выручка из C6 (`lead_charges`, `LedgerService`).
|
||||
- Reuse: data-scientist #49 (финмодели), Boost #10 / Pest #18 (выгрузка), finance plugin #61 (US-механика).
|
||||
- ADR-012 (граница finance-tooling C6/C7).
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Написать references/ru-tax-context.md**
|
||||
|
||||
Создать `.claude/skills/ru-tax-accounting/references/ru-tax-context.md`:
|
||||
|
||||
```markdown
|
||||
# РСБУ / НК РФ — контекст для выручки Лидерры за лиды
|
||||
|
||||
> Не налоговая консультация. Контекст для подготовки данных бухгалтеру.
|
||||
|
||||
## 1. Налоговые режимы РФ
|
||||
|
||||
- **ОСНО + НДС (НК РФ гл. 21)** — НДС 20% на реализацию услуг РФ. Электронные/рекламные
|
||||
услуги — проверить место реализации и применимые льготы.
|
||||
- **УСН (НК РФ гл. 26.2)** — «доходы» (6%) или «доходы минус расходы» (15%). Без НДС
|
||||
(кроме исключений). Типичный режим для раннего SaaS.
|
||||
- Применимый режим зависит от регистрации ООО (Б-1) — до закрытия Б-1 фиксируем как параметр.
|
||||
|
||||
## 2. Налогооблагаемое событие
|
||||
|
||||
- **Пополнение баланса** = аванс (предоплата). По НДС — момент определения базы может
|
||||
возникать на аванс; по УСН-доходы — доход по поступлению (кассовый метод).
|
||||
- **Списание за лид** = реализация услуги (закрытие аванса).
|
||||
- **Возврат на баланс / с баланса** = корректировка базы.
|
||||
- Различать по `lead_charges.charge_source` и операциям `LedgerService`.
|
||||
|
||||
## 3. Маппинг billing → налоговая база
|
||||
|
||||
| Billing-сущность | Учётный смысл |
|
||||
|---|---|
|
||||
| Пополнение (`BillingTopupService`) | Аванс / поступление |
|
||||
| Списание (`LedgerService`, `lead_charges`) | Реализация (выручка) |
|
||||
| `delivered_in_month` (`tenants`) | Объём для tier — не налог напрямую |
|
||||
| Возврат | Корректировка |
|
||||
|
||||
## 4. РСБУ vs управленческий / US-GAAP
|
||||
|
||||
- РСБУ — российский план счетов, первичные документы (акт/УПД), кассовый/начисление.
|
||||
- US-GAAP-скилы плагина `finance` (#61) — иная форма (income statement / balance sheet);
|
||||
применимы для *внутренней управленки*, не для РФ-отчётности.
|
||||
|
||||
## 5. Выгрузки для бухгалтера
|
||||
|
||||
- Реестр списаний за период: `lead_charges` (period, tenant, сумма, charge_source).
|
||||
- Реестр пополнений: операции `LedgerService` / `BillingTopupService`.
|
||||
- Инструменты выгрузки: Boost #10 (Eloquent/SQL), Pest #18 (фикстуры/проверки), `BillingSummaryProvider` (готовый отчёт-провайдер).
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Валидировать структуру скила**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node -e "JSON.parse(require('fs').readFileSync('.claude/skills/ru-tax-accounting/evals/evals.json','utf8')); console.log('evals.json OK')"
|
||||
grep -c "^name: ru-tax-accounting" .claude/skills/ru-tax-accounting/SKILL.md
|
||||
grep -c "^description:" .claude/skills/ru-tax-accounting/SKILL.md
|
||||
```
|
||||
|
||||
Expected: `evals.json OK`, оба grep → `1`.
|
||||
|
||||
- [ ] **Step 5: Триггер-eval (самопроверка)**
|
||||
|
||||
Прогнать positive/near_miss как в Task 1 Step 5. Зафиксировать результат. При промахе — уточнить `description`.
|
||||
|
||||
- [ ] **Step 6: lefthook-проверка + commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add -- .claude/skills/ru-tax-accounting/
|
||||
git commit -m "feat(finance): ru-tax-accounting skill — РСБУ/НК РФ context C7
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" -- .claude/skills/ru-tax-accounting/
|
||||
```
|
||||
|
||||
Expected: PASS. При cspell-флаге — добавить термины в `cspell-words.txt`, повторить с обоими путями.
|
||||
|
||||
---
|
||||
|
||||
## ФАЗА 3 — нормативка + роутер + наблюдатель + карта + ADR
|
||||
|
||||
> Перед правкой КАЖДОГО нормативного файла (Tooling/PSR_v1/Pravila/CLAUDE.md/routing-off-phase/router-procedure) — pre-flight: `git fetch && git log HEAD..origin/main --oneline` (Pravila §15.2).
|
||||
|
||||
### Task 4: ADR-012 — граница finance-tooling
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `docs/adr/012-finance-tooling.md`
|
||||
|
||||
- [ ] **Step 1: Создать ADR-012**
|
||||
|
||||
Создать `docs/adr/012-finance-tooling.md` (формат как соседние ADR в `docs/adr/`):
|
||||
|
||||
```markdown
|
||||
# ADR-012: Finance-tooling — наполнение разделов карты C6 + C7
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-05-20
|
||||
**Контекст:** эпик finance-tooling (объединённые C6+C7), spec `docs/superpowers/specs/2026-05-20-finance-tooling-c6-c7-design.md`.
|
||||
|
||||
## Context
|
||||
|
||||
Разделы карты C6 «Финансы — биллинг и тарификация» и C7 «Финансы — бухгалтерия и
|
||||
налоги» были пусты. Биллинг-подсистема (Plan 4) велика в коде, но dedicated dev-tooling
|
||||
скуден. Заказчик решил объединить C6+C7 в один эпик и покрыть полностью.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **finance plugin (#61)** (knowledge-work-plugins) — homed **C7** (primary), cross-ref C6.
|
||||
- ✅ C6: `reconciliation`, `variance-analysis`.
|
||||
- ⚠️ C7 частично (US-GAAP): `financial-statements`, `close-management`, `journal-entry`, `journal-entry-prep`.
|
||||
- ❌ not-applicable РФ: `sox-testing`, `audit-support` (нет SOX у частной РФ-компании).
|
||||
- DEFERRED: warehouse-MCP (snowflake/databricks/bigquery) — не стек проекта (PG+Redis).
|
||||
2. **billing-audit (#62)** — self-authored project-скил, C6. Денежные инварианты Лидерры.
|
||||
3. **ru-tax-accounting (#63)** — self-authored project-скил, C7. РСБУ/НК РФ. Закрывает gap US-плагина.
|
||||
4. **Граница C6 ↔ C7:** C6 = начисление денег клиенту за лиды; C7 = учёт и налоги компании.
|
||||
Точка стыка: billing-выручка (`lead_charges`/`LedgerService`) — выход C6 и вход C7.
|
||||
5. **Reuse** существующих узлов в C6/C7 через `NODE_SECTION_SECONDARY` (см. spec §6).
|
||||
|
||||
## Boundaries (конфликт-аудит)
|
||||
|
||||
- FIN1 warehouse-MCP → DEFERRED. FIN2 SOX → not-applicable РФ. FIN3 finance vs operations.
|
||||
- FIN4 finance reconciliation (инструмент) vs CsvReconcileJob (код). FIN5 billing-audit vs process-*/D3.
|
||||
- FIN6 ru-tax vs finance plugin vs D1/D2. FIN7 граница C6↔C7. FIN8 self-authored скилы линтуются.
|
||||
|
||||
## Consequences
|
||||
|
||||
- C6/C7 карты непусты. Новая off-phase подкатегория `finance-tooling` (15-я).
|
||||
- Реальный платёжный провайдер и warehouse-аналитика — DEFERRED (Б-1 / вне стека).
|
||||
- ru-tax-accounting — контекст/выгрузки, не налоговая консультация (бухгалтерия вне репо).
|
||||
```
|
||||
|
||||
- [ ] **Step 2: lefthook + commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add -- docs/adr/012-finance-tooling.md
|
||||
git commit -m "docs(adr): ADR-012 finance-tooling boundary C6/C7
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" -- docs/adr/012-finance-tooling.md
|
||||
```
|
||||
|
||||
Expected: PASS (adr-judge job 9 OK). cspell-флаги → cspell-words.txt.
|
||||
|
||||
### Task 5: Tooling Прил.Н — §4.36/§4.37/§4.38 + §0 + header
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/Tooling_v8_3.md`
|
||||
|
||||
- [ ] **Step 1: pre-flight sync**
|
||||
|
||||
Run: `git fetch && git log HEAD..origin/main --oneline` — ожидаемо пусто или чужие коммиты (не Tooling). Если Tooling изменён на origin/main — сначала rebase.
|
||||
|
||||
- [ ] **Step 2: Добавить §4.36 finance plugin** (после §4.35 context7)
|
||||
|
||||
Вставить блок (формат как §4.30):
|
||||
|
||||
```markdown
|
||||
### 4.36. finance plugin — финансы и бухгалтерия (off-phase, finance-tooling)
|
||||
|
||||
**Атрибуты:**
|
||||
|
||||
| id | name | kind | phase | subcategory | triggers | boundaries | dormant | last-touched |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| #61 | finance plugin | plugin | off-phase | finance-tooling | «сверка, variance-анализ, US-GAAP-отчётность, проводки, close» | ADR-012 (граница C6/C7, vs operations) | false | 2026-05-20 |
|
||||
|
||||
**Источник:** Claude Code plugin, marketplace `anthropics/knowledge-work-plugins`, plugin `finance@knowledge-work-plugins` (Anthropic Verified). Включается в `~/.claude/settings.json` `enabledPlugins`. 9 скилов (reconciliation/variance-analysis/financial-statements/close-management/journal-entry/journal-entry-prep/sox-testing/audit-support) + MCP-серверы (snowflake/databricks/bigquery/slack/ms365 — http).
|
||||
|
||||
**Категория:** off-phase, **finance-tooling** — 15-я off-phase подкатегория. Домашний раздел карты — **C7** «Финансы — бухгалтерия и налоги» (primary), cross-ref **C6** «биллинг/тарификация».
|
||||
|
||||
**Назначение:** учёт и финансовая отчётность — сверка счетов, анализ отклонений, US-GAAP-отчёты (P&L/BS/CF), управление закрытием периода, проводки.
|
||||
|
||||
**Роль:** инструмент **#61**. Применимость к РФ-контексту: ✅ `reconciliation`/`variance-analysis` (C6); ⚠️ `financial-statements`/`close-management`/`journal-entry`/`journal-entry-prep` (US-GAAP — частично, у нас РСБУ); ❌ `sox-testing`/`audit-support` (not-applicable — нет SOX у частной РФ-компании). MCP-серверы (snowflake/databricks/bigquery) — **DEFERRED**, не стек проекта.
|
||||
|
||||
**Конфликт-аудит (ADR-012):** FIN1 warehouse-MCP DEFERRED; FIN2 SOX not-applicable; FIN3 граница с operations #51 (finance = учёт/сверка/отчётность, operations = операционные процессы/риск); FIN4 finance `reconciliation` (инструмент) vs `CsvReconcileJob` (код проекта). РФ-специфику покрывает `ru-tax-accounting` #63.
|
||||
|
||||
**Координация:** PSR_v1 R10.1 Блок 1. Не UI → вне R6.0/R6.1/R14. ADR-012.
|
||||
|
||||
### 4.37. billing-audit — аудит денежной корректности (off-phase, finance-tooling)
|
||||
|
||||
**Атрибуты:**
|
||||
|
||||
| id | name | kind | phase | subcategory | triggers | boundaries | dormant | last-touched |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| #62 | billing-audit | skill | off-phase | finance-tooling | «аудит списания, money-инварианты, идемпотентность, тариф-резолюция, дрейф reconcile» | ADR-012 (vs process-*/D3/ru-tax) | false | 2026-05-20 |
|
||||
|
||||
**Источник:** self-authored standalone project-скил в `.claude/skills/billing-audit/` (`SKILL.md` + `references/invariants.md` + `evals/evals.json`). Свой, не вендоренный; **линтуется** (cspell+markdownlint, не в ignorePaths — как process-*/discovery-interview, LINT1).
|
||||
|
||||
**Категория:** off-phase, **finance-tooling**. Раздел карты **C6** «Финансы — биллинг и тарификация».
|
||||
|
||||
**Назначение:** аудит денежных инвариантов биллинг-кода Лидерры — сохранение суммы (bcmath/без float), идемпотентность списания, корректность 7-ступенчатой tier-резолюции, интерпретация дрейфа CsvReconcile >5%, провенанс charge_source.
|
||||
|
||||
**Роль:** инструмент **#62**. Триггер-eval в `.claude/skills/billing-audit/evals/evals.json`.
|
||||
|
||||
**Конфликт-аудит (ADR-012, FIN5):** объект иной — ≠ process-modeling/process-analysis (#52/#53, поток/процесс), ≠ D3 audit-security (#39/#40, безопасность), ≠ ru-tax-accounting (#63, учёт/налог результата), ≠ product-management metrics-review (#42, метрики выручки).
|
||||
|
||||
**Координация:** PSR_v1 R10.1 Блок 1 note (self-authored project-скил). Не UI → вне R6.0/R6.1/R14.
|
||||
|
||||
### 4.38. ru-tax-accounting — РСБУ/НК РФ контекст (off-phase, finance-tooling)
|
||||
|
||||
**Атрибуты:**
|
||||
|
||||
| id | name | kind | phase | subcategory | triggers | boundaries | dormant | last-touched |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| #63 | ru-tax-accounting | skill | off-phase | finance-tooling | «РСБУ, НДС/УСН, налоговая база, налогооблагаемое событие, выгрузка бухгалтеру» | ADR-012 (vs finance plugin/billing-audit/D1/D2) | false | 2026-05-20 |
|
||||
|
||||
**Источник:** self-authored standalone project-скил в `.claude/skills/ru-tax-accounting/` (`SKILL.md` + `references/ru-tax-context.md` + `evals/evals.json`). Свой, не вендоренный; **линтуется** (LINT1).
|
||||
|
||||
**Категория:** off-phase, **finance-tooling**. Раздел карты **C7** «Финансы — бухгалтерия и налоги».
|
||||
|
||||
**Назначение:** перевод billing-выручки (выход C6) в российский учётно-налоговый контекст — налоговые режимы (НДС/УСН), налогооблагаемое событие, маппинг выручка→база, выгрузки для бухгалтера. Закрывает РФ-gap US-GAAP-плагина finance #61.
|
||||
|
||||
**Роль:** инструмент **#63**. Не налоговая консультация; бухгалтерия компании ведётся вне dev-репо (1С/аутсорс). Реальный платёжный провайдер — DEFERRED (Б-1).
|
||||
|
||||
**Конфликт-аудит (ADR-012, FIN6):** ≠ billing-audit #62 (корректность начисления в коде), ≠ finance plugin #61 (US-GAAP-механика), ≠ D1 (договоры/право), ≠ D2 (ПДн 152-ФЗ).
|
||||
|
||||
**Координация:** PSR_v1 R10.1 Блок 1 note (self-authored project-скил). Не UI → вне R6.0/R6.1/R14.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Обновить §0 счётчик + header**
|
||||
|
||||
В §0 «КАНОН СЧЁТЧИКОВ»: счётчик формализованных позиций 60 → **63** (off-phase +3); добавить подкатегорию **finance-tooling** (15-я). Обновить header Прил.Н: v2.17 → **v2.18** с записью «finance-tooling: +§4.36 finance plugin #61 / §4.37 billing-audit #62 / §4.38 ru-tax-accounting #63; §0 счётчик 60→63 + 15-я подкатегория; cross-ref Pravila v1.34 / PSR_v1 v3.18 / CLAUDE.md v2.21». Сверить фактическое число в §0 (Step выполняется по факту прочитанного §0, не вслепую).
|
||||
|
||||
- [ ] **Step 4: lefthook + commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add -- docs/Tooling_v8_3.md cspell-words.txt
|
||||
git commit -m "docs(tooling): finance-tooling #61-#63 + §0 counter 60->63 (Прил.Н v2.18)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" -- docs/Tooling_v8_3.md cspell-words.txt
|
||||
```
|
||||
|
||||
Expected: PASS. cspell-флаги (РСБУ/УСН/etc. уже добавлены в Task 1/3; новые — добавить).
|
||||
|
||||
### Task 6: PSR_v1 R10.1 + version
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/Plugin_stack_rules_v1.md`
|
||||
|
||||
- [ ] **Step 1: pre-flight sync** (`git fetch && git log HEAD..origin/main --oneline`).
|
||||
|
||||
- [ ] **Step 2: Блок 1 — добавить строку finance plugin** (после строки `operations` / в конец таблицы Блок 1)
|
||||
|
||||
```markdown
|
||||
| **finance** *(9 skills: `reconciliation` / `variance-analysis` / `financial-statements` / `close-management` / `journal-entry` / `journal-entry-prep` / `sox-testing` / `audit-support`)* | `anthropics/knowledge-work-plugins` (plugin `finance@knowledge-work-plugins`, Anthropic Verified) | финансы/бухгалтерия — сверка, анализ отклонений, US-GAAP-отчётность, закрытие периода, проводки. Категория: **finance-tooling** (Tooling #61, вне UI-пула). Homed C7, cross-ref C6 | при учётно-финансовой работе. Применимость РФ: ✅ reconciliation/variance; ⚠️ US-GAAP-скилы частично; ❌ SOX-скилы not-applicable; warehouse-MCP DEFERRED (ADR-012). Не UI → вне R6.0/R6.1/R14 |
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Блок 1 — добавить note (self-authored finance-скилы)** (после note v3.13)
|
||||
|
||||
```markdown
|
||||
**Блок 1 — note (v3.18):** **billing-audit** (Tooling #62) + **ru-tax-accounting** (Tooling #63) — self-authored project-скилы в `.claude/skills/billing-audit/` и `.claude/skills/ru-tax-accounting/`, **не** вендоренные и **не** через marketplace; написаны проектом (паттерн `audit-portal`/`regression`/`process-*`/`discovery-interview`). **Линтуются** lefthook'ом (cspell+markdownlint), **не** в ignorePaths (LINT1). Категория **finance-tooling** (15-я off-phase подкатегория, разделы C6/C7 карты), вне R6.0/R6.1/R14. ADR-012.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Обновить версию-шапку PSR_v1** v3.17 → **v3.18** с changelog-строкой (формат как v3.9): «finance-tooling: R10.1 Блок 1 +finance plugin (#61) + note (+billing-audit #62 / ru-tax-accounting #63). Не UI → вне R6/R14. Содержательных изменений R0–R16: 0. Связано: Tooling v2.18, Pravila v1.34, CLAUDE.md v2.21; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`».
|
||||
|
||||
- [ ] **Step 5: lefthook + commit**
|
||||
|
||||
```bash
|
||||
git add -- docs/Plugin_stack_rules_v1.md cspell-words.txt
|
||||
git commit -m "docs(psr): R10.1 finance-tooling #61-#63 (PSR_v1 v3.18)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" -- docs/Plugin_stack_rules_v1.md cspell-words.txt
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Task 7: Pravila §13.2 + version
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/Pravila_raboty_Claude_v1_1.md`
|
||||
|
||||
- [ ] **Step 1: pre-flight sync.**
|
||||
|
||||
- [ ] **Step 2: §13.2 — добавить абзац** (после абзаца «Off-phase authoring-tooling + dev-support», перед `### 13.3`)
|
||||
|
||||
```markdown
|
||||
**Off-phase finance-tooling (C6+C7, v1.34, 20.05.2026):** Инструменты разделов C6 «Финансы — биллинг и тарификация» и C7 «Финансы — бухгалтерия и налоги» карты — #61 `finance` plugin (Tooling §4.36; marketplace `finance@knowledge-work-plugins`, Anthropic Verified, 9 скилов; homed C7, cross-ref C6; РФ-применимость: ✅ reconciliation/variance, ⚠️ US-GAAP-скилы частично, ❌ SOX-скилы not-applicable, warehouse-MCP DEFERRED), #62 `billing-audit` (Tooling §4.37; self-authored project-скил `.claude/skills/billing-audit/` — денежные инварианты биллинга C6: сохранение суммы bcmath, идемпотентность, tier-резолюция, дрейф reconcile, charge_source), #63 `ru-tax-accounting` (Tooling §4.38; self-authored project-скил `.claude/skills/ru-tax-accounting/` — РСБУ/НК РФ контекст C7: НДС/УСН, налоговая база, выгрузки бухгалтеру; закрывает РФ-gap US-плагина). Плюс reuse-классификация существующих узлов в C6/C7 через `NODE_SECTION_SECONDARY` (Boost/Pest/Larastan/Sentry/Redis/PM metrics-review/data-scientist/operations/process-*/context7) — без новых номеров. **Пятнадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. self-authored скилы billing-audit/ru-tax-accounting **линтуются** (не в ignorePaths, LINT1). Границы — ADR-012 (граница C6↔C7: начисление клиенту vs учёт/налоги компании; FIN1–FIN8). Регулируется PSR_v1 R10.1 Блок 1 (finance plugin) + note (2 self-authored скила). Установлено 20.05.2026 на ветке `worktree-finance-tooling-c6-c7`; план `docs/superpowers/plans/2026-05-20-finance-tooling-c6-c7.md`.
|
||||
```
|
||||
|
||||
- [ ] **Step 3: §0 cross-ref + §10 changelog + версия-шапка** Pravila v1.33 → **v1.34** (формат как соседние записи; +строку в §10 changelog-таблице).
|
||||
|
||||
- [ ] **Step 4: lefthook + commit**
|
||||
|
||||
```bash
|
||||
git add -- docs/Pravila_raboty_Claude_v1_1.md cspell-words.txt
|
||||
git commit -m "docs(pravila): §13.2 finance-tooling abzац (Pravila v1.34)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" -- docs/Pravila_raboty_Claude_v1_1.md cspell-words.txt
|
||||
```
|
||||
|
||||
### Task 8: Роутер — routing-off-phase.md + router-procedure.md
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/routing-off-phase.md`
|
||||
- Modify: `docs/router-procedure.md`
|
||||
|
||||
- [ ] **Step 1: pre-flight sync.**
|
||||
|
||||
- [ ] **Step 2: routing-off-phase.md — +3 строки routing-таблицы** (в раздел «Таблица routing», после строки context7 #60)
|
||||
|
||||
```markdown
|
||||
| Аудит денежной корректности биллинга (списание/тариф/баланс/дрейф/charge_source) | **billing-audit** (project-скил) | #62 | finance-tooling | C6; ≠ process-*/D3/ru-tax (ADR-012) |
|
||||
| РСБУ/НК РФ контекст: НДС/УСН, налоговая база, выгрузка бухгалтеру | **ru-tax-accounting** (project-скил) | #63 | finance-tooling | C7; ≠ finance plugin/D1/D2 (ADR-012) |
|
||||
| Сверка счетов / variance-анализ / US-GAAP-отчётность / проводки | **finance plugin** | #61 | finance-tooling | C7; SOX not-applicable, warehouse-MCP DEFERRED (ADR-012) |
|
||||
```
|
||||
|
||||
- [ ] **Step 3: routing-off-phase.md — +связка L13** (в раздел «Канонические связки», после L12)
|
||||
|
||||
```markdown
|
||||
| L13 | `billing-audit` (#62) + `Pest` (#18) + `Boost` (#10) + `Sentry`/`Redis` (#34/#35) → `ru-tax-accounting` (#63) | Финансовая цепочка: аудит денежных инвариантов кода (billing-audit) тестами (Pest) на моделях (Boost) с runtime-фактами (Sentry/Redis) → перевод выверенной выручки в учётно-налоговый контекст (ru-tax). C6→C7. Граница — ADR-012. |
|
||||
```
|
||||
|
||||
- [ ] **Step 4: routing-off-phase.md — scope + версия** Обновить scope-диапазон «Tooling §4.11–§4.35» → «§4.11–§4.38»; версию v1.1 → **v1.2** (запись: «+3 строки finance-tooling #61-#63 + связка L13 + scope →§4.38; ADR-012»).
|
||||
|
||||
- [ ] **Step 5: router-procedure.md — changelog-touch** Добавить в Changelog строку:
|
||||
|
||||
```markdown
|
||||
- **v1.1 (2026-05-20)** — finance-tooling узлы #61-#63 добавлены в реестр Tooling §4.36-§4.38 (читаются step 3) и routing-off-phase.md (связка L13). Структурных правок процедуры нет. ADR-012.
|
||||
```
|
||||
|
||||
И обновить `**Status:**` строку версией v1.1.
|
||||
|
||||
- [ ] **Step 6: lefthook + commit**
|
||||
|
||||
```bash
|
||||
git add -- docs/routing-off-phase.md docs/router-procedure.md cspell-words.txt
|
||||
git commit -m "docs(router): finance-tooling routing rows + L13 chain (routing-off-phase v1.2)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" -- docs/routing-off-phase.md docs/router-procedure.md cspell-words.txt
|
||||
```
|
||||
|
||||
### Task 9: CLAUDE.md (прямой Edit — worktree-эксцепшн §5 п.10)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
- [ ] **Step 1: pre-flight sync.**
|
||||
|
||||
- [ ] **Step 2: §3.3 — добавить 3 строки** (после #60 context7, формат однострочного индекса):
|
||||
|
||||
```markdown
|
||||
| 61 | Финансы/бухгалтерия (сверка, variance, US-GAAP, проводки) — *off-phase* | **finance plugin** | off-phase, finance-tooling — Tooling §4.36; homed C7, cross-ref C6; SOX not-applicable РФ |
|
||||
| 62 | Аудит денежной корректности биллинга (инварианты) — *off-phase* | **billing-audit** | off-phase, finance-tooling — Tooling §4.37; project-скил, раздел C6 |
|
||||
| 63 | РСБУ/НК РФ контекст (НДС/УСН, налоговая база) — *off-phase* | **ru-tax-accounting** | off-phase, finance-tooling — Tooling §4.38; project-скил, раздел C7 |
|
||||
```
|
||||
|
||||
- [ ] **Step 3: §0 cross-refs** Pravila v1.33→**v1.34** / PSR_v1 v3.17→**v3.18** / Tooling Прил.Н v2.17→**v2.18**.
|
||||
|
||||
- [ ] **Step 4: §6 +абзац** (сверху) «2026-05-20 finance-tooling integration (C6+C7)» — кратко: 3 узла (#61 finance plugin homed C7 / #62 billing-audit C6 / #63 ru-tax-accounting C7) + reuse + расширенная нормативка (роутер L13 + наблюдатель 9-атрибутные блоки) + ADR-012; разделы C6/C7 карты непусты; worktree.
|
||||
|
||||
- [ ] **Step 5: §9 +entry** v2.20 → **v2.21** с описанием правок (§3.3 +#61/#62/#63, §0 cross-refs, §6, шапка). Обновить шапку версии.
|
||||
|
||||
- [ ] **Step 6: lefthook + commit**
|
||||
|
||||
```bash
|
||||
git add -- CLAUDE.md cspell-words.txt
|
||||
git commit -m "docs(claude-md): §3.3 finance-tooling #61-#63 (CLAUDE.md v2.21)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" -- CLAUDE.md cspell-words.txt
|
||||
```
|
||||
|
||||
### Task 10: Карта — automation-graph-data.js + automation-graph.html
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/automation-graph-data.js`
|
||||
- Modify: `docs/automation-graph.html`
|
||||
|
||||
- [ ] **Step 1: NODES — добавить 3 узла** (в массив `NODES`, рядом со skills_proj-кластером)
|
||||
|
||||
```javascript
|
||||
{ id: 'finance_plugin', label: 'finance\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 200) },
|
||||
{ id: 'billing_audit', label: 'billing-audit\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 397) },
|
||||
{ id: 'ru_tax', label: 'ru-tax-accounting\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 407) },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: NODE_SECTION — добавить привязки** (в объект `NODE_SECTION`, новой секцией-комментарием)
|
||||
|
||||
```javascript
|
||||
// finance-tooling C6+C7 (20.05.2026) — разделы «Финансы»
|
||||
finance_plugin: 'C7', billing_audit: 'C6', ru_tax: 'C7',
|
||||
```
|
||||
|
||||
- [ ] **Step 3: NODE_SECTION_SECONDARY — finance cross-ref + reuse** (в объект `NODE_SECTION_SECONDARY`)
|
||||
|
||||
```javascript
|
||||
// finance-tooling C6+C7 (20.05.2026) — cross-ref + reuse-классификация
|
||||
finance_plugin: ['C6'],
|
||||
mcp_boost: ['C6', 'C7'], ag_pest: ['C6', 'C7'], lh_larastan: ['C6'],
|
||||
mcp_sentry: ['C6'], mcp_redis: ['C6'], product_mgmt: ['C6'],
|
||||
data_scientist: ['C6', 'C7'], ops_plugin: ['C6', 'C7'], context7: ['C6'],
|
||||
process_modeling: ['C6'], process_analysis: ['C6'],
|
||||
```
|
||||
|
||||
> Примечание: если у узла уже есть запись в `NODE_SECTION_SECONDARY` (напр. `mcp_boost: ['A3']`, `context7: ['A3']`, `ag_pest: ['A3']`, `process_modeling: ['C10']`, `process_analysis: ['C10']`, `product_mgmt: ['C10']`) — НЕ перезаписывать, а **дополнить** массив (`mcp_boost: ['A3','C6','C7']` и т.д.). Сверить с фактическим содержимым перед правкой.
|
||||
|
||||
- [ ] **Step 4: EDGES — добавить рёбра** (в массив `EDGES`, через хелпер `E(...)`)
|
||||
|
||||
```javascript
|
||||
E('tooling', 'finance_plugin', '§4.36 #61 — реестр'),
|
||||
E('tooling', 'billing_audit', '§4.37 #62 — реестр'),
|
||||
E('tooling', 'ru_tax', '§4.38 #63 — реестр'),
|
||||
E('billing_audit', 'ag_pest', 'аудит инвариантов\nчерез тесты'),
|
||||
E('mcp_boost', 'billing_audit','модели биллинга'),
|
||||
E('finance_plugin', 'ru_tax', 'РФ-специфика поверх\nUS-механики (ADR-012)'),
|
||||
E('billing_audit', 'ru_tax', 'выручка C6 →\nналог.база C7'),
|
||||
```
|
||||
|
||||
- [ ] **Step 5: NODE_DETAILS (`automation-graph.html`) — `nd()` для 3 узлов** (рядом с finance-кластером, формат как `discovery_interview`)
|
||||
|
||||
```javascript
|
||||
finance_plugin: nd(
|
||||
'Marketplace-плагин (Anthropic): финансы/бухгалтерия — сверка, variance-анализ, US-GAAP-отчётность, закрытие периода, проводки. 9 скилов.',
|
||||
'При учётно-финансовой работе C7 (и сверке/variance для C6). РФ: reconciliation/variance ✅; US-GAAP-скилы ⚠️; SOX ❌; warehouse-MCP DEFERRED.',
|
||||
'plugin finance@knowledge-work-plugins (enabledPlugins). Категория finance-tooling, homed C7. Не UI → вне R6/R14. Tooling §4.36 #61, CLAUDE.md §3.3 #61, ADR-012.',
|
||||
[{ name: 'Tooling', cond: '§4.36 #61 — реестр' }],
|
||||
[{ name: 'FIN2', cond: 'SOX not-applicable РФ' }, { name: 'FIN3', cond: 'граница с operations #51' }],
|
||||
[{ name: 'ru-tax-accounting', cond: 'РФ-специфика поверх US-механики' }]
|
||||
),
|
||||
billing_audit: nd(
|
||||
'Self-authored скил: аудит денежных инвариантов биллинга Лидерры — сумма (bcmath), идемпотентность, tier-резолюция, дрейф reconcile, charge_source.',
|
||||
'При правке/ревью кода Billing — проверить денежную корректность начисления.',
|
||||
'Свой project-скил .claude/skills/billing-audit/ (линтуется, LINT1). Не UI → вне R6/R14. Tooling §4.37 #62, CLAUDE.md §3.3 #62, ADR-012.',
|
||||
[{ name: 'Tooling', cond: '§4.37 #62 — реестр' }],
|
||||
[{ name: 'FIN5', cond: 'объект ≠ process-*/D3/ru-tax' }],
|
||||
[{ name: 'Pest', cond: 'инварианты через тесты' }, { name: 'Boost', cond: 'модели биллинга' }]
|
||||
),
|
||||
ru_tax: nd(
|
||||
'Self-authored скил: РСБУ/НК РФ контекст для выручки Лидерры — НДС/УСН, налоговая база, налогооблагаемое событие, выгрузки бухгалтеру.',
|
||||
'При «как учесть/обложить по РФ» — перевод billing-выручки (выход C6) в учётно-налоговый контекст C7.',
|
||||
'Свой project-скил .claude/skills/ru-tax-accounting/ (линтуется, LINT1). Закрывает РФ-gap US-плагина finance. Не UI → вне R6/R14. Tooling §4.38 #63, CLAUDE.md §3.3 #63, ADR-012.',
|
||||
[{ name: 'Tooling', cond: '§4.38 #63 — реестр' }],
|
||||
[{ name: 'FIN6', cond: '≠ finance plugin/billing-audit/D1/D2' }],
|
||||
[{ name: 'billing-audit', cond: 'выручка C6 → налог.база C7' }, { name: 'finance plugin', cond: 'US-механика' }]
|
||||
),
|
||||
```
|
||||
|
||||
- [ ] **Step 6: NODE_META — добавить 3 записи** (`automation-graph.html`, рядом с discovery_interview)
|
||||
|
||||
```javascript
|
||||
finance_plugin: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' },
|
||||
billing_audit: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' },
|
||||
ru_tax: { since: '20.05.2026', changed: '—', uses: 0, usesSrc: 'новый узел' },
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Обновить версии-метки + счётчики META** В `automation-graph-data.js`/`automation-graph.html`: метку узла `claude_md` (`CLAUDE.md v2.20` → `v2.21`); счётчики «узлов/рёбер» в шапке/META (узлов +3, рёбер +7) — сверить фактические числа перед правкой; комментарий-счётчик «Покрывает все N узлов» в `NODE_SECTION` (+3).
|
||||
|
||||
- [ ] **Step 8: JS-smoke (карта парсится, секции покрыты, рёбра валидны)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node -e "
|
||||
const fs=require('fs'); const vm=require('vm');
|
||||
const src=fs.readFileSync('docs/automation-graph-data.js','utf8');
|
||||
const sandbox={module:{exports:{}},exports:{}}; vm.createContext(sandbox);
|
||||
vm.runInContext(src + '\n;globalThis.__d={NODES,EDGES,NODE_SECTION,NODE_SECTION_SECONDARY,SECTIONS};', sandbox);
|
||||
const d=sandbox.__d||globalThis.__d;
|
||||
const ids=new Set(d.NODES.map(n=>n.id));
|
||||
const miss=d.NODES.filter(n=>!d.NODE_SECTION[n.id]).map(n=>n.id);
|
||||
const badEdge=d.EDGES.filter(e=>!ids.has(e.from)||!ids.has(e.to)).map(e=>e.from+'->'+e.to);
|
||||
const c6=Object.entries(d.NODE_SECTION).filter(([,s])=>s==='C6').length;
|
||||
const c7=Object.entries(d.NODE_SECTION).filter(([,s])=>s==='C7').length;
|
||||
console.log('nodes',d.NODES.length,'edges',d.EDGES.length,'no-section',JSON.stringify(miss),'bad-edges',JSON.stringify(badEdge),'C6',c6,'C7',c7);
|
||||
"
|
||||
```
|
||||
|
||||
Expected: `no-section []`, `bad-edges []`, `C6 ≥1`, `C7 ≥2` (finance_plugin+ru_tax), nodes/edges +3/+7 от прежних.
|
||||
> Если data-файл не экспортирует через `module.exports` и `vm` не видит const — fallback: открыть `docs/automation-graph.html` через Playwright MCP, проверить 0 console-ошибок + screenshot; плюс grep-ассерты: `grep -c "finance_plugin\|billing_audit\|ru_tax" docs/automation-graph-data.js` → ≥9 (3 NODES + 3 NODE_SECTION + рёбра).
|
||||
|
||||
- [ ] **Step 9: lefthook + commit**
|
||||
|
||||
```bash
|
||||
git add -- docs/automation-graph-data.js docs/automation-graph.html cspell-words.txt
|
||||
git commit -m "feat(map): finance-tooling — populate C6+C7 (+3 nodes, +7 edges)
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>" -- docs/automation-graph-data.js docs/automation-graph.html cspell-words.txt
|
||||
```
|
||||
|
||||
### Task 11: Контролёры наблюдателя + финальная регрессия + push
|
||||
|
||||
- [ ] **Step 1: Прогон brain-контролёров C1/C2 на новых узлах**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
node tools/l1-watcher.mjs 2>&1 | tail -20 || true
|
||||
node tools/cross-ref-checker.mjs 2>&1 | tail -20 || true
|
||||
```
|
||||
|
||||
(точные имена скриптов — из `lefthook.yml` jobs 11-14; если иначе — взять оттуда). Expected: новые узлы (#61-#63) не вносят НОВЫХ name@source drift / cross-ref-разрывов. Pre-existing drift (известный, WARN-only) — допустим. Зафиксировать вывод.
|
||||
|
||||
- [ ] **Step 2: Финальная регрессия (docs/skills epic — quick)**
|
||||
|
||||
Run: `/regression quick` (markdownlint + cspell + при наличии PHP — pint/larastan). Expected: GREEN. Записать canonical status line.
|
||||
|
||||
- [ ] **Step 3: lychee на изменённых .md (pre-push gate)**
|
||||
|
||||
Run: `npx lefthook run pre-push 2>&1 | tail -30` (или `npm run links`). Expected: 0 broken на изменённых docs. gitleaks-full-history — в составе pre-push.
|
||||
|
||||
- [ ] **Step 4: Push**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git fetch origin && git log HEAD..origin/main --oneline
|
||||
git push origin worktree-finance-tooling-c6-c7:main
|
||||
```
|
||||
|
||||
Expected: FF-push. Если origin/main ушёл вперёд — rebase на свежий origin/main, повтор JS-smoke, push.
|
||||
|
||||
- [ ] **Step 5: verification-before-completion**
|
||||
|
||||
Invoke superpowers:verification-before-completion: подтвердить разделы C6/C7 непусты (JS-smoke вывод), 3 узла во всех 8 артефактах, регрессия GREEN, push выполнен (`git log -1 origin/main`).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (выполнено при написании плана)
|
||||
|
||||
**Spec coverage:** §4 слои → Tasks 1-3 (скилы/плагин) + Tasks 4-10 (нормативка/карта/ADR). §5 узлы → Tasks 1/2/3 + §4.36-38 (Task 5). §6 reuse → Task 10 Step 3. §7 граница → ADR Task 4. §8 нормативка (8 артефактов) → Tasks 4-10 (Tooling/PSR/Pravila/CLAUDE/routing-off-phase/router-procedure/ADR/observer-блоки в Task 5 + контролёры Task 11). §9 карта → Task 10. §10 конфликт-аудит → ADR Task 4. §11 изоляция → header + pre-flight в каждом Task. §12 верификация → Task 11. Покрытие полное.
|
||||
|
||||
**Placeholder scan:** нет TBD/TODO; весь контент скилов/нормативки/ADR/карты приведён дословно. Счётчики (60→63, узлы/рёбра) с явным указанием «сверить факт перед правкой» — не placeholder, а защита от дрейфа.
|
||||
|
||||
**Type consistency:** node-id единообразны (`finance_plugin`/`billing_audit`/`ru_tax`) во всех Tasks (10 NODES/SECTION/EDGES/DETAILS/META); номера #61/#62/#63 и §4.36/§4.37/§4.38 согласованы между Tooling/PSR/Pravila/CLAUDE/routing/ADR; версии (Tooling v2.18 / PSR v3.18 / Pravila v1.34 / CLAUDE v2.21 / routing v1.2 / router-procedure v1.1) согласованы во всех cross-ref.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,801 @@
|
||||
# Переделка миграции проектов — План 1: фундамент данных
|
||||
|
||||
> **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:** Заложить модель данных под per-субъект экспорт и cap-распределение: `supplier_projects.subject_code`, pivot `project_supplier_links` (замена 3 FK-слотов), `deals.subject_code`, сид `system_settings.supplier_export_mode`, бэкофилл существующих привязок.
|
||||
|
||||
**Architecture:** Инкрементальные миграции (raw SQL, idempotent guard через `to_regclass`/`Schema::hasColumn`/`IF NOT EXISTS`) + параллельная правка консолидированного `db/schema.sql` + запись в `db/CHANGELOG_schema.md`. Pivot — SaaS-level (без RLS, как `supplier_projects`): пишется sync-флоу, читается sharing-флоу через BYPASSRLS-роль `crm_supplier_worker`. Колонки `projects.supplier_b{1,2,3}_project_id` остаются (двойная запись) до зелёной регрессии — удаление в follow-up.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / PostgreSQL 16 (NULLS NOT DISTINCT для unique-индекса пула «Вся РФ») / Pest 4 / squawk (линт миграций) / Eloquent belongsToMany.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../specs/2026-05-20-project-migration-redesign-design.md) §4.2, §5.
|
||||
|
||||
**Prereq:** выполняется в изолированном worktree (см. `superpowers:using-git-worktrees`) от свежего `origin/main`, либо на ветке `feat/project-migration-redesign`. После каждой миграции — `php artisan migrate:fresh` smoke не запускать на каждом шаге; полный прогон — Task 7.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `supplier_projects.subject_code` + составной unique-индекс
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php`
|
||||
- Modify: `db/schema.sql:902-930` (блок `CREATE TABLE supplier_projects` + индекс)
|
||||
- Modify: `db/CHANGELOG_schema.md` (новая запись сверху)
|
||||
- Test: `app/tests/Feature/Supplier/SupplierProjectSubjectCodeTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Database\QueryException;
|
||||
|
||||
it('allows same (platform, unique_key) with different subject_code', function (): void {
|
||||
SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
|
||||
'subject_code' => 83, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
expect(SupplierProject::query()->where('unique_key', 'okna.ru')->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('rejects duplicate (platform, unique_key, subject_code) including NULL pool', function (): void {
|
||||
SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'pool.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
expect(fn () => SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'pool.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('rejects subject_code out of 1..89 range', function (): void {
|
||||
expect(fn () => SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bad.ru',
|
||||
'subject_code' => 90, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — убедиться, что падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierProjectSubjectCodeTest`
|
||||
Expected: FAIL — `subject_code` ещё не fillable / колонки нет (массовое присвоение игнорит поле, unique старый, тесты падают на count/отсутствии исключения).
|
||||
|
||||
- [ ] **Step 3: Написать миграцию**
|
||||
|
||||
`app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Per-субъект supplier_projects (эпик переделки миграции проектов, v8.26).
|
||||
*
|
||||
* +subject_code SMALLINT NULL (1..89 субъект РФ; NULL = пул «Вся РФ»).
|
||||
* Старый unique (platform, unique_key) → (platform, unique_key, subject_code)
|
||||
* NULLS NOT DISTINCT — пул «Вся РФ» уникален per (platform, unique_key).
|
||||
*
|
||||
* Guard: migrate:fresh грузит schema.sql v8.26 (delta уже там) до миграций.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('supplier_projects', 'subject_code')) {
|
||||
DB::statement('ALTER TABLE supplier_projects ADD COLUMN subject_code SMALLINT');
|
||||
}
|
||||
|
||||
DB::statement(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_supplier_projects_subject_code'
|
||||
) THEN
|
||||
ALTER TABLE supplier_projects
|
||||
ADD CONSTRAINT chk_supplier_projects_subject_code
|
||||
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89)) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
DB::statement('ALTER TABLE supplier_projects VALIDATE CONSTRAINT chk_supplier_projects_subject_code');
|
||||
|
||||
DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_unique_key_unique');
|
||||
DB::statement(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_key_subject_unique '
|
||||
.'ON supplier_projects (platform, unique_key, subject_code) NULLS NOT DISTINCT'
|
||||
);
|
||||
|
||||
DB::statement(
|
||||
"COMMENT ON COLUMN supplier_projects.subject_code IS "
|
||||
."'Субъект РФ 1..89 (resources/js/constants/regions.ts). NULL = пул «Вся РФ». Эпик миграции проектов v8.26.'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_key_subject_unique');
|
||||
DB::statement(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_unique_key_unique '
|
||||
.'ON supplier_projects (platform, unique_key)'
|
||||
);
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT IF EXISTS chk_supplier_projects_subject_code');
|
||||
|
||||
if (Schema::hasColumn('supplier_projects', 'subject_code')) {
|
||||
Schema::table('supplier_projects', fn ($t) => $t->dropColumn('subject_code'));
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Зеркалить в `db/schema.sql`**
|
||||
|
||||
В блоке `CREATE TABLE supplier_projects` ([schema.sql:902](../../../db/schema.sql#L902)) после строки `current_regions JSONB,` добавить:
|
||||
|
||||
```sql
|
||||
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ» (v8.26)
|
||||
```
|
||||
|
||||
В список CHECK добавить:
|
||||
|
||||
```sql
|
||||
,
|
||||
CONSTRAINT chk_supplier_projects_subject_code
|
||||
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89))
|
||||
```
|
||||
|
||||
Заменить индекс `supplier_projects_platform_unique_key_unique` ([schema.sql:929-930](../../../db/schema.sql#L929)) на:
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX supplier_projects_platform_key_subject_unique
|
||||
ON supplier_projects(platform, unique_key, subject_code) NULLS NOT DISTINCT;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Записать в `db/CHANGELOG_schema.md`** (новая запись сверху, над v8.25)
|
||||
|
||||
```markdown
|
||||
## v8.26 — 2026-05-20 — supplier_projects.subject_code (per-субъект экспорт)
|
||||
|
||||
`supplier_projects` +1 колонка `subject_code SMALLINT NULL` (1..89; NULL = пул «Вся РФ»),
|
||||
+1 CHECK `chk_supplier_projects_subject_code`. Unique-индекс
|
||||
`supplier_projects_platform_unique_key_unique` (platform, unique_key) → заменён на
|
||||
`supplier_projects_platform_key_subject_unique` (platform, unique_key, subject_code)
|
||||
NULLS NOT DISTINCT (пул «Вся РФ» уникален per источник×платформа).
|
||||
Эпик: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.2.
|
||||
Миграция: 2026_05_20_100000_supplier_projects_subject_code.php (Schema::hasColumn +
|
||||
pg_constraint guards). Индексы: −1 +1 (нет дельты count). RLS не затронут (SaaS-level).
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Сделать `subject_code` fillable в модели**
|
||||
|
||||
В `app/app/Models/SupplierProject.php` в `$fillable` ([SupplierProject.php:32](../../../app/app/Models/SupplierProject.php#L32)) добавить `'subject_code',`; в `casts()` ([:45](../../../app/app/Models/SupplierProject.php#L45)) добавить `'subject_code' => 'integer',`.
|
||||
|
||||
- [ ] **Step 7: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=SupplierProjectSubjectCodeTest`
|
||||
Expected: PASS (3 теста). Вывести полный вывод.
|
||||
|
||||
- [ ] **Step 8: squawk + pint + larastan на изменённое**
|
||||
|
||||
Run: `composer pint -- app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php app/app/Models/SupplierProject.php; composer stan`
|
||||
Expected: 0 ошибок (larastan может потребовать `php artisan ide-helper:models -W` для нового атрибута — выполнить при @property-ошибке).
|
||||
|
||||
- [ ] **Step 9: Коммит** (явный путь — shared-dir)
|
||||
|
||||
```bash
|
||||
git add -- app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php app/app/Models/SupplierProject.php app/tests/Feature/Supplier/SupplierProjectSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
git commit -m "feat(supplier): supplier_projects.subject_code + per-subject unique index (v8.26)" -- app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php app/app/Models/SupplierProject.php app/tests/Feature/Supplier/SupplierProjectSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: pivot-таблица `project_supplier_links` (замена 3 FK-слотов)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_20_101000_create_project_supplier_links.php`
|
||||
- Modify: `db/schema.sql` (после блока ALTER TABLE projects FK ~[schema.sql:947](../../../db/schema.sql#L947))
|
||||
- Modify: `db/CHANGELOG_schema.md`
|
||||
- Test: `app/tests/Feature/Supplier/ProjectSupplierLinkSchemaTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('creates pivot row linking project to supplier_project', function (): void {
|
||||
$project = Project::factory()->create();
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'link.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => 'B1',
|
||||
'subject_code' => 82,
|
||||
]);
|
||||
|
||||
expect(DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)->where('supplier_project_id', $sp->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('cascades pivot deletion when supplier_project is deleted', function (): void {
|
||||
$project = Project::factory()->create();
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'casc.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $sp->id, 'platform' => 'B2', 'subject_code' => 82,
|
||||
]);
|
||||
|
||||
$sp->delete();
|
||||
|
||||
expect(DB::table('project_supplier_links')->where('supplier_project_id', $sp->id)->exists())->toBeFalse();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=ProjectSupplierLinkSchemaTest`
|
||||
Expected: FAIL — таблицы `project_supplier_links` нет.
|
||||
|
||||
- [ ] **Step 3: Написать миграцию**
|
||||
|
||||
`app/database/migrations/2026_05_20_101000_create_project_supplier_links.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* M:N pivot между projects (tenant) и supplier_projects (SaaS, shared) — v8.26.
|
||||
*
|
||||
* Заменяет 3 FK-слота projects.supplier_b{1,2,3}_project_id (которые не вмещают
|
||||
* per-субъект модель: N субъектов × до 3 платформ = до 3N связей).
|
||||
* SaaS-level (без RLS, как supplier_projects): пишется sync-флоу, читается
|
||||
* sharing-флоу через BYPASSRLS-роль crm_supplier_worker.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$exists = DB::selectOne("SELECT to_regclass('public.project_supplier_links') AS r");
|
||||
if ($exists !== null && $exists->r !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE TABLE project_supplier_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
subject_code SMALLINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1', 'B2', 'B3')),
|
||||
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_psl_supplier_project ON project_supplier_links (supplier_project_id);
|
||||
CREATE INDEX idx_psl_project ON project_supplier_links (project_id);
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP TABLE IF EXISTS project_supplier_links');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Зеркалить в `db/schema.sql`** — после блока `ALTER TABLE projects ADD CONSTRAINT projects_supplier_b3_project_id_fk ...` ([schema.sql:947](../../../db/schema.sql#L947)) добавить:
|
||||
|
||||
```sql
|
||||
|
||||
-- v8.26: M:N pivot projects ↔ supplier_projects (замена 3 FK-слотов supplier_b{1,2,3}_project_id).
|
||||
CREATE TABLE project_supplier_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ»
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3')),
|
||||
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
|
||||
);
|
||||
CREATE INDEX idx_psl_supplier_project ON project_supplier_links(supplier_project_id);
|
||||
CREATE INDEX idx_psl_project ON project_supplier_links(project_id);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Записать в `db/CHANGELOG_schema.md`** (запись сверху)
|
||||
|
||||
```markdown
|
||||
## v8.26 (доп) — 2026-05-20 — project_supplier_links (M:N pivot)
|
||||
|
||||
+1 таблица SaaS-level `project_supplier_links` (project_id, supplier_project_id,
|
||||
platform, subject_code, created_at): M:N замена 3 FK-слотов
|
||||
projects.supplier_b{1,2,3}_project_id (per-субъект модель). +2 FK (оба ON DELETE
|
||||
CASCADE), +1 CHECK chk_psl_platform, +1 UNIQUE uq_psl_project_supplier, +2 индекса.
|
||||
Без RLS (как supplier_projects). Старые FK-колонки остаются (двойная запись) до
|
||||
follow-up. Миграция: 2026_05_20_101000_create_project_supplier_links.php.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=ProjectSupplierLinkSchemaTest`
|
||||
Expected: PASS (2). Полный вывод.
|
||||
|
||||
- [ ] **Step 7: squawk-чек миграции**
|
||||
|
||||
Run: `composer pint -- app/database/migrations/2026_05_20_101000_create_project_supplier_links.php`
|
||||
Expected: OK. (squawk прогонится в pre-commit; CREATE TABLE без backfill — safe.)
|
||||
|
||||
- [ ] **Step 8: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/database/migrations/2026_05_20_101000_create_project_supplier_links.php app/tests/Feature/Supplier/ProjectSupplierLinkSchemaTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
git commit -m "feat(supplier): project_supplier_links M:N pivot (v8.26)" -- app/database/migrations/2026_05_20_101000_create_project_supplier_links.php app/tests/Feature/Supplier/ProjectSupplierLinkSchemaTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `deals.subject_code` (регион-тег поставщика)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_20_102000_deals_subject_code.php`
|
||||
- Modify: `db/schema.sql` (блок `CREATE TABLE deals` рядом с `region_code` [schema.sql:1612](../../../db/schema.sql#L1612))
|
||||
- Modify: `db/CHANGELOG_schema.md`
|
||||
- Test: `app/tests/Feature/Supplier/DealSubjectCodeTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
it('deals has nullable subject_code column', function (): void {
|
||||
expect(Schema::hasColumn('deals', 'subject_code'))->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=DealSubjectCodeTest`
|
||||
Expected: FAIL — колонки нет.
|
||||
|
||||
- [ ] **Step 3: Написать миграцию**
|
||||
|
||||
`app/database/migrations/2026_05_20_102000_deals_subject_code.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* deals.subject_code — субъект РФ из тега поставщика (raw_payload['tag']) — v8.26.
|
||||
*
|
||||
* Источник истины региона сделки = тег проекта у поставщика (надёжнее phone-prefix
|
||||
* для мобильных). Отдельно от deals.region_code (ISO-3166, phone-derived).
|
||||
* deals партиционирована — ADD COLUMN наследуется партициями.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('deals', 'subject_code')) {
|
||||
DB::statement('ALTER TABLE deals ADD COLUMN subject_code SMALLINT');
|
||||
}
|
||||
|
||||
DB::statement(
|
||||
"COMMENT ON COLUMN deals.subject_code IS "
|
||||
."'Субъект РФ 1..89 из тега поставщика (raw_payload[tag]). NULL = «Вся РФ»/неизвестно. v8.26.'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('deals', 'subject_code')) {
|
||||
Schema::table('deals', fn ($t) => $t->dropColumn('subject_code'));
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Зеркалить в `db/schema.sql`** — в блоке `CREATE TABLE deals` после строки `region_code VARCHAR(8),` ([schema.sql:1612](../../../db/schema.sql#L1612)) добавить:
|
||||
|
||||
```sql
|
||||
subject_code SMALLINT, -- v8.26: субъект РФ 1..89 из тега поставщика (raw_payload[tag]); NULL = вся РФ/неизвестно
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Записать в `db/CHANGELOG_schema.md`** (сверху)
|
||||
|
||||
```markdown
|
||||
## v8.26 (доп) — 2026-05-20 — deals.subject_code
|
||||
|
||||
`deals` +1 колонка `subject_code SMALLINT NULL` — субъект РФ из тега поставщика
|
||||
(raw_payload[tag]); отдельно от region_code (ISO, phone-derived). Наследуется
|
||||
12 партициями. Миграция: 2026_05_20_102000_deals_subject_code.php.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Сделать `subject_code` fillable в `Deal`**
|
||||
|
||||
В `app/app/Models/Deal.php` добавить `'subject_code'` в `$fillable` и `'subject_code' => 'integer'` в `casts()`.
|
||||
|
||||
- [ ] **Step 7: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=DealSubjectCodeTest`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 8: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/database/migrations/2026_05_20_102000_deals_subject_code.php app/app/Models/Deal.php app/tests/Feature/Supplier/DealSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
git commit -m "feat(supplier): deals.subject_code from supplier tag (v8.26)" -- app/database/migrations/2026_05_20_102000_deals_subject_code.php app/app/Models/Deal.php app/tests/Feature/Supplier/DealSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: seed `system_settings.supplier_export_mode`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php`
|
||||
- Modify: `db/CHANGELOG_schema.md`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierExportModeSeedTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('seeds supplier_export_mode = batch by default', function (): void {
|
||||
$row = DB::table('system_settings')->where('key', 'supplier_export_mode')->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->value)->toBe('batch')
|
||||
->and($row->type)->toBe('string');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierExportModeSeedTest`
|
||||
Expected: FAIL — строки нет.
|
||||
|
||||
- [ ] **Step 3: Написать миграцию (idempotent insert)**
|
||||
|
||||
`app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Глобальный тумблер режима экспорта проектов поставщику (v8.26).
|
||||
* 'batch' (default, прод-безопасно) | 'online'. Резолвится SupplierExportMode (План 3).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$exists = DB::table('system_settings')->where('key', 'supplier_export_mode')->exists();
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('system_settings')->insert([
|
||||
'key' => 'supplier_export_mode',
|
||||
'value' => 'batch',
|
||||
'type' => 'string',
|
||||
'description' => 'Режим экспорта проектов поставщику: batch (ночной 18:00) | online (сразу при правке).',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->delete();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Записать в `db/CHANGELOG_schema.md`** (сверху)
|
||||
|
||||
```markdown
|
||||
## v8.26 (доп) — 2026-05-20 — seed system_settings.supplier_export_mode
|
||||
|
||||
Сид-строка `supplier_export_mode='batch'` (тумблер режима экспорта; online|batch).
|
||||
Не структурное изменение. Миграция: 2026_05_20_103000_seed_supplier_export_mode.php.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=SupplierExportModeSeedTest`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php app/tests/Feature/Supplier/SupplierExportModeSeedTest.php db/CHANGELOG_schema.md
|
||||
git commit -m "feat(supplier): seed supplier_export_mode toggle (v8.26)" -- app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php app/tests/Feature/Supplier/SupplierExportModeSeedTest.php db/CHANGELOG_schema.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Eloquent-связи Project ↔ SupplierProject через pivot
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Models/SupplierProject.php` (relation `projects()`)
|
||||
- Modify: `app/app/Models/Project.php` (relation `supplierProjects()`)
|
||||
- Test: `app/tests/Feature/Supplier/ProjectSupplierRelationTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
|
||||
it('links project to supplier projects via belongsToMany pivot', function (): void {
|
||||
$project = Project::factory()->create();
|
||||
$sp1 = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'rel.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$sp2 = SupplierProject::query()->create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'rel.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$project->supplierProjects()->attach([
|
||||
$sp1->id => ['platform' => 'B1', 'subject_code' => 82],
|
||||
$sp2->id => ['platform' => 'B2', 'subject_code' => 82],
|
||||
]);
|
||||
|
||||
expect($project->supplierProjects()->count())->toBe(2)
|
||||
->and($sp1->projects()->pluck('projects.id')->all())->toContain($project->id)
|
||||
->and($project->supplierProjects->first()->pivot->platform)->not->toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=ProjectSupplierRelationTest`
|
||||
Expected: FAIL — методов `supplierProjects()` / `projects()` нет.
|
||||
|
||||
- [ ] **Step 3: Добавить связь в `SupplierProject`**
|
||||
|
||||
В `app/app/Models/SupplierProject.php` добавить импорт `use Illuminate\Database\Eloquent\Relations\BelongsToMany;` и метод:
|
||||
|
||||
```php
|
||||
/**
|
||||
* @return BelongsToMany<Project, $this>
|
||||
*/
|
||||
public function projects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Project::class, 'project_supplier_links')
|
||||
->withPivot(['platform', 'subject_code']);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Добавить связь в `Project`**
|
||||
|
||||
В `app/app/Models/Project.php` добавить импорт `use Illuminate\Database\Eloquent\Relations\BelongsToMany;` и метод:
|
||||
|
||||
```php
|
||||
/**
|
||||
* @return BelongsToMany<SupplierProject, $this>
|
||||
*/
|
||||
public function supplierProjects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(SupplierProject::class, 'project_supplier_links')
|
||||
->withPivot(['platform', 'subject_code']);
|
||||
}
|
||||
```
|
||||
|
||||
(Если `use App\Models\SupplierProject;` ещё не импортирован — добавить.)
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=ProjectSupplierRelationTest`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 6: pint + larastan**
|
||||
|
||||
Run: `composer pint -- app/app/Models/SupplierProject.php app/app/Models/Project.php; composer stan`
|
||||
Expected: 0 ошибок (при @property/relation-ошибке — `php artisan ide-helper:models -W` для обеих моделей, закоммитить обновлённый helper-файл если меняется).
|
||||
|
||||
- [ ] **Step 7: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Models/SupplierProject.php app/app/Models/Project.php app/tests/Feature/Supplier/ProjectSupplierRelationTest.php
|
||||
git commit -m "feat(supplier): Project<->SupplierProject belongsToMany via pivot" -- app/app/Models/SupplierProject.php app/app/Models/Project.php app/tests/Feature/Supplier/ProjectSupplierRelationTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Backfill pivot из существующих `supplier_b{1,2,3}_project_id`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php`
|
||||
- Test: `app/tests/Feature/Supplier/BackfillProjectSupplierLinksTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('backfills pivot rows from legacy supplier_b{1,2,3}_project_id slots', function (): void {
|
||||
$sp1 = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bf.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$sp3 = SupplierProject::query()->create([
|
||||
'platform' => 'B3', 'signal_type' => 'site', 'unique_key' => 'bf.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'supplier_b1_project_id' => $sp1->id,
|
||||
'supplier_b3_project_id' => $sp3->id,
|
||||
]);
|
||||
|
||||
// Симулируем «до бэкофилла»: pivot пуст.
|
||||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||||
|
||||
// Запуск логики бэкофилла повторно (миграция идемпотентна).
|
||||
require_once base_path('database/migrations/2026_05_20_104000_backfill_project_supplier_links.php');
|
||||
(include base_path('database/migrations/2026_05_20_104000_backfill_project_supplier_links.php'))->up();
|
||||
|
||||
$rows = DB::table('project_supplier_links')->where('project_id', $project->id)->get();
|
||||
expect($rows)->toHaveCount(2)
|
||||
->and($rows->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B3']);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=BackfillProjectSupplierLinksTest`
|
||||
Expected: FAIL — файла миграции нет.
|
||||
|
||||
- [ ] **Step 3: Написать миграцию бэкофилла**
|
||||
|
||||
`app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Бэкофилл pivot project_supplier_links из legacy supplier_b{1,2,3}_project_id (v8.26).
|
||||
*
|
||||
* Для каждого ненулевого слота → строка pivot (subject_code=NULL: legacy-записи без
|
||||
* субъекта). Идемпотентно — ON CONFLICT DO NOTHING по uq_psl_project_supplier.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['B1' => 'supplier_b1_project_id', 'B2' => 'supplier_b2_project_id', 'B3' => 'supplier_b3_project_id'] as $platform => $col) {
|
||||
DB::statement(
|
||||
'INSERT INTO project_supplier_links (project_id, supplier_project_id, platform, subject_code, created_at) '
|
||||
."SELECT id, {$col}, ?, NULL, NOW() FROM projects WHERE {$col} IS NOT NULL "
|
||||
.'ON CONFLICT (project_id, supplier_project_id) DO NOTHING',
|
||||
[$platform]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Бэкофилл-данные не откатываем точечно (pivot живёт дальше); no-op.
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=BackfillProjectSupplierLinksTest`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 5: pint**
|
||||
|
||||
Run: `composer pint -- app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php`
|
||||
Expected: OK.
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php app/tests/Feature/Supplier/BackfillProjectSupplierLinksTest.php
|
||||
git commit -m "feat(supplier): backfill project_supplier_links from legacy FK slots" -- app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php app/tests/Feature/Supplier/BackfillProjectSupplierLinksTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Регрессия фундамента
|
||||
|
||||
**Files:** нет (проверка).
|
||||
|
||||
- [ ] **Step 1: migrate:fresh на чистой БД**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing`
|
||||
Expected: все миграции прошли, 0 ошибок (schema.sql v8.26 + 5 новых инкрементальных миграций идемпотентны).
|
||||
|
||||
- [ ] **Step 2: Полный Supplier+Integration suite**
|
||||
|
||||
Run: `php artisan test --testsuite=Feature -- --filter='Supplier|Integration|Project'` (или `composer test -- --group=supplier` если группа есть; иначе `php artisan test app/tests/Feature/Supplier`)
|
||||
Expected: GREEN, 0 регрессий относительно baseline. Выписать число passed/failed; любой failed — с file:line.
|
||||
|
||||
- [ ] **Step 3: pint + larastan на всём изменённом**
|
||||
|
||||
Run: `composer pint; composer stan`
|
||||
Expected: 0 ошибок (larastan baseline без новых записей; при необходимости regen ide-helper).
|
||||
|
||||
- [ ] **Step 4 (verification-before-completion):** перед заявлением «План 1 готов» — invoke `superpowers:verification-before-completion`, привести фактический вывод `migrate:fresh` + теста, не «должно проходить».
|
||||
|
||||
---
|
||||
|
||||
## Self-review (выполнен при написании плана)
|
||||
|
||||
- **Покрытие spec §4.2:** subject_code (T1), pivot (T2), deals.subject_code (T3), system_settings seed (T4), модели/связи (T5); §5 миграция/бэкофилл (T6). Резолвер `SupplierExportMode::current()`, формула заказа, save-маппинг, админка, ЛК — **вне Плана 1** (Планы 2-4, см. roadmap в spec/комментарии).
|
||||
- **Без плейсхолдеров:** весь код приведён; «if squawk flags» снят — CHECK сразу NOT VALID + VALIDATE (squawk-safe).
|
||||
- **Согласованность типов:** `subject_code SMALLINT`/cast integer везде; pivot withPivot(['platform','subject_code']) — те же поля во всех тасках; имена индексов/констрейнтов уникальны.
|
||||
- **Известное ограничение:** Task 6 тест дважды вызывает `up()` (idempotent ON CONFLICT) — проверяет повторный прогон; если в проекте тест-БД не несёт legacy FK-колонок (они ещё есть, §4.2 двойная запись) — ок.
|
||||
@@ -0,0 +1,641 @@
|
||||
# Переделка миграции проектов — План 2: входящее распределение лидов
|
||||
|
||||
> **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:** Переписать маршрутизацию входящего лида: cap=3 (лид уходит максимум 3 случайным недобравшим клиентам), eligibility — структурно через pivot `project_supplier_links` (без phone-фильтра по `region_mask`), регион сделки — из тега поставщика (`raw_payload['tag']` → `deals.subject_code`).
|
||||
|
||||
**Architecture:** `LeadRouter` фильтрует кандидатов запросом через pivot (заменяет `match($platform)` по FK-колонке), убирает PHP-фильтр `PhonePrefixService::phoneMatchesRegions`. Новый pure-сервис `LeadDistributor` отбирает ≤3 получателей через инъектируемый сидируемый `\Random\Randomizer` (детерминизм тестов). `RegionTagResolver` (зеркало `resources/js/constants/regions.ts`) резолвит имя субъекта из тега в код 1..89. `RouteSupplierLeadJob` оркеструет: matched → distributor (cap) → deal.subject_code из тега.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Eloquent / `\Random\Randomizer` (PHP 8.2 seedable RNG) / Pest 4.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../specs/2026-05-20-project-migration-redesign-design.md) §4.5 (часть распределения), §4.6, §4.4.
|
||||
|
||||
**Prereq:** План 1 (фундамент) исполнен — есть `project_supplier_links`, `supplier_projects.subject_code`, `deals.subject_code`, связи belongsToMany. **Зависимость от Плана 3 отсутствует:** в проде pivot пока заполнен только бэкофиллом (legacy-связи, subject_code=NULL) — LeadRouter работает и на них; per-субъект связи появятся в Плане 3. Формула заказа (`SupplierQuotaAllocator`) — в Плане 3 (с её вызывающим sync-джобом), НЕ здесь.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `RegionTagResolver` — тег субъекта → код 1..89
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Support/RussianRegions.php` (канонический словарь субъектов — зеркало `resources/js/constants/regions.ts`)
|
||||
- Create: `app/app/Services/RegionTagResolver.php`
|
||||
- Test: `app/tests/Unit/Services/RegionTagResolverTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Support\RussianRegions;
|
||||
|
||||
it('resolves subject name to code', function (): void {
|
||||
$r = new RegionTagResolver;
|
||||
expect($r->resolve('Москва'))->toBe(82)
|
||||
->and($r->resolve('Санкт-Петербург'))->toBe(83)
|
||||
->and($r->resolve('Республика Адыгея'))->toBe(1);
|
||||
});
|
||||
|
||||
it('returns null for «РФ» pool tag, empty and unknown', function (): void {
|
||||
$r = new RegionTagResolver;
|
||||
expect($r->resolve('РФ'))->toBeNull()
|
||||
->and($r->resolve(''))->toBeNull()
|
||||
->and($r->resolve('Нарния'))->toBeNull();
|
||||
});
|
||||
|
||||
it('canonical region map mirrors regions.ts — exactly 89 subjects', function (): void {
|
||||
expect(count(RussianRegions::CODE_TO_NAME))->toBe(89);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=RegionTagResolverTest`
|
||||
Expected: FAIL — классов нет.
|
||||
|
||||
- [ ] **Step 3: Создать `RussianRegions` (зеркало regions.ts, 89 субъектов)**
|
||||
|
||||
`app/app/Support/RussianRegions.php` — карта `code => name` для кодов **1..89** (sentinel 0 «Вся РФ» НЕ включаем). Источник истины — [resources/js/constants/regions.ts](../../../app/resources/js/constants/regions.ts): скопировать ВСЕ 89 пар `{ code, name }` (code 1..89), сохранив точные названия (включая диакритику «Северная Осетия — Алания», «Республика Саха (Якутия)»).
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Канонический справочник субъектов РФ (1..89) — PHP-зеркало
|
||||
* resources/js/constants/regions.ts (конституционный порядок, ст. 65).
|
||||
* Sentinel 0 «Вся РФ» не входит (= NULL subject_code / пустой regions).
|
||||
*
|
||||
* ВАЖНО: при правке regions.ts синхронно править этот файл (тест RegionTagResolverTest
|
||||
* «mirrors regions.ts — exactly 89» ловит расхождение по count, но не по именам —
|
||||
* сверять имена вручную при изменениях).
|
||||
*/
|
||||
final class RussianRegions
|
||||
{
|
||||
/** @var array<int, string> code(1..89) => официальное имя субъекта */
|
||||
public const CODE_TO_NAME = [
|
||||
1 => 'Республика Адыгея',
|
||||
2 => 'Республика Алтай',
|
||||
// … скопировать коды 3..81 из regions.ts (республики/края/области) …
|
||||
82 => 'Москва',
|
||||
83 => 'Санкт-Петербург',
|
||||
84 => 'Севастополь',
|
||||
85 => 'Еврейская автономная область',
|
||||
86 => 'Ненецкий автономный округ',
|
||||
87 => 'Ханты-Мансийский автономный округ — Югра',
|
||||
88 => 'Чукотский автономный округ',
|
||||
89 => 'Ямало-Ненецкий автономный округ',
|
||||
];
|
||||
|
||||
/** @return array<string, int> name => code (обратный индекс) */
|
||||
public static function nameToCode(): array
|
||||
{
|
||||
return array_flip(self::CODE_TO_NAME);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**NB исполнителю:** заполнить все 89 строк из regions.ts (в примере выше пропущены 3..81 — скопировать дословно). Тест Step 1 «exactly 89» провалится, если не все.
|
||||
|
||||
- [ ] **Step 4: Создать `RegionTagResolver`**
|
||||
|
||||
`app/app/Services/RegionTagResolver.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
|
||||
/**
|
||||
* Резолвит регион-тег поставщика (raw_payload['tag'] = имя субъекта или «РФ»)
|
||||
* в код субъекта 1..89. «РФ»/пусто/неизвестно → null (пул «Вся РФ»/неизвестно).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.4.
|
||||
*/
|
||||
final class RegionTagResolver
|
||||
{
|
||||
public function resolve(string $tag): ?int
|
||||
{
|
||||
$tag = trim($tag);
|
||||
|
||||
if ($tag === '' || $tag === 'РФ') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RussianRegions::nameToCode()[$tag] ?? null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=RegionTagResolverTest`
|
||||
Expected: PASS (3). Полный вывод. (Если «exactly 89» падает — не все субъекты скопированы из regions.ts.)
|
||||
|
||||
- [ ] **Step 6: pint + larastan**
|
||||
|
||||
Run: `composer pint -- app/app/Support/RussianRegions.php app/app/Services/RegionTagResolver.php; composer stan`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 7: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Support/RussianRegions.php app/app/Services/RegionTagResolver.php app/tests/Unit/Services/RegionTagResolverTest.php
|
||||
git commit -m "feat(supplier): RegionTagResolver + RussianRegions (subject name->code)" -- app/app/Support/RussianRegions.php app/app/Services/RegionTagResolver.php app/tests/Unit/Services/RegionTagResolverTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `LeadRouter` — eligibility через pivot, без phone-фильтра
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/LeadRouter.php` (весь класс — убрать `PhonePrefixService`, переписать запрос на pivot, сигнатура `matchEligibleProjects` без `$phone`)
|
||||
- Modify: `app/tests/Feature/Services/LeadRouterTest.php` (region-filter кейсы → pivot-кейсы; вызовы без `$phone`)
|
||||
- Modify: `app/tests/Feature/Supplier/SupplierConnectionTest.php:100` (вызов без `$phone`)
|
||||
|
||||
- [ ] **Step 1: Переписать тест `LeadRouterTest` под pivot**
|
||||
|
||||
Заменить region-filter кейсы (include/exclude по `region_mask`) на eligibility-через-pivot. Базовый каркас (адаптировать существующие helper'ы фабрик из текущего файла; ключевая смена — кандидат входит, только если есть строка pivot на этот supplier_project):
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\LeadRouter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function linkProjectToSupplier(Project $project, SupplierProject $sp): void
|
||||
{
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'subject_code' => $sp->subject_code,
|
||||
]);
|
||||
}
|
||||
|
||||
it('returns project linked via pivot to the supplier_project', function (): void {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($project, $sp);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(1)
|
||||
->and($matched->first()->id)->toBe($project->id);
|
||||
});
|
||||
|
||||
it('excludes project NOT linked to this supplier_project', function (): void {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r2.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]); // не линкуем
|
||||
|
||||
expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('excludes inactive project, project at limit, and zero-balance tenant', function (): void {
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r3.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$t1 = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$inactive = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => false, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($inactive, $sp);
|
||||
|
||||
$atLimit = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => true, 'daily_limit_target' => 5, 'delivered_today' => 5, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($atLimit, $sp);
|
||||
|
||||
$t0 = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => 0]);
|
||||
$broke = Project::factory()->create(['tenant_id' => $t0->id, 'is_active' => true, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($broke, $sp);
|
||||
|
||||
expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
|
||||
});
|
||||
```
|
||||
|
||||
(Сохранить из старого файла прочие нерегиональные кейсы, заменив вызовы `matchEligibleProjects($supplier, '799...')` → `matchEligibleProjects($supplier)` и добавив `linkProjectToSupplier()` где проект должен быть eligible. Региональные кейсы include/exclude по `region_mask` — **удалить** (фильтр снят).)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=LeadRouterTest`
|
||||
Expected: FAIL — сигнатура ещё с `$phone` + фильтр по region даёт другой результат / pivot не учитывается.
|
||||
|
||||
- [ ] **Step 3: Переписать `LeadRouter`**
|
||||
|
||||
`app/app/Services/LeadRouter.php` — полностью:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
|
||||
*
|
||||
* Eligibility — структурно через pivot project_supplier_links: проект eligible,
|
||||
* если связан с пришедшим supplier_project (= источник × субъект) + активен +
|
||||
* сегодня рабочий день + есть остаток лимита + у тенанта есть баланс.
|
||||
*
|
||||
* Регион сопоставляется самим supplier_project (тег = субъект) — phone-prefix
|
||||
* фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
|
||||
* гарантирован тем, через какой supplier_project пришёл лид.
|
||||
*
|
||||
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) — в
|
||||
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
|
||||
*/
|
||||
class LeadRouter
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
|
||||
{
|
||||
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
/** @var Collection<int, Project> $candidates */
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->whereExists(function ($q) use ($supplierProject): void {
|
||||
$q->selectRaw('1')
|
||||
->from('project_supplier_links')
|
||||
->whereColumn('project_supplier_links.project_id', 'projects.id')
|
||||
->where('project_supplier_links.supplier_project_id', $supplierProject->id);
|
||||
})
|
||||
->where('is_active', true)
|
||||
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
|
||||
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
|
||||
->whereExists(function ($q): void {
|
||||
$q->selectRaw('1')
|
||||
->from('tenants')
|
||||
->whereColumn('tenants.id', 'projects.tenant_id')
|
||||
->where(function ($qq): void {
|
||||
$qq->where('tenants.balance_leads', '>', 0)
|
||||
->orWhere('tenants.balance_rub', '>', 0);
|
||||
});
|
||||
})
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return $candidates->values();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Обновить прочих вызывающих (drop `$phone`)**
|
||||
|
||||
- `app/tests/Feature/Supplier/SupplierConnectionTest.php:100`: `matchEligibleProjects($supplier, '79991234567')` → `matchEligibleProjects($supplier)` (+ убедиться, что проект там линкуется через pivot, иначе ожидание скорректировать).
|
||||
- `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php:489`: mock `shouldReceive('matchEligibleProjects')->with(...)` — снять аргумент phone из `with()` (если задан), вернуть Collection как раньше.
|
||||
|
||||
- [ ] **Step 5: Прогнать LeadRouter + связанные**
|
||||
|
||||
Run: `php artisan test --filter='LeadRouterTest|SupplierConnectionTest'`
|
||||
Expected: PASS. Полный вывод; любой failed — с file:line.
|
||||
|
||||
- [ ] **Step 6: pint + larastan**
|
||||
|
||||
Run: `composer pint -- app/app/Services/LeadRouter.php; composer stan`
|
||||
Expected: 0 ошибок. (`PhonePrefixService` остаётся в проекте — его юзает `ProjectService` dual-write; `PhonePrefixServiceTest` не трогаем.)
|
||||
|
||||
- [ ] **Step 7: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Services/LeadRouter.php app/tests/Feature/Services/LeadRouterTest.php app/tests/Feature/Supplier/SupplierConnectionTest.php
|
||||
git commit -m "feat(supplier): LeadRouter eligibility via pivot, drop phone region filter" -- app/app/Services/LeadRouter.php app/tests/Feature/Services/LeadRouterTest.php app/tests/Feature/Supplier/SupplierConnectionTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `LeadDistributor` — cap=3 случайных получателя (детерминируемо)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/LeadDistributor.php`
|
||||
- Test: `app/tests/Unit/Services/LeadDistributorTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\LeadDistributor;
|
||||
use Illuminate\Support\Collection;
|
||||
use Random\Engine\Mt19937;
|
||||
use Random\Randomizer;
|
||||
|
||||
function projects(int $n): Collection
|
||||
{
|
||||
return collect(range(1, $n))->map(fn (int $i) => (object) ['id' => $i]);
|
||||
}
|
||||
|
||||
it('returns all when eligible count <= cap (3)', function (): void {
|
||||
$d = new LeadDistributor;
|
||||
expect($d->selectRecipients(projects(2)))->toHaveCount(2)
|
||||
->and($d->selectRecipients(projects(3)))->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('caps at 3 when more eligible', function (): void {
|
||||
$d = new LeadDistributor;
|
||||
expect($d->selectRecipients(projects(7)))->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('selection is a subset of eligible and deterministic under seeded RNG', function (): void {
|
||||
$eligible = projects(7);
|
||||
$d = new LeadDistributor(new Randomizer(new Mt19937(42)));
|
||||
$picked = $d->selectRecipients($eligible)->pluck('id')->all();
|
||||
|
||||
expect($picked)->toHaveCount(3)
|
||||
->and(collect($picked)->every(fn ($id) => $id >= 1 && $id <= 7))->toBeTrue();
|
||||
|
||||
// тот же seed → тот же выбор
|
||||
$d2 = new LeadDistributor(new Randomizer(new Mt19937(42)));
|
||||
expect($d2->selectRecipients($eligible)->pluck('id')->all())->toBe($picked);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=LeadDistributorTest`
|
||||
Expected: FAIL — класса нет.
|
||||
|
||||
- [ ] **Step 3: Создать `LeadDistributor`**
|
||||
|
||||
`app/app/Services/LeadDistributor.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Random\Randomizer;
|
||||
|
||||
/**
|
||||
* Отбор получателей входящего лида: ≤ CAP случайных из eligible (sharing cap).
|
||||
*
|
||||
* cap=3 — защита владельца номера-донора (лид продаётся максимум 3 раза).
|
||||
* Eligible уже отфильтрован LeadRouter (есть остаток лимита) → отбор лимит не
|
||||
* превышает. Рандом через инъектируемый \Random\Randomizer (тесты сидируют
|
||||
* Mt19937 для детерминизма; прод — CSPRNG по умолчанию).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.6.
|
||||
*/
|
||||
final class LeadDistributor
|
||||
{
|
||||
public const CAP = 3;
|
||||
|
||||
public function __construct(private readonly Randomizer $randomizer = new Randomizer) {}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param Collection<int, T> $eligible
|
||||
* @return Collection<int, T>
|
||||
*/
|
||||
public function selectRecipients(Collection $eligible): Collection
|
||||
{
|
||||
$items = $eligible->values()->all();
|
||||
|
||||
if (count($items) <= self::CAP) {
|
||||
return collect($items);
|
||||
}
|
||||
|
||||
$keys = $this->randomizer->pickArrayKeys($items, self::CAP);
|
||||
|
||||
return collect($keys)->map(fn (int $k) => $items[$k])->values();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=LeadDistributorTest`
|
||||
Expected: PASS (3). Полный вывод.
|
||||
|
||||
- [ ] **Step 5: pint + larastan**
|
||||
|
||||
Run: `composer pint -- app/app/Services/LeadDistributor.php; composer stan`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Services/LeadDistributor.php app/tests/Unit/Services/LeadDistributorTest.php
|
||||
git commit -m "feat(supplier): LeadDistributor cap=3 seedable random selection" -- app/app/Services/LeadDistributor.php app/tests/Unit/Services/LeadDistributorTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `RouteSupplierLeadJob` — cap=3 + регион в deal
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php` (handle: distributor; createDealCopyForProject: deal.subject_code из тега)
|
||||
- Modify: `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` (cap=3 кейс, mock matchEligibleProjects без phone, seeded LeadDistributor bind)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест (cap=3 + регион)**
|
||||
|
||||
Добавить в `RouteSupplierLeadJobTest` (адаптировать существующие фабрики/helpers файла):
|
||||
|
||||
```php
|
||||
it('caps deal creation at 3 recipients and tags deal with subject from payload', function (): void {
|
||||
// seeded distributor — детерминизм
|
||||
$this->app->bind(\App\Services\LeadDistributor::class, fn () => new \App\Services\LeadDistributor(
|
||||
new \Random\Randomizer(new \Random\Engine\Mt19937(7))
|
||||
));
|
||||
|
||||
$sp = \App\Models\SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
// 5 eligible клиентов, привязанных к sp через pivot, с балансом и лимитом
|
||||
foreach (range(1, 5) as $i) {
|
||||
$t = \App\Models\Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$p = \App\Models\Project::factory()->create([
|
||||
'tenant_id' => $t->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
\Illuminate\Support\Facades\DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $p->id, 'supplier_project_id' => $sp->id, 'platform' => 'B1', 'subject_code' => 82,
|
||||
]);
|
||||
}
|
||||
|
||||
$lead = \App\Models\SupplierLead::factory()->create([
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['project' => 'B1_cap.ru', 'tag' => 'Москва', 'vid' => 555111],
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
(new \App\Jobs\RouteSupplierLeadJob($lead->id))->handle(
|
||||
app(\App\Services\LeadRouter::class),
|
||||
app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
|
||||
app(\App\Services\DuplicateDetector::class),
|
||||
app(\App\Services\NotificationService::class),
|
||||
app(\App\Services\Billing\LedgerService::class),
|
||||
app(\App\Services\LeadDistributor::class),
|
||||
app(\App\Services\RegionTagResolver::class),
|
||||
);
|
||||
|
||||
$deals = \App\Models\Deal::query()->where('source_crm_id', 555111)->get();
|
||||
expect($deals)->toHaveCount(3)
|
||||
->and($deals->pluck('subject_code')->unique()->all())->toBe([82]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter='RouteSupplierLeadJobTest'`
|
||||
Expected: FAIL — handle() ещё без `LeadDistributor`/`RegionTagResolver` параметров; создаётся 5 deal'ов вместо 3; subject_code не пишется.
|
||||
|
||||
- [ ] **Step 3: Внести изменения в `RouteSupplierLeadJob`**
|
||||
|
||||
a) Импорты: добавить `use App\Services\LeadDistributor;` и `use App\Services\RegionTagResolver;`.
|
||||
|
||||
b) Сигнатура `handle()` — добавить два параметра в конец:
|
||||
|
||||
```php
|
||||
public function handle(
|
||||
LeadRouter $router,
|
||||
SupplierProjectResolver $resolver,
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
LeadDistributor $distributor,
|
||||
RegionTagResolver $tagResolver,
|
||||
): void {
|
||||
```
|
||||
|
||||
c) Заменить блок matched→loop ([RouteSupplierLeadJob.php:111-133](../../../app/app/Jobs/RouteSupplierLeadJob.php#L111)):
|
||||
|
||||
```php
|
||||
$matched = $router->matchEligibleProjects($supplier);
|
||||
$selected = $distributor->selectRecipients($matched); // cap=3 случайных
|
||||
|
||||
$subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
|
||||
|
||||
$createdCount = 0;
|
||||
$failures = [];
|
||||
foreach ($selected as $project) {
|
||||
try {
|
||||
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)) {
|
||||
$createdCount++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$failures[] = ['project_id' => $project->id, 'tenant_id' => $project->tenant_id, 'error' => $e->getMessage()];
|
||||
Log::warning('supplier_lead.per_project_routing_failed', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'project_id' => $project->id,
|
||||
'tenant_id' => $project->tenant_id,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($selected->isNotEmpty() && $createdCount === 0 && count($failures) === $selected->count()) {
|
||||
throw new RuntimeException(
|
||||
'All eligible projects failed routing for supplier_lead='.$lead->id.
|
||||
'; last error: '.($failures[array_key_last($failures)]['error'] ?? 'unknown')
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
d) `createDealCopyForProject` — добавить параметр `?int $subjectCode` и писать его в Deal::create:
|
||||
|
||||
```php
|
||||
private function createDealCopyForProject(
|
||||
SupplierLead $lead,
|
||||
Project $project,
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
?int $subjectCode,
|
||||
): bool {
|
||||
```
|
||||
|
||||
В `Deal::create([...])` ([RouteSupplierLeadJob.php:247](../../../app/app/Jobs/RouteSupplierLeadJob.php#L247)) добавить строку `'subject_code' => $subjectCode,` (рядом с `'status' => 'new',`).
|
||||
|
||||
- [ ] **Step 4: Прогнать тест cap+регион — зелёный**
|
||||
|
||||
Run: `php artisan test --filter='RouteSupplierLeadJobTest'`
|
||||
Expected: PASS (новый кейс + существующие). Полный вывод; failed — с file:line. (Существующие кейсы с ≤3 eligible не затронуты — distributor вернёт всех.)
|
||||
|
||||
- [ ] **Step 5: pint + larastan**
|
||||
|
||||
Run: `composer pint -- app/app/Jobs/RouteSupplierLeadJob.php; composer stan`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
|
||||
git commit -m "feat(supplier): RouteSupplierLeadJob cap=3 distribution + deal.subject_code from tag" -- app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Регрессия распределения
|
||||
|
||||
**Files:** нет (проверка).
|
||||
|
||||
- [ ] **Step 1: Полный Supplier + Jobs + Integration suite**
|
||||
|
||||
Run: `php artisan test app/tests/Feature/Supplier app/tests/Feature/Jobs app/tests/Feature/Services app/tests/Unit/Services`
|
||||
Expected: GREEN. Выписать passed/failed; любой failed — file:line. Особое внимание `AutoPauseFlowTest` (зависит от matchEligibleProjects is_active фильтра — он сохранён).
|
||||
|
||||
- [ ] **Step 2: pint + larastan по всему дереву**
|
||||
|
||||
Run: `composer pint; composer stan`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 3 (verification-before-completion):** перед заявлением «План 2 готов» — invoke `superpowers:verification-before-completion`; привести фактический вывод тестов, не «должно проходить».
|
||||
|
||||
---
|
||||
|
||||
## Self-review (выполнен при написании плана)
|
||||
|
||||
- **Покрытие spec §4.5/§4.6/§4.4:** pivot-eligibility + снятие phone-фильтра (T2); cap=3 рандом (T3) + интеграция в job (T4); регион→deals.subject_code из тега (T1+T4). Формула заказа §4.5 (заказ) — в Плане 3 (с вызывающим sync-джобом), отмечено в prereq.
|
||||
- **Без плейсхолдеров:** код приведён; единственное «дозаполнить» — 89 субъектов в `RussianRegions` из существующего regions.ts (концертный источник в репо + тест «exactly 89» как гейт; не TODO).
|
||||
- **Согласованность типов:** `matchEligibleProjects(SupplierProject): Collection` — единая новая сигнатура во всех вызовах (T2 Step 4 перечисляет всех вызывающих); `selectRecipients(Collection): Collection`; `RegionTagResolver::resolve(string): ?int`; `deals.subject_code` int — совпадает с Планом 1 Task 3.
|
||||
- **Риск:** `Randomizer::pickArrayKeys` доступен с PHP 8.2 (у нас 8.3 ✓). Детерминизм в job-тесте — через container-bind seeded `LeadDistributor`.
|
||||
@@ -0,0 +1,548 @@
|
||||
# Переделка миграции проектов — План 3: канал экспорта + формула заказа
|
||||
|
||||
> **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:** Переписать экспорт проектов поставщику: режимы online/batch (глобальный тумблер), per-субъект save (R7, tag=субъект), один multi-flag save B1+B2+B3 с захватом 3 external_id (R5), портал делит лимит сам (R6, наш split убран), формула заказа `max(наиб, ceil(Σ/3))`, запись связей в pivot.
|
||||
|
||||
**Architecture:** `SupplierExportMode` (резолвер тумблера `system_settings`). `SupplierQuotaAllocator` → pure `computeOrder()` (max(наиб, ceil(Σ/3))) + union workdays, без platform-split. `SupplierPortalClient::saveProjectMultiFlag()` — один save с 3 флагами → дочитывает `listProjects()` → возвращает `[platform => external_id]` (R-SAVE вариант а). `SyncSupplierProjectsJob` переписан с FK-итерации на **группировку (источник × субъект)** из активных Лидерра-проектов; создаёт/обновляет 3 supplier_projects (по платформе) на группу + пишет pivot. `SyncSupplierProjectJob` (онлайн) при mode=online шлёт полные параметры, иначе — каркас (как сейчас). `ProjectService` диспатчит онлайн-sync mode-aware.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / Playwright bridge (R-SAVE live smoke).
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../specs/2026-05-20-project-migration-redesign-design.md) §4.1, §4.3, §4.5 (часть заказа), §8 R-SAVE.
|
||||
|
||||
**Prereq:** Планы 1+2 исполнены (pivot, subject_code, RegionTagResolver, RussianRegions, LeadRouter на pivot). Жив supplier-портал + сессия (`refresh-session.js`) для Task 1 smoke.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: R-SAVE live smoke — захват 3 external_id из multi-flag save (ГЕЙТ)
|
||||
|
||||
> Гейт всего плана. До зелёного smoke прод-путь R5 не переписывать. Решение заказчика — **вариант а** (дочитать listProjects); fallback **б** (3 раздельных save) только если а провалится.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/storage/_smoke_multiflag_save.php` (временный tinker-скрипт; удалить после)
|
||||
- Create: `docs/discovery/2026-05-20-rsave-multiflag-finding.md` (фиксация результата)
|
||||
|
||||
- [ ] **Step 1: Написать smoke-скрипт**
|
||||
|
||||
`app/storage/_smoke_multiflag_save.php` — через живой `SupplierPortalClient` (Tier-1 AJAX):
|
||||
|
||||
1. POST `/admin/visit/rt-project-save` с payload, где `srcrt=srcbl=srcmt=true`, уникальный `name` (валидный домен, lowercase — recon §находка 1), `tag="Москва"`, `type=hosts`, `regions=[]`, `limit=9`.
|
||||
2. Запомнить `id` из ответа (последний из 3 — recon §находка 2).
|
||||
3. `listProjects()` → найти ВСЕ строки с `name == <наш>` И `tag == "Москва"` → собрать их `id`.
|
||||
4. Вывести: сколько проектов создалось (ожидаем 3), их id + платформы (поле `src`).
|
||||
5. Cleanup: `deleteProject()` по каждому из найденных id.
|
||||
|
||||
```php
|
||||
<?php
|
||||
// app/storage/_smoke_multiflag_save.php — запуск: php artisan tinker --execute="require 'storage/_smoke_multiflag_save.php';"
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
|
||||
$client = app(SupplierPortalClient::class);
|
||||
$name = 'rsave-smoke-'.date('His').'.example';
|
||||
$tag = 'Москва';
|
||||
|
||||
// 1. multi-flag save (срабатываем напрямую через request — saveProject() пока single-flag).
|
||||
$resp = app('supplier.smoke.multiflag')($name, $tag); // helper ниже ИЛИ inline request
|
||||
echo "save response id (last): ".$resp."\n";
|
||||
|
||||
// 3. дочитать список
|
||||
$all = $client->listProjects();
|
||||
$mine = array_values(array_filter($all, fn ($p) => ($p['name'] ?? '') === $name && ($p['tag'] ?? '') === $tag));
|
||||
echo "found projects by name+tag: ".count($mine)."\n";
|
||||
foreach ($mine as $p) {
|
||||
echo " id={$p['id']} src=".json_encode($p['src'] ?? null)."\n";
|
||||
}
|
||||
|
||||
// 5. cleanup
|
||||
foreach ($mine as $p) {
|
||||
$client->deleteProject((int) $p['id']);
|
||||
echo "deleted {$p['id']}\n";
|
||||
}
|
||||
```
|
||||
|
||||
(`src`/поле платформы в строке listProjects — уточнить по факту вывода; recon §listProjects описывает конверт `{projects:[{id,tag,src,name,type,...}]}`.)
|
||||
|
||||
- [ ] **Step 2: Прогнать smoke против живого портала**
|
||||
|
||||
Run: `cd app; php artisan tinker --execute="require base_path('storage/_smoke_multiflag_save.php');"`
|
||||
Expected: вывод `found projects by name+tag: 3` + 3 разные платформы + 3 успешных delete. Привести ПОЛНЫЙ вывод.
|
||||
|
||||
- [ ] **Step 3: Зафиксировать находку**
|
||||
|
||||
Создать `docs/discovery/2026-05-20-rsave-multiflag-finding.md`: результат (3 ли проекта; как платформа кодируется в `src`; по каким полям матчить). **Решение:** вариант а подтверждён ИЛИ откат на б (3 раздельных save). Этот файл — вход для Task 4.
|
||||
|
||||
- [ ] **Step 4: Удалить временный скрипт**
|
||||
|
||||
```bash
|
||||
rm app/storage/_smoke_multiflag_save.php
|
||||
git add -- docs/discovery/2026-05-20-rsave-multiflag-finding.md
|
||||
git commit -m "docs(supplier): R-SAVE multi-flag save live smoke finding" -- docs/discovery/2026-05-20-rsave-multiflag-finding.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `SupplierExportMode` — резолвер глобального тумблера
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Supplier/SupplierExportMode.php`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierExportModeTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('reads mode from system_settings, defaults batch', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
expect(SupplierExportMode::current())->toBe('online')
|
||||
->and(SupplierExportMode::isOnline())->toBeTrue();
|
||||
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
|
||||
expect(SupplierExportMode::current())->toBe('batch')
|
||||
->and(SupplierExportMode::isOnline())->toBeFalse();
|
||||
});
|
||||
|
||||
it('falls back to batch when setting missing', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->delete();
|
||||
expect(SupplierExportMode::current())->toBe('batch');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierExportModeTest`
|
||||
Expected: FAIL — класса нет.
|
||||
|
||||
- [ ] **Step 3: Создать резолвер**
|
||||
|
||||
`app/app/Services/Supplier/SupplierExportMode.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Глобальный режим экспорта проектов поставщику (system_settings).
|
||||
* 'online' — sync сразу при create/edit с полными параметрами;
|
||||
* 'batch' — каркас сразу + полные параметры ночным SyncSupplierProjectsJob (18:00).
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.1.
|
||||
*/
|
||||
final class SupplierExportMode
|
||||
{
|
||||
public const ONLINE = 'online';
|
||||
|
||||
public const BATCH = 'batch';
|
||||
|
||||
public static function current(): string
|
||||
{
|
||||
$value = DB::table('system_settings')->where('key', 'supplier_export_mode')->value('value');
|
||||
|
||||
return $value === self::ONLINE ? self::ONLINE : self::BATCH;
|
||||
}
|
||||
|
||||
public static function isOnline(): bool
|
||||
{
|
||||
return self::current() === self::ONLINE;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SupplierExportModeTest`
|
||||
Expected: PASS (2). Полный вывод.
|
||||
|
||||
- [ ] **Step 5: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint -- app/app/Services/Supplier/SupplierExportMode.php; composer stan
|
||||
git add -- app/app/Services/Supplier/SupplierExportMode.php app/tests/Feature/Supplier/SupplierExportModeTest.php
|
||||
git commit -m "feat(supplier): SupplierExportMode toggle resolver (online|batch)" -- app/app/Services/Supplier/SupplierExportMode.php app/tests/Feature/Supplier/SupplierExportModeTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `SupplierQuotaAllocator` — формула заказа max(наиб, ceil(Σ/3))
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Supplier/SupplierQuotaAllocator.php` (новый `computeOrder()`; удалить `distributeForPlatform`)
|
||||
- Test: `app/tests/Unit/Services/SupplierQuotaAllocatorTest.php` (переписать под формулу заказа)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест (verified-примеры из brief)**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\SupplierQuotaAllocator;
|
||||
|
||||
it('computeOrder = max(наибольший лимит, ceil(Σ/3))', function (array $limits, int $expected): void {
|
||||
expect(SupplierQuotaAllocator::computeOrder($limits))->toBe($expected);
|
||||
})->with([
|
||||
'brief 1' => [[5, 5, 10, 20], 20],
|
||||
'brief 2' => [array_merge(array_fill(0, 15, 5), [10]), 29], // 15×5 + 10 → Σ85, наиб10, ceil(85/3)=29
|
||||
'brief 3' => [[15, 15, 15], 15],
|
||||
'brief 4' => [[15, 15, 15, 30], 30],
|
||||
'brief 5' => [[10, 10, 10, 10], 14],
|
||||
'single' => [[7], 7],
|
||||
'empty' => [[], 0],
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierQuotaAllocatorTest`
|
||||
Expected: FAIL — метода `computeOrder` нет (старый `allocate`/`distributeForPlatform` ещё на месте).
|
||||
|
||||
- [ ] **Step 3: Добавить `computeOrder`, удалить `distributeForPlatform`**
|
||||
|
||||
В `app/app/Services/Supplier/SupplierQuotaAllocator.php`:
|
||||
|
||||
- Добавить статический метод:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Заказ у поставщика на (источник × субъект): max(наибольший лимит, ceil(Σ/3)).
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
* Один лимит на группу; портал делит на B1/B2/B3 сам (R6 — наш split убран).
|
||||
*
|
||||
* @param array<int, int> $dailyLimits лимиты eligible-сегодня клиентов группы
|
||||
*/
|
||||
public static function computeOrder(array $dailyLimits): int
|
||||
{
|
||||
if ($dailyLimits === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$sum = array_sum($dailyLimits);
|
||||
$max = max($dailyLimits);
|
||||
|
||||
return max($max, (int) ceil($sum / 3));
|
||||
}
|
||||
```
|
||||
|
||||
- Удалить метод `distributeForPlatform()` ([SupplierQuotaAllocator.php:73-95](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L73)). `allocate()` и `unionInts()` — переписываются/используются в Task 5 (sync rewrite); на этом шаге **оставить `allocate()` как есть**, но заменить внутри вызов `distributeForPlatform(...)` на `computeOrder($eligibleProjects->pluck('daily_limit')->all())` для лимита (временная совместимость до Task 5; платформенный split исчезает — лимит одинаков для всех платформ). Конкретно строку `$platformLimit = self::distributeForPlatform(...)` ([:59](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L59)) заменить на:
|
||||
|
||||
```php
|
||||
$platformLimit = self::computeOrder(
|
||||
$eligibleProjects->pluck('daily_limit')->map(fn ($v) => (int) $v)->all()
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SupplierQuotaAllocatorTest`
|
||||
Expected: PASS (7 датасетов). Полный вывод.
|
||||
|
||||
- [ ] **Step 5: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint -- app/app/Services/Supplier/SupplierQuotaAllocator.php; composer stan
|
||||
git add -- app/app/Services/Supplier/SupplierQuotaAllocator.php app/tests/Unit/Services/SupplierQuotaAllocatorTest.php
|
||||
git commit -m "feat(supplier): order formula max(max, ceil(sum/3)), drop platform split" -- app/app/Services/Supplier/SupplierQuotaAllocator.php app/tests/Unit/Services/SupplierQuotaAllocatorTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `SupplierPortalClient::saveProjectMultiFlag` — R5/R6/R7 save (вариант а)
|
||||
|
||||
> Точная реализация захвата id зависит от Task 1 finding. Ниже — вариант а (дочитать listProjects). Если smoke показал иное (б) — реализовать раздельные save по платформам.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Supplier/SupplierPortalClient.php` (новый `saveProjectMultiFlag()`; `toPayload` — tag из dto, multi-flag по набору платформ)
|
||||
- Modify: `app/app/Services/Supplier/Dto/SupplierProjectDto.php` (+поля `tag`, `platforms`)
|
||||
- Test: `app/tests/Feature/Supplier/SupplierPortalClientMultiFlagTest.php` (HTTP::fake)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест (HTTP::fake save + listProjects)**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('multi-flag save returns external_id per platform via listProjects', function (): void {
|
||||
Http::fake([
|
||||
'*/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'id' => '300'], 200),
|
||||
'*/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '100', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'rt'],
|
||||
['id' => '200', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'bl'],
|
||||
['id' => '300', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'mt'],
|
||||
['id' => '999', 'name' => 'other.ru', 'tag' => 'Москва', 'src' => 'rt'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'okna.ru', limit: 9,
|
||||
workdays: [1,2,3,4,5,6,7], regions: [82], regionsReverse: false, status: 'active',
|
||||
tag: 'Москва', platforms: ['B1','B2','B3'],
|
||||
);
|
||||
|
||||
$ids = app(SupplierPortalClient::class)->saveProjectMultiFlag($dto);
|
||||
|
||||
expect($ids)->toBe(['B1' => 100, 'B2' => 200, 'B3' => 300]);
|
||||
});
|
||||
```
|
||||
|
||||
(Маппинг `src`-кода портала → платформа — по Task 1 finding: `rt→B1, bl→B2, mt→B3` ориентировочно из `srcrt/srcbl/srcmt`. Уточнить по выводу smoke.)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierPortalClientMultiFlagTest`
|
||||
Expected: FAIL — метода нет, DTO без `tag`/`platforms`.
|
||||
|
||||
- [ ] **Step 3: Расширить DTO**
|
||||
|
||||
В `app/app/Services/Supplier/Dto/SupplierProjectDto.php` добавить в конструктор два параметра (с дефолтами для обратной совместимости single-flag путей):
|
||||
|
||||
```php
|
||||
public string $tag = '_lidpotok',
|
||||
/** @var array<int, string> */
|
||||
public array $platforms = [],
|
||||
```
|
||||
|
||||
(`platforms` пустой → single `[platform]`. `tag` дефолт `_lidpotok` — старые пути не ломаются.)
|
||||
|
||||
- [ ] **Step 4: `toPayload` — tag из dto + multi-flag**
|
||||
|
||||
В `toPayload` ([SupplierPortalClient.php:413](../../../app/app/Services/Supplier/SupplierPortalClient.php#L413)):
|
||||
|
||||
- `'tag' => $dto->tag,` (вместо хардкода `'_lidpotok'`).
|
||||
- Набор активных платформ: `$platforms = $dto->platforms !== [] ? $dto->platforms : [$dto->platform];` → `'srcrt' => in_array('B1', $platforms, true), 'srcbl' => in_array('B2', $platforms, true), 'srcmt' => in_array('B3', $platforms, true),`.
|
||||
- `'regions' => $dto->regions` уже есть — теперь несёт `[subject_code]` или `[]`.
|
||||
|
||||
- [ ] **Step 5: Добавить `saveProjectMultiFlag`**
|
||||
|
||||
```php
|
||||
/**
|
||||
* R5: один save с флагами всех dto->platforms → портал создаёт N rt-проектов,
|
||||
* портал делит лимит сам (R6). Ответ rt-project-save отдаёт id последнего →
|
||||
* дочитываем listProjects и матчим по name+tag (R-SAVE вариант а, Task 1 finding).
|
||||
*
|
||||
* @return array<string, int> [platform => external_id]
|
||||
*/
|
||||
public function saveProjectMultiFlag(SupplierProjectDto $dto): array
|
||||
{
|
||||
$response = $this->request(
|
||||
'POST', '/admin/visit/rt-project-save',
|
||||
$this->toPayload($dto, externalId: 0), asJson: true,
|
||||
);
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
|
||||
|
||||
$srcToPlatform = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
|
||||
$out = [];
|
||||
foreach ($this->listProjects() as $p) {
|
||||
if (($p['name'] ?? null) !== $dto->uniqueKey || ($p['tag'] ?? null) !== $dto->tag) {
|
||||
continue;
|
||||
}
|
||||
$platform = $srcToPlatform[$p['src'] ?? ''] ?? null;
|
||||
if ($platform !== null && in_array($platform, $dto->platforms !== [] ? $dto->platforms : [$dto->platform], true)) {
|
||||
$out[$platform] = (int) $p['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SupplierPortalClientMultiFlagTest`
|
||||
Expected: PASS. Полный вывод. (Старые `SupplierPortalClient` тесты — single-flag/`_lidpotok` дефолт — не затронуты; прогнать `--filter=SupplierPortalClient` целиком.)
|
||||
|
||||
- [ ] **Step 7: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint -- app/app/Services/Supplier/SupplierPortalClient.php app/app/Services/Supplier/Dto/SupplierProjectDto.php; composer stan
|
||||
git add -- app/app/Services/Supplier/SupplierPortalClient.php app/app/Services/Supplier/Dto/SupplierProjectDto.php app/tests/Feature/Supplier/SupplierPortalClientMultiFlagTest.php
|
||||
git commit -m "feat(supplier): saveProjectMultiFlag R5 + tag/platforms DTO (R6/R7)" -- app/app/Services/Supplier/SupplierPortalClient.php app/app/Services/Supplier/Dto/SupplierProjectDto.php app/tests/Feature/Supplier/SupplierPortalClientMultiFlagTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `SyncSupplierProjectsJob` — группировка (источник × субъект) + pivot
|
||||
|
||||
> Ядро рерайта. Переход от FK-итерации к группировке активных Лидерра-проектов по (источник × субъект); на группу — один multi-flag save + 3 supplier_projects (по платформе, subject_code) + pivot-связи.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php`
|
||||
- Modify: `app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php`
|
||||
|
||||
**Алгоритм `handle()` (новый):**
|
||||
|
||||
1. Загрузить активные Лидерра-projects (`is_active=true`, `archived_at IS NULL`) через `pgsql_supplier`.
|
||||
2. Развернуть каждый в группы `(signal_type, identifier, subject_code)`:
|
||||
- subjects = `project.regions` (коды 1..89); если пусто → одна группа с `subject_code = null` (пул «Вся РФ»).
|
||||
- identifier = `signal_identifier` (site/call) / sender(+keyword) (sms) — как `buildUniqueKey` в `SyncSupplierProjectJob`.
|
||||
- platforms = `resolvePlatforms()` (site/call → B1+B2+B3; sms+keyword → B2+B3; sms → B3).
|
||||
3. Для каждой группы:
|
||||
- eligible-сегодня (workday-маска на завтра, как сейчас targetDate=tomorrow) проекты группы.
|
||||
- `order = SupplierQuotaAllocator::computeOrder($eligibleLimits)`; `workdays = union`; `tag = subject ? RussianRegions::CODE_TO_NAME[subject] : 'РФ'`; `regions = subject ? [subject] : []`.
|
||||
- найти существующие supplier_projects группы (по `unique_key=identifier`, `subject_code`); если ни одного — создать через `saveProjectMultiFlag` (→ 3 id), upsert 3 supplier_projects (platform, signal_type, unique_key, subject_code, supplier_external_id, current_limit=order, current_regions); если есть — `updateProject` каждого external_id (R6: один лимит).
|
||||
- синхронизировать pivot: для каждого contributing Лидерра-проекта × каждого supplier_project группы — upsert строки `project_supplier_links` (idempotent ON CONFLICT).
|
||||
4. Failure-handling (Auth/Transient/Client/Window/TierEscalated), time-budget cutoff — **сохранить как есть**.
|
||||
|
||||
- [ ] **Step 1: Переписать тест `SyncSupplierProjectsJobTest`** под группировку
|
||||
|
||||
Ключевые кейсы (HTTP::fake портала; адаптировать фабрики существующего файла):
|
||||
|
||||
- **per-subject:** проект `regions=[82,83]`, site → 2 группы (Москва, СПб) → 2 multi-flag save → 6 supplier_projects (2 субъекта × 3 платформы) с верными `subject_code`/`tag`; pivot — 6 связей на проект.
|
||||
- **вся РФ пул:** проект `regions=[]` → 1 группа `subject_code=null`, `tag='РФ'`, `regions=[]` → 3 supplier_projects.
|
||||
- **order:** 2 проекта на один (источник×субъект) лимиты [10,20] → save с `limit=20` (computeOrder).
|
||||
- **sms:** sms+keyword → платформы B2+B3 (2 supplier_projects на субъект).
|
||||
- **idempotent:** повторный прогон без изменений → updateProject (не дубль supplier_projects/pivot).
|
||||
|
||||
(Привести полный код хотя бы первого и order-кейса по образцу существующих тестов файла + `Http::fake` save/list как в Task 4.)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SyncSupplierProjectsJobTest`
|
||||
Expected: FAIL — старая FK-итерация не группирует по субъекту, не пишет pivot.
|
||||
|
||||
- [ ] **Step 3: Переписать `handle()` + `syncOne` → `syncGroup`**
|
||||
|
||||
Реализовать алгоритм выше. Сохранить: connection `pgsql_supplier`, time-budget cutoff, все catch-блоки (Auth/Transient/Client/Window/Tier), `SupplierSyncLog`. Заменить: FK-итерацию supplier_projects → итерацию по группам активных проектов; `SupplierQuotaAllocator::allocate` → `computeOrder` + ручная сборка DTO (tag/regions/platforms) + `saveProjectMultiFlag` на create; pivot upsert через `DB::table('project_supplier_links')->...->upsert(...)` или `INSERT ... ON CONFLICT DO NOTHING`. Хелперы `buildUniqueKey`/`resolvePlatforms` — переиспользовать (вынести из `SyncSupplierProjectJob` в общий трейт/сервис `SupplierProjectGrouping` чтобы DRY; см. Task 6).
|
||||
|
||||
**NB:** точная сигнатура save-капчи — по Task 1 finding (вариант а реализован в Task 4 `saveProjectMultiFlag`). Update-путь существующих проектов остаётся `updateProject(externalId, dto)` на каждый из 3 (R6: одинаковый лимит).
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SyncSupplierProjectsJobTest`
|
||||
Expected: PASS все кейсы. Полный вывод; failed — file:line.
|
||||
|
||||
- [ ] **Step 5: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint -- app/app/Jobs/Supplier/SyncSupplierProjectsJob.php; composer stan
|
||||
git add -- app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php
|
||||
git commit -m "feat(supplier): SyncSupplierProjectsJob per-subject grouping + pivot + order" -- app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `SyncSupplierProjectJob` (онлайн) — mode-aware полные параметры
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Supplier/SupplierProjectGrouping.php` (DRY-хелперы buildUniqueKey/resolvePlatforms/subjectsOf)
|
||||
- Modify: `app/app/Jobs/SyncSupplierProjectJob.php` (online: full params; batch: каркас как сейчас)
|
||||
- Test: `app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
it('online mode creates per-subject supplier_projects with full params + pivot', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
Http::fake([/* save OK + listProjects 3 ids per subject как в Task 4 */]);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'okna.ru',
|
||||
'is_active' => true, 'daily_limit_target' => 12, 'regions' => [82], 'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3)
|
||||
->and(SupplierProject::where('unique_key', 'okna.ru')->where('subject_code', 82)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('batch mode keeps каркас (limit 0, no subject save)', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
|
||||
// ... ожидание: каркас как сейчас (current_limit=0), ночной job дольёт.
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SyncSupplierProjectJobTest`
|
||||
Expected: FAIL — джоб не mode-aware, не per-subject.
|
||||
|
||||
- [ ] **Step 3: Вынести общие хелперы в `SupplierProjectGrouping`**
|
||||
|
||||
`app/app/Services/Supplier/SupplierProjectGrouping.php` — статические `buildUniqueKey(Project,$platform)`, `resolvePlatforms(Project): array`, `subjectsOf(Project): array` (`regions ?: [null]`). Перенести логику из `SyncSupplierProjectJob::buildUniqueKey/resolvePlatforms` ([SyncSupplierProjectJob.php:138-172](../../../app/app/Jobs/SyncSupplierProjectJob.php#L138)); Task 5 sync-job тоже их использует (DRY).
|
||||
|
||||
- [ ] **Step 4: Сделать `SyncSupplierProjectJob::handle()` mode-aware**
|
||||
|
||||
`online` → для каждой (subject × platform-set) группы проекта: `saveProjectMultiFlag` (full params: limit=daily_limit_target, regions=[subject]/[], tag) → upsert supplier_projects + pivot. `batch` → текущий каркас-путь (limit 0, regions []), ночной `SyncSupplierProjectsJob` дольёт. Эскалация в ярус-3 при сбое — сохранить (FailoverProjectChannel).
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SyncSupplierProjectJobTest`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 6: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint -- app/app/Services/Supplier/SupplierProjectGrouping.php app/app/Jobs/SyncSupplierProjectJob.php; composer stan
|
||||
git add -- app/app/Services/Supplier/SupplierProjectGrouping.php app/app/Jobs/SyncSupplierProjectJob.php app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php
|
||||
git commit -m "feat(supplier): online-mode full-param per-subject sync + grouping helpers" -- app/app/Services/Supplier/SupplierProjectGrouping.php app/app/Jobs/SyncSupplierProjectJob.php app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: закрепить крон 18:00 тестом (R2 — уже выполнен)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify/verify: `app/tests/Feature/Schedule/SupplierScheduleTest.php`
|
||||
|
||||
- [ ] **Step 1: Убедиться, что тест требует 18:00**
|
||||
|
||||
`SupplierScheduleTest.php:15` уже: «SyncSupplierProjectsJob is scheduled at 18:00 MSK». Прогнать:
|
||||
Run: `php artisan test --filter=SupplierScheduleTest`
|
||||
Expected: PASS (крон уже `dailyAt('18:00')`, [console.php:52](../../../app/routes/console.php#L52)). Если теста на 18:00 нет — добавить assert по образцу существующего. R2 = no-op кроме закрепления.
|
||||
|
||||
- [ ] **Step 2: Коммит (если тест добавлялся)**
|
||||
|
||||
```bash
|
||||
git add -- app/tests/Feature/Schedule/SupplierScheduleTest.php
|
||||
git commit -m "test(supplier): lock SyncSupplierProjectsJob cron at 18:00 MSK (R2)" -- app/tests/Feature/Schedule/SupplierScheduleTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Регрессия экспорта
|
||||
|
||||
**Files:** нет (проверка).
|
||||
|
||||
- [ ] **Step 1: Полный Supplier + Jobs + Schedule suite**
|
||||
|
||||
Run: `php artisan test app/tests/Feature/Supplier app/tests/Feature/Jobs app/tests/Feature/Schedule app/tests/Unit/Services`
|
||||
Expected: GREEN. passed/failed; failed — file:line.
|
||||
|
||||
- [ ] **Step 2: pint + larastan**
|
||||
|
||||
Run: `composer pint; composer stan`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 3 (verification-before-completion):** перед «План 3 готов» — invoke `superpowers:verification-before-completion`; фактический вывод тестов.
|
||||
|
||||
---
|
||||
|
||||
## Self-review (выполнен при написании плана)
|
||||
|
||||
- **Покрытие spec §4.1/§4.3/§4.5/§8:** режимы online/batch (T2 + T6 mode-aware + T7 крон); формула заказа (T3); R5 multi-flag + захват id (T1 smoke + T4); R6 (split убран — T3); R7 tag=субъект/regions=[subject]/[] (T4 toPayload + T5/T6 группировка); per-субъект + pivot (T5/T6).
|
||||
- **Без плейсхолдеров:** концертный код для T2/T3/T4. T1 (smoke) и T5 (sync rewrite) несут явную зависимость от живого контракта портала — это не TODO, а гейт: T1 фиксирует контракт, T5 реализует поверх него. `src→platform` маппинг помечен «уточнить по выводу smoke» (реальная неизвестность портала, закрывается T1).
|
||||
- **Согласованность типов:** `computeOrder(array<int>): int`; `saveProjectMultiFlag(DTO): array<string,int>`; DTO +`tag:string`/`platforms:array` с дефолтами (single-flag пути целы); `SupplierExportMode::current(): string`. `subject_code` int|null — совпадает с Планом 1.
|
||||
- **Риск (R-SAVE):** если T1 smoke покажет, что 3 id из listProjects не вытащить надёжно — переключиться на fallback б (3 раздельных save, каждый отдаёт id) в T4; алгоритм T5 при этом не меняется (он зовёт `saveProjectMultiFlag`, чья внутренность инкапсулирует а/б).
|
||||
@@ -0,0 +1,465 @@
|
||||
# Переделка миграции проектов — План 4: админка (тумблер + очистка) + ЛК
|
||||
|
||||
> **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:** Доделать UI эпика: глобальный тумблер режима экспорта (online/batch) в админке; ручной экран «Проекты у поставщика» (кто заказывал · дата последней поставки · bulk-удаление); в ЛК — обязательный выбор региона + явная опция «Вся РФ» с предупреждением и подтверждением.
|
||||
|
||||
**Architecture:** Бэкенд — `AdminSupplierIntegrationController` +4 метода (getExportMode/setExportMode/projectsIndex/projectsDestroy), удаление на портале через `SupplierPortalClient::deleteProject` (pivot CASCADE снимает локальные связи). Фронт — тумблер в `AdminSupplierIntegrationView.vue` + новый `AdminSupplierProjectsView.vue` (таблица + bulk-delete) + роут/nav. ЛК — `NewProjectDialog.vue`: вернуть sentinel «Вся РФ» (code 0), взаимоисключение с субъектами, предупреждение-диалог + блок submit без выбора. «Require region» — UI-гейт (на бэке `regions=[]` = «Вся РФ» неотличим от «забыл»; backend-валидация `present|array` без изменений).
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Vue 3 + Vuetify 3 / Pest 4 / Vitest.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../specs/2026-05-20-project-migration-redesign-design.md) §4.1 (тумблер), §4.7 (очистка), §4.8 (ЛК).
|
||||
|
||||
**Prereq:** Планы 1+2+3 исполнены (pivot, subject_code, supplier_export_mode seed, SupplierExportMode резолвер, per-субъект supplier_projects).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: тумблер режима экспорта (бэкенд + UI)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php` (+`getExportMode`, +`setExportMode`)
|
||||
- Modify: `app/routes/*` (роуты в admin-группе рядом с supplier-integration; найти по `AdminSupplierIntegrationController`)
|
||||
- Modify: `app/resources/js/views/admin/AdminSupplierIntegrationView.vue` (+тумблер)
|
||||
- Test: `app/tests/Feature/Admin/SupplierExportModeEndpointTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест (бэкенд)**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SaasAdminUser;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('GET export-mode returns current; POST switches it', function (): void {
|
||||
$admin = SaasAdminUser::factory()->create(); // адаптировать под фактическую admin-аутентификацию проекта
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
|
||||
|
||||
$this->actingAs($admin, 'saas_admin')
|
||||
->getJson('/api/admin/supplier-integration/export-mode')
|
||||
->assertOk()->assertJson(['mode' => 'batch']);
|
||||
|
||||
$this->actingAs($admin, 'saas_admin')
|
||||
->postJson('/api/admin/supplier-integration/export-mode', ['mode' => 'online'])
|
||||
->assertOk()->assertJson(['mode' => 'online']);
|
||||
|
||||
expect(DB::table('system_settings')->where('key', 'supplier_export_mode')->value('value'))->toBe('online');
|
||||
});
|
||||
|
||||
it('POST export-mode rejects invalid value', function (): void {
|
||||
$admin = SaasAdminUser::factory()->create();
|
||||
$this->actingAs($admin, 'saas_admin')
|
||||
->postJson('/api/admin/supplier-integration/export-mode', ['mode' => 'turbo'])
|
||||
->assertStatus(422);
|
||||
});
|
||||
```
|
||||
|
||||
(Admin-guard/factory — привести к фактическому паттерну из существующих `app/tests/Feature/Admin/*` тестов.)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierExportModeEndpointTest`
|
||||
Expected: FAIL — методов/роутов нет.
|
||||
|
||||
- [ ] **Step 3: Добавить методы в контроллер**
|
||||
|
||||
В `AdminSupplierIntegrationController`:
|
||||
|
||||
```php
|
||||
public function getExportMode(): JsonResponse
|
||||
{
|
||||
return response()->json(['mode' => \App\Services\Supplier\SupplierExportMode::current()]);
|
||||
}
|
||||
|
||||
public function setExportMode(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'mode' => ['required', 'in:online,batch'],
|
||||
]);
|
||||
|
||||
DB::table('system_settings')->updateOrInsert(
|
||||
['key' => 'supplier_export_mode'],
|
||||
['value' => $data['mode'], 'type' => 'string', 'updated_at' => now()],
|
||||
);
|
||||
|
||||
return response()->json(['mode' => $data['mode']]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Зарегистрировать роуты**
|
||||
|
||||
Рядом с существующими роутами `AdminSupplierIntegrationController` (найти `supplier-integration` в `app/routes/`), в той же группе (auth:saas_admin / admin middleware):
|
||||
|
||||
```php
|
||||
Route::get('admin/supplier-integration/export-mode', [AdminSupplierIntegrationController::class, 'getExportMode']);
|
||||
Route::post('admin/supplier-integration/export-mode', [AdminSupplierIntegrationController::class, 'setExportMode']);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SupplierExportModeEndpointTest`
|
||||
Expected: PASS (2). Полный вывод.
|
||||
|
||||
- [ ] **Step 6: UI-тумблер в `AdminSupplierIntegrationView.vue`**
|
||||
|
||||
Добавить секцию «Режим экспорта проектов» с `<v-switch>` / `<v-btn-toggle>` (online|batch): на mount GET `/api/admin/supplier-integration/export-mode`, при смене POST. Подпись: «Онлайн — перенос сразу при правке; Пакетный — ночной 18:00». Vitest-стаб API.
|
||||
|
||||
- [ ] **Step 7: Vitest для тумблера**
|
||||
|
||||
`app/resources/js/views/admin/__tests__/AdminSupplierIntegrationView.export-mode.spec.ts` — mount, mock GET возвращает batch → переключение шлёт POST online. Run: `npm run test:vue -- AdminSupplierIntegrationView.export-mode`. Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint; composer stan
|
||||
git add -- app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php app/routes app/resources/js/views/admin/AdminSupplierIntegrationView.vue app/resources/js/views/admin/__tests__/AdminSupplierIntegrationView.export-mode.spec.ts app/tests/Feature/Admin/SupplierExportModeEndpointTest.php
|
||||
git commit -m "feat(admin): supplier export-mode toggle (online|batch) endpoint + UI" -- app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php app/routes app/resources/js/views/admin/AdminSupplierIntegrationView.vue app/resources/js/views/admin/__tests__/AdminSupplierIntegrationView.export-mode.spec.ts app/tests/Feature/Admin/SupplierExportModeEndpointTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: экран «Проекты у поставщика» — бэкенд (список + bulk-delete)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php` (+`projectsIndex`, +`projectsDestroy`)
|
||||
- Modify: `app/routes/*` (2 роута)
|
||||
- Test: `app/tests/Feature/Admin/SupplierProjectsAdminTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SaasAdminUser;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('lists supplier projects with orderers and last delivery date', function (): void {
|
||||
$admin = SaasAdminUser::factory()->create();
|
||||
$tenant = Tenant::factory()->create(['name' => 'ООО Ромашка']);
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
|
||||
'subject_code' => 82, 'current_limit' => 5, 'sync_status' => 'ok', 'supplier_external_id' => '777',
|
||||
]);
|
||||
$project = Project::factory()->create(['tenant_id' => $tenant->id, 'is_active' => true]);
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $sp->id, 'platform' => 'B1', 'subject_code' => 82,
|
||||
]);
|
||||
|
||||
$resp = $this->actingAs($admin, 'saas_admin')
|
||||
->getJson('/api/admin/supplier-integration/projects')->assertOk()->json();
|
||||
|
||||
$row = collect($resp['projects'])->firstWhere('id', $sp->id);
|
||||
expect($row['unique_key'])->toBe('okna.ru')
|
||||
->and($row['subject_code'])->toBe(82)
|
||||
->and($row['orderers'])->toContain('ООО Ромашка');
|
||||
});
|
||||
|
||||
it('bulk-deletes selected supplier projects on portal + locally (pivot cascades)', function (): void {
|
||||
Http::fake(['*/admin/visit/rt-project-delete' => Http::response(['status' => 'OK'], 200)]);
|
||||
$admin = SaasAdminUser::factory()->create();
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'del.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok', 'supplier_external_id' => '888',
|
||||
]);
|
||||
|
||||
$this->actingAs($admin, 'saas_admin')
|
||||
->postJson('/api/admin/supplier-integration/projects/delete', ['ids' => [$sp->id]])
|
||||
->assertOk()->assertJson(['deleted' => 1]);
|
||||
|
||||
expect(SupplierProject::find($sp->id))->toBeNull();
|
||||
Http::assertSent(fn ($r) => str_contains($r->url(), 'rt-project-delete'));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierProjectsAdminTest`
|
||||
Expected: FAIL — методов/роутов нет.
|
||||
|
||||
- [ ] **Step 3: Добавить методы**
|
||||
|
||||
В `AdminSupplierIntegrationController` (+ импорт `use App\Services\Supplier\SupplierPortalClient;`):
|
||||
|
||||
```php
|
||||
public function projectsIndex(): JsonResponse
|
||||
{
|
||||
$conn = DB::connection('pgsql_supplier');
|
||||
|
||||
$projects = $conn->table('supplier_projects as sp')
|
||||
->select('sp.id', 'sp.platform', 'sp.signal_type', 'sp.unique_key', 'sp.subject_code', 'sp.supplier_external_id', 'sp.current_limit', 'sp.inactive_since')
|
||||
->orderBy('sp.unique_key')->orderBy('sp.subject_code')->orderBy('sp.platform')
|
||||
->get()
|
||||
->map(function ($sp) use ($conn): array {
|
||||
$orderers = $conn->table('project_supplier_links as psl')
|
||||
->join('projects as p', 'p.id', '=', 'psl.project_id')
|
||||
->join('tenants as t', 't.id', '=', 'p.tenant_id')
|
||||
->where('psl.supplier_project_id', $sp->id)
|
||||
->where('p.is_active', true)
|
||||
->distinct()->pluck('t.name')->all();
|
||||
|
||||
$lastDelivery = $conn->table('supplier_leads')
|
||||
->where('supplier_project_id', $sp->id)
|
||||
->max('created_at');
|
||||
|
||||
return [
|
||||
'id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'signal_type' => $sp->signal_type,
|
||||
'unique_key' => $sp->unique_key,
|
||||
'subject_code' => $sp->subject_code !== null ? (int) $sp->subject_code : null,
|
||||
'subject_name' => $sp->subject_code !== null
|
||||
? (\App\Support\RussianRegions::CODE_TO_NAME[(int) $sp->subject_code] ?? null)
|
||||
: 'РФ',
|
||||
'current_limit' => (int) $sp->current_limit,
|
||||
'orderers' => $orderers,
|
||||
'last_delivery_at' => $lastDelivery,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json(['projects' => $projects->all()]);
|
||||
}
|
||||
|
||||
public function projectsDestroy(Request $request, SupplierPortalClient $client): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'ids' => ['required', 'array', 'min:1'],
|
||||
'ids.*' => ['integer'],
|
||||
]);
|
||||
|
||||
$deleted = 0;
|
||||
$failures = [];
|
||||
|
||||
foreach (SupplierProject::whereIn('id', $data['ids'])->get() as $sp) {
|
||||
try {
|
||||
if ($sp->supplier_external_id !== null) {
|
||||
$client->deleteProject((int) $sp->supplier_external_id);
|
||||
}
|
||||
$sp->delete(); // project_supplier_links снимутся CASCADE
|
||||
$deleted++;
|
||||
} catch (\Throwable $e) {
|
||||
$failures[] = ['id' => $sp->id, 'error' => $e->getMessage()];
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(['deleted' => $deleted, 'failures' => $failures]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Роуты**
|
||||
|
||||
```php
|
||||
Route::get('admin/supplier-integration/projects', [AdminSupplierIntegrationController::class, 'projectsIndex']);
|
||||
Route::post('admin/supplier-integration/projects/delete', [AdminSupplierIntegrationController::class, 'projectsDestroy']);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SupplierProjectsAdminTest`
|
||||
Expected: PASS (2). Полный вывод; failed — file:line.
|
||||
|
||||
- [ ] **Step 6: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint; composer stan
|
||||
git add -- app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php app/routes app/tests/Feature/Admin/SupplierProjectsAdminTest.php
|
||||
git commit -m "feat(admin): supplier projects list (orderers, last delivery) + bulk delete" -- app/app/Http/Controllers/Api/AdminSupplierIntegrationController.php app/routes app/tests/Feature/Admin/SupplierProjectsAdminTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: экран «Проекты у поставщика» — фронтенд
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/resources/js/views/admin/AdminSupplierProjectsView.vue`
|
||||
- Modify: `app/resources/js/router/index.ts` (роут `/admin/supplier-projects`)
|
||||
- Modify: `app/resources/js/layouts/AdminLayout.vue` (nav-пункт)
|
||||
- Test: `app/resources/js/views/admin/__tests__/AdminSupplierProjectsView.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий Vitest**
|
||||
|
||||
```ts
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import AdminSupplierProjectsView from '../AdminSupplierProjectsView.vue';
|
||||
import { apiClient } from '../../../api/client';
|
||||
|
||||
vi.mock('../../../api/client', () => ({ apiClient: { get: vi.fn(), post: vi.fn() }, ensureCsrfCookie: vi.fn() }));
|
||||
|
||||
describe('AdminSupplierProjectsView', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('renders rows with orderers + last delivery and bulk-deletes selected', async () => {
|
||||
(apiClient.get as any).mockResolvedValue({ data: { projects: [
|
||||
{ id: 1, platform: 'B1', unique_key: 'okna.ru', subject_name: 'Москва', current_limit: 5, orderers: ['ООО Ромашка'], last_delivery_at: '2026-05-19T10:00:00Z' },
|
||||
] } });
|
||||
(apiClient.post as any).mockResolvedValue({ data: { deleted: 1, failures: [] } });
|
||||
|
||||
const wrapper = mount(AdminSupplierProjectsView, { global: { plugins: [createVuetify()] } });
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('okna.ru');
|
||||
expect(wrapper.text()).toContain('ООО Ромашка');
|
||||
|
||||
// выбрать строку + удалить
|
||||
await wrapper.find('[data-testid="row-checkbox-1"]').setValue(true);
|
||||
await wrapper.find('[data-testid="bulk-delete-btn"]').trigger('click');
|
||||
// подтверждение → confirm
|
||||
await wrapper.find('[data-testid="confirm-delete-btn"]').trigger('click');
|
||||
await flushPromises();
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/admin/supplier-integration/projects/delete', { ids: [1] });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
(`flushPromises` — импортировать из `@vue/test-utils`; адаптировать селекторы под фактическую разметку компонента.)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `npm run test:vue -- AdminSupplierProjectsView`
|
||||
Expected: FAIL — компонента нет.
|
||||
|
||||
- [ ] **Step 3: Создать `AdminSupplierProjectsView.vue`**
|
||||
|
||||
Таблица `<v-data-table>` колонки: чекбокс · Источник (`unique_key`) · Платформа · Регион (`subject_name`) · Лимит · Кто заказывал (`orderers.join(', ')`) · Последняя поставка (`last_delivery_at` форматом даты, «—» если null). Над таблицей — `<v-btn data-testid="bulk-delete-btn" :disabled="selected.length===0">Удалить выбранные</v-btn>`. По клику — `<v-dialog>` подтверждения с `data-testid="confirm-delete-btn"` → POST `/api/admin/supplier-integration/projects/delete` `{ids: selected}` → refresh + snackbar (deleted/failures). Иконки — Lucide через IconSet (не mdi). Загрузка списка на mount GET `/api/admin/supplier-integration/projects`.
|
||||
|
||||
- [ ] **Step 4: Роут + nav**
|
||||
|
||||
- `router/index.ts`: `{ path: '/admin/supplier-projects', component: () => import('../views/admin/AdminSupplierProjectsView.vue'), meta: { layout: 'app', requiresAdmin: true } }` (адаптировать под фактический meta-паттерн admin-роутов).
|
||||
- `AdminLayout.vue`: nav-пункт «Проекты у поставщика» (Lucide-иконка).
|
||||
|
||||
- [ ] **Step 5: Прогнать Vitest — зелёный**
|
||||
|
||||
Run: `npm run test:vue -- AdminSupplierProjectsView`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 6: type-check + lint + Коммит**
|
||||
|
||||
```bash
|
||||
npm run type-check; npm run lint:vue
|
||||
git add -- app/resources/js/views/admin/AdminSupplierProjectsView.vue app/resources/js/router/index.ts app/resources/js/layouts/AdminLayout.vue app/resources/js/views/admin/__tests__/AdminSupplierProjectsView.spec.ts
|
||||
git commit -m "feat(admin): supplier projects cleanup screen (list + bulk delete)" -- app/resources/js/views/admin/AdminSupplierProjectsView.vue app/resources/js/router/index.ts app/resources/js/layouts/AdminLayout.vue app/resources/js/views/admin/__tests__/AdminSupplierProjectsView.spec.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: ЛК — обязательный регион + «Вся РФ» с предупреждением
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/projects/NewProjectDialog.vue`
|
||||
- Test: `app/resources/js/views/projects/__tests__/NewProjectDialog.regions.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Написать падающий Vitest**
|
||||
|
||||
```ts
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import NewProjectDialog from '../NewProjectDialog.vue';
|
||||
|
||||
vi.mock('../../../api/client', () => ({ apiClient: { post: vi.fn().mockResolvedValue({}) }, ensureCsrfCookie: vi.fn(), extractErrorMessage: () => '' }));
|
||||
|
||||
const mountDialog = () => mount(NewProjectDialog, {
|
||||
props: { modelValue: true, mode: 'create' },
|
||||
global: { plugins: [createVuetify()], stubs: { DevIndexBadge: true } },
|
||||
});
|
||||
|
||||
describe('NewProjectDialog regions gate', () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it('blocks submit when no region chosen', async () => {
|
||||
const w = mountDialog();
|
||||
// заполнить остальные обязательные поля минимально...
|
||||
await w.find('[data-testid="submit-btn"]').trigger('click');
|
||||
const { apiClient } = await import('../../../api/client');
|
||||
expect(apiClient.post).not.toHaveBeenCalled();
|
||||
expect(w.text()).toContain('Выберите регион'); // ошибка-валидации
|
||||
});
|
||||
|
||||
it('selecting «Вся РФ» shows warning and requires confirm before submit; sends regions=[]', async () => {
|
||||
const w = mountDialog();
|
||||
// выбрать «Вся РФ» (sentinel)
|
||||
await w.vm.$nextTick();
|
||||
(w.vm as any).chooseVsyaRf?.(); // или установить form.regions=[0] через UI
|
||||
await w.vm.$nextTick();
|
||||
expect(w.text()).toContain('всю Россию'); // предупреждение
|
||||
await w.find('[data-testid="confirm-vsya-rf"]').trigger('click');
|
||||
// заполнить прочие поля + submit
|
||||
await w.find('[data-testid="submit-btn"]').trigger('click');
|
||||
const { apiClient } = await import('../../../api/client');
|
||||
const payload = (apiClient.post as any).mock.calls[0][1];
|
||||
expect(payload.regions).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
(Селекторы/хелперы — адаптировать под фактическую разметку; ключевые проверки: (1) пустой выбор → submit заблокирован + ошибка; (2) «Вся РФ» → предупреждение + подтверждение → `regions:[]`.)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `npm run test:vue -- NewProjectDialog.regions`
|
||||
Expected: FAIL — гейта/опции «Вся РФ»/предупреждения нет.
|
||||
|
||||
- [ ] **Step 3: Доработать `NewProjectDialog.vue`**
|
||||
|
||||
- Вернуть «Вся РФ» в опции: `selectableRegions` ([NewProjectDialog.vue:154](../../../app/resources/js/views/projects/NewProjectDialog.vue#L154)) — включить sentinel `code:0` (или отдельный чекбокс «Вся РФ»).
|
||||
- **Взаимоисключение:** выбор «Вся РФ» очищает конкретные субъекты и наоборот.
|
||||
- **Предупреждение:** при выборе «Вся РФ» — `<v-dialog>`/`<v-alert>` «Вы выбрали всю Россию — проект будет получать лиды по всем регионам» + кнопка `data-testid="confirm-vsya-rf"`; без подтверждения «Вся РФ» не считается выбранной.
|
||||
- **Гейт submit:** `submit()` блокируется (ошибка `errors.regions = ['Выберите регион']`), если не выбрано ни субъектов, ни подтверждённой «Вся РФ». Submit-кнопка `:disabled` либо ранний `return` в `submit()`.
|
||||
- **Маппинг на API:** «Вся РФ» → `form.regions = []` (sentinel 0 не отправлять); конкретные → коды субъектов как сейчас.
|
||||
- Подпись поля: «Источник» / регион — оставить «Регионы» (но убрать «(пусто = вся РФ)», т.к. пусто теперь = не выбрано / ошибка).
|
||||
|
||||
- [ ] **Step 4: Прогнать Vitest — зелёный**
|
||||
|
||||
Run: `npm run test:vue -- NewProjectDialog.regions`
|
||||
Expected: PASS. Полный вывод. Прогнать также существующие `NewProjectDialog` specs — не сломаны (если дефолт изменился — поправить старые ожидания «regions=[]»).
|
||||
|
||||
- [ ] **Step 5: type-check + lint + Коммит**
|
||||
|
||||
```bash
|
||||
npm run type-check; npm run lint:vue
|
||||
git add -- app/resources/js/views/projects/NewProjectDialog.vue app/resources/js/views/projects/__tests__/NewProjectDialog.regions.spec.ts
|
||||
git commit -m "feat(projects): require region + explicit «Вся РФ» with warning gate" -- app/resources/js/views/projects/NewProjectDialog.vue app/resources/js/views/projects/__tests__/NewProjectDialog.regions.spec.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Регрессия эпика (полная)
|
||||
|
||||
**Files:** нет (проверка).
|
||||
|
||||
- [ ] **Step 1: Backend full Supplier/Admin/Jobs**
|
||||
|
||||
Run: `php artisan test app/tests/Feature/Supplier app/tests/Feature/Admin app/tests/Feature/Jobs app/tests/Feature/Schedule app/tests/Unit/Services`
|
||||
Expected: GREEN. passed/failed; failed — file:line.
|
||||
|
||||
- [ ] **Step 2: Frontend Vitest**
|
||||
|
||||
Run: `npm run test:vue`
|
||||
Expected: GREEN (или `--maxWorkers=2` при OOM — квирк 98).
|
||||
|
||||
- [ ] **Step 3: Полный regression sweep**
|
||||
|
||||
Run: `/regression full` (Pest --parallel + Larastan + Vitest + Vite build + lychee + gitleaks). Привести каноническую статус-строку + вердикт.
|
||||
|
||||
- [ ] **Step 4 (verification-before-completion):** перед «План 4 / эпик готов» — invoke `superpowers:verification-before-completion`; фактический вывод, не «должно проходить».
|
||||
|
||||
---
|
||||
|
||||
## Self-review (выполнен при написании плана)
|
||||
|
||||
- **Покрытие spec §4.1/§4.7/§4.8:** тумблер режима endpoint+UI (T1); экран очистки — список (кто заказывал через pivot→projects→tenants, дата последней поставки = max supplier_leads.created_at) + bulk-delete через `deleteProject` + CASCADE pivot (T2 бэкенд, T3 фронт); ЛК require-region UI-гейт + «Вся РФ» предупреждение/подтверждение/взаимоисключение, regions=[] на API (T4).
|
||||
- **Без плейсхолдеров:** бэкенд-код полный (контроллер, тесты). Фронт — структура компонента + ключевая логика + Vitest с `data-testid`; точные селекторы помечены «адаптировать под разметку» (компонент создаётся в этом же таске — не внешняя неизвестность).
|
||||
- **Согласованность типов:** `mode` ∈ {online,batch} единообразно (резолвер План 3 + endpoint); `projectsIndex` отдаёт `subject_code:int|null` + `subject_name` (RussianRegions из Плана 2); `projectsDestroy` `{ids:int[]}` → `{deleted,failures}`; ЛК → `regions:int[]` (пусто=Вся РФ) совпадает с StoreProjectRequest `present|array`.
|
||||
- **Граница require-region:** на бэке `regions=[]` (Вся РФ) и «забыл» неотличимы → гейт намеренно UI-only (T4); backend-валидация `present|array` не меняется (P1: Вся РФ — валидный пустой массив).
|
||||
- **Риск:** admin-аутентификация в тестах (`saas_admin` guard / factory) — привести к фактическому паттерну существующих `app/tests/Feature/Admin/*`; роуты — в ту же группу, что текущие `AdminSupplierIntegrationController` методы.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Дашборд мозга — дизайн
|
||||
|
||||
**Дата:** 2026-05-19
|
||||
**Статус:** согласован в brainstorming-сессии, готов к writing-plans
|
||||
**Источник:** brainstorming-сессия Claude + Дмитрий
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
`docs/automation-graph.html` («карта мозга») — статический снимок: 124 узла, 130 рёбер, 11 размеченных конфликтов, плюс ручная теплокарта `NODE_META` за фиксированное окно. Карта показывает *топологию на момент*, но не показывает *работу*: как решались задачи, какие узлы задействованы, где возникли ошибки и ретраи, куда перенаправлялось, где сталкивались узлы.
|
||||
|
||||
Журнал исполнения уже существует — `docs/observer/episodes-YYYY-MM.jsonl`: наблюдатель (ADR-011) пишет одну запись на каждый ход (Stop-событие). В записи — выбранный узел, инструменты, ошибки, ретраи, перенаправления, hard-floor, классификация задачи, окружение. **Данные есть — нет визуализации.** Карта и журнал между собой не связаны.
|
||||
|
||||
## 2. Цель и не-цели
|
||||
|
||||
**Цель:** standalone-дашборд, визуализирующий журнал эпизодов поверх топологии карты — три способа смотреть на работу мозга плюс сама карта.
|
||||
|
||||
**В scope:**
|
||||
|
||||
- Чтение `episodes-*.jsonl` (схема v1 и v2).
|
||||
- Четыре view: Карта, Разбор задачи, Лента сессии, Агрегат.
|
||||
- Граф как общий холст для траекторий и тепла.
|
||||
- Три слоя «конфликтов» (см. §6).
|
||||
|
||||
**Не-цели (YAGNI):**
|
||||
|
||||
- Не меняем формат эпизодов и логику наблюдателя (исключение — отдельная задача, §13).
|
||||
- Не правим `/brain-retro` и контролёры.
|
||||
- Не пиксель-полировка — Forest накладывается на этапе реализации (frontend-design).
|
||||
- Нет истории до запуска наблюдателя — её физически нет.
|
||||
- Не Vue/Vuetify-приложение — это dev-инструмент, zero-build (см. §3).
|
||||
|
||||
## 3. Зафиксированные решения
|
||||
|
||||
**Тех-модель: standalone HTML + локальный статик-сервер.** Один HTML-файл без сборки; читает свежий JSONL через статик-сервер (`fetch` с `file://` браузер блокирует). Сервер — ~20 строк на `node:http`, запуск npm-командой, гасится по Ctrl-C. Ноль новых npm-зависимостей, ноль постоянных демонов. Отвергнуты: «запекаемый файл» (генератор вшивает данные — лента не живая) и «Vue/Vuetify-приложение» (сборка + стек портала в dev-инструменте; vis.js всё равно не Vue).
|
||||
|
||||
**Раскладка: граф баннером сверху.** Постоянная полоса графа сверху, рабочая зона view снизу; переключатель view меняет нижнюю зону. Граф всегда виден; широкие таблицы и ленты снизу.
|
||||
|
||||
## 4. Архитектура
|
||||
|
||||
### 4.1. Слой данных
|
||||
|
||||
Источник — `docs/observer/episodes-YYYY-MM.jsonl`, append-only, одна строка = один эпизод.
|
||||
|
||||
Сервер `tools/brain-dashboard-server.mjs`:
|
||||
|
||||
- статика из корня репо (HTML, JS, JSONL, `automation-graph.html`, vis.js);
|
||||
- эндпоинт `GET /api/episodes` → JSON-список имён файлов `docs/observer/episodes-*.jsonl` (дашборд не угадывает имена);
|
||||
- больше ничего; только localhost.
|
||||
|
||||
Парсер (JS, внутри дашборда):
|
||||
|
||||
- читает каждую строку JSONL → объект эпизода;
|
||||
- нормализует **v1** (строки без `schema_version` — нет `decision_provenance` / `environment` / `task_size` / `prompt_signal`, `outcome` уже проставлен) и **v2** (`schema_version: 2`);
|
||||
- битые строки и строки-маркеры `observer_error` — пропускаются, ведётся счётчик «N пропущено»;
|
||||
- результат — нормализованный массив эпизодов единой формы (отсутствующие v1-поля → `null`).
|
||||
|
||||
Производные данные (тепло, кластеры, агрегаты) считаются в браузере при загрузке. Ноль вшитых данных → всегда свежо.
|
||||
|
||||
### 4.2. Карта = общий холст
|
||||
|
||||
Сейчас топология зашита константами внутри `automation-graph.html` (один файл ≈2900 строк): `NODES` (стр. 229), `EDGES` (стр. 418), секции, `CONFLICT`-данные (стр. ≈406–614), `NODE_META` (стр. 1898), `NODE_SECTION` (стр. 2155).
|
||||
|
||||
**Рефактор-вынос:** константы топологии (`NODES`, `EDGES`, `SECTIONS`, `CONFLICT`-данные, `NODE_SECTION`) выносятся в `docs/automation-graph-data.js`. Старая карта `<script src>`-ит его — поведение и вид не меняются (подтвердить визуальным smoke-тестом). `NODE_META` (ручная теплокарта) **остаётся в старой карте** — дашборд её не использует, он считает тепло из эпизодов.
|
||||
|
||||
Дашборд импортирует `automation-graph-data.js` и строит **свой** экземпляр vis.js-графа в баннере. Этот граф управляемый — на нём анимируются траектории (Разбор) и красится тепло (Агрегат). Iframe старой карты отвергнут: чужой iframe нельзя анимировать снаружи.
|
||||
|
||||
Вкладка «Карта» = тот же граф дашборда в режиме «без оверлея». Файл `automation-graph.html` продолжает существовать как самостоятельная голая карта.
|
||||
|
||||
### 4.3. Атрибуция узлов (честная)
|
||||
|
||||
Граф — 124 узла (MCP-серверы, плагины, скилы, инструменты, секции). Эпизод даёт сигналы об узлах:
|
||||
|
||||
- `primary_rationale.node_chosen` — чаще всего `"direct"`, иногда id скила (`"superpowers:systematic-debugging"`);
|
||||
- события `skill_invoked` — id скилов;
|
||||
- `tool_summary.counts` — имена встроенных инструментов Claude (`Read`, `Edit`, `Bash`, `Grep`, …) и `Skill` / `ToolSearch`.
|
||||
|
||||
Маршрутизируемый словарь наблюдателя — `tools/observer-known-nodes.txt` (~22 имени: 13 superpowers-скилов + 7 проектных + 2 плагина/команды). Это **меньше** 124 узлов графа и пересекается с ними частично.
|
||||
|
||||
**Решение:** дашборд держит таблицу соответствия `сигнал эпизода → id узла графа`. Подсвечивает узлы, для которых соответствие есть. Узлы без соответствия (встроенные инструменты Claude, большинство MCP/плагинов, которые эпизоды пока не называют) **остаются неподсвеченными** — это ожидаемо и подписано в UI («атрибутировано N из M сигналов»). Полнее станет, когда роутер начнёт писать `node_chosen` детальнее — вне scope этой задачи.
|
||||
|
||||
## 5. Четыре view
|
||||
|
||||
Общий каркас (раскладка из §3): сверху переключатель view + граф-баннер; снизу — рабочая зона.
|
||||
|
||||
### 5.1. Карта
|
||||
|
||||
Граф без оверлея: топология + 11 размеченных дизайн-конфликтов (цвета RED / BLACK / GREEN из `CONFLICT_TYPES`). Фильтры по секциям/типам — переносятся из старой карты по возможности (или минимальный набор). Это «нулевое состояние» холста.
|
||||
|
||||
### 5.2. Разбор задачи (ретроспектива)
|
||||
|
||||
Нижняя зона: слева список эпизодов (фильтр: дата, `task_classification`, `outcome`, `path_type`, наличие ошибок); справа — детали выбранного.
|
||||
|
||||
Выбор эпизода → траектория:
|
||||
|
||||
- на графе подсвечиваются атрибутированные узлы (§4.3);
|
||||
- справа — упорядоченный список событий эпизода (`skill_invoked` / `error` / `retry` / `hook_fired` / `interrupt` / `time_burn`), плюс шапка: классификация, `path_type`, `decision_provenance` (если `user_directed_method` — «перенаправление: выбран X, автономно был бы Y»), `hard_floor`, окружение (`economy_level`, `model`, `post_compaction`, `session_turn`, `parallel_session`), `task_size`.
|
||||
|
||||
Честно: внутри хода есть упорядоченный список событий, но не каждый tool-вызов по порядку — `tool_summary` даёт только счётчики. «Траектория» = последовательность событий + сводка инструментов.
|
||||
|
||||
### 5.3. Лента сессии (живая)
|
||||
|
||||
Нижняя зона: одноколоночный поток эпизодов, сгруппированных по `task_id` / `task_ref`, новый ход сверху. Карточка хода: время, классификация, `path_type`, атрибутированный узел, ошибки/ретраи (бейджи), длительность (`ended_at − started_at`), флаг перенаправления.
|
||||
|
||||
Автоопрос: дашборд раз в N секунд (по умолчанию 5) перезапрашивает `/api/episodes` + текущий месячный JSONL, дописывает новые строки. Полл — только в этом view. Кнопка пауза/возобновить.
|
||||
|
||||
### 5.4. Агрегат (тренды)
|
||||
|
||||
Нижняя зона: плитки метрик по всем эпизодам:
|
||||
|
||||
- тепло узлов (авто — сколько раз каждый атрибутированный узел встречался); красит граф-баннер;
|
||||
- горячие точки ошибок/ретраев (узлы и классы задач с наибольшей долей `error` / `retry`);
|
||||
- доля перенаправлений (`decision_provenance.kind == "user_directed_method"`);
|
||||
- распределения `economy_level`, `path_type` (improvised/regulated), `task_classification`, `outcome`;
|
||||
- счётчик `observer_error` и пропущенных строк.
|
||||
|
||||
Опциональный reuse: для v2-эпизодов с `outcome: "unknown"` — переиспользовать детерминированный inference из `tools/brain-retro-analyzer.mjs`, если он оформлен импортируемым модулем; иначе показывать `unknown`. Решается в плане.
|
||||
|
||||
## 6. Конфликты — три слоя
|
||||
|
||||
Запрос — «где конфликты среди узлов». Эпизоды не пишут «узел A столкнулся с узлом B» — только `error` / `retry` / `hook_fired.errors`. Дашборд отдаёт три явно подписанных слоя:
|
||||
|
||||
1. **Дизайн-конфликты** — 11 размеченных `CONFLICT`-рёбер карты (факт, из топологии).
|
||||
2. **Трение** — эпизоды с `error`/`retry`, привязанные к атрибутированным в них узлам. Это инференс («во время хода с этим узлом была ошибка»), не доказанный конфликт. Подписано.
|
||||
3. **Корреляция** — эпизод с ошибкой, где атрибутированы два узла, между которыми есть `CONFLICT`-ребро → «конфликт мог реализоваться». Эвристика. Подписано.
|
||||
|
||||
Настоящего лога «узел×узел» нет. См. §13.
|
||||
|
||||
## 7. Раскладка
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ [Карта] [Разбор] [Лента] [Агрегат] │ переключатель view
|
||||
├─────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ГРАФ — баннер (vis.js) │ ~40% высоты, всегда виден
|
||||
│ │
|
||||
├─────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ рабочая зона view (меняется) │ ~60% высоты
|
||||
│ │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Граф-баннер общий для всех view; рабочая зона своя у каждого. Forest-палитра (Teal `#0F6E56`, ivory `#F6F3EC`, теало-нуар `#012019`), Inter / JetBrains Mono — накладываются на этапе реализации как CSS-переменные.
|
||||
|
||||
## 8. Файлы и компоненты
|
||||
|
||||
| Файл | Назначение | Статус |
|
||||
|---|---|---|
|
||||
| `docs/observer/dashboard.html` | каркас дашборда (раскладка из §3/§7, vis.js-граф) | новый |
|
||||
| `docs/observer/dashboard.js` | парсер JSONL + агрегатор + 4 view + рендер графа | новый |
|
||||
| `docs/automation-graph-data.js` | вынесенная топология (`NODES` / `EDGES` / `SECTIONS` / `CONFLICT` / `NODE_SECTION`) | новый (вынос) |
|
||||
| `docs/automation-graph.html` | `<script src>` на data-файл; остальное без изменений | правка |
|
||||
| `tools/brain-dashboard-server.mjs` | статик-сервер + `/api/episodes` | новый |
|
||||
| `package.json` | скрипт `brain:dashboard` (запуск сервера + открытие браузера) | правка |
|
||||
| `tools/brain-dashboard-*.test.mjs` | тесты парсера / агрегатора / сервера | новый |
|
||||
|
||||
`dashboard.js` при росте можно разбить на модули (`parser.js`, `aggregate.js`, `graph.js`, `views/*.js`) — решается в плане по фактическому размеру.
|
||||
|
||||
## 9. Обработка ошибок и граничные случаи
|
||||
|
||||
- Битая JSONL-строка / `observer_error`-маркер → пропуск, инкремент счётчика, показ счётчика в UI.
|
||||
- Месячный файл отсутствует или пуст → не ошибка.
|
||||
- Эпизодов нет вообще → дружелюбное пустое состояние.
|
||||
- v1-эпизод (нет v2-полей) → недостающие поля `null`, UI показывает «—».
|
||||
- Сервер не запущен → дашборд физически не откроется (он отдаётся этим же сервером); пустой `/api/episodes` → пустое состояние.
|
||||
- `automation-graph-data.js` не загрузился → пустой граф + явное сообщение.
|
||||
|
||||
## 10. Тестирование
|
||||
|
||||
- **TDD** на чистую логику (`dashboard.js` — парсер, нормализация v1/v2, агрегатор, атрибуция узлов, инференс конфликтов): `tools/brain-dashboard-*.test.mjs` на `node:test`, failing-first → GREEN. Паттерн — как существующие `tools/*.test.mjs`.
|
||||
- Сервер `brain-dashboard-server.mjs` — smoke-тест (поднять, дёрнуть `/api/episodes`, проверить отдачу статики).
|
||||
- Вынос топологии — визуальный smoke старой карты (Playwright или ручной): карта выглядит и фильтруется как до выноса.
|
||||
- Рендер view и графа — ручной визуальный smoke в браузере.
|
||||
|
||||
## 11. Честные ограничения
|
||||
|
||||
1. **Гранулярность — один эпизод на ход.** Внутри хода есть упорядоченный список событий, но не каждый tool-вызов по порядку (только счётчики `tool_summary`).
|
||||
2. **«Живость» — после Stop, не в процессе.** Лента обновляется после завершения хода (+ задержка автоопроса), не пока ход идёт.
|
||||
3. **Атрибуция узлов частичная** — `node_chosen` чаще `direct`; словарь наблюдателя ~22 имени против 124 узлов графа. Бóльшая часть графа не подсвечивается. См. §4.3.
|
||||
4. **Конфликты узел×узел не логируются** — даётся инференс (§6), не факт.
|
||||
5. **История — только с запуска наблюдателя (~19.05.2026).** Раньше эпизодов нет.
|
||||
|
||||
## 12. Порядок сборки (3 фазы, один спек)
|
||||
|
||||
- **Фаза 1 — фундамент.** Статик-сервер + `/api/episodes`; вынос топологии в `automation-graph-data.js` + правка старой карты + визуальный smoke; каркас `dashboard.html`; парсер v1/v2 + атрибуция узлов (TDD); граф-баннер; view «Карта» + view «Разбор задачи»; npm-скрипт `brain:dashboard`.
|
||||
- **Фаза 2 — живость.** View «Лента сессии» + автоопрос/пауза.
|
||||
- **Фаза 3 — агрегат.** View «Агрегат» + тепло на граф-баннер + три слоя конфликтов (§6); опц. reuse `brain-retro-analyzer.mjs` для outcome-инференса.
|
||||
|
||||
## 13. Открытые вопросы / отложено
|
||||
|
||||
- **Настоящий лог конфликтов узел×узел** — потребует нового типа события в наблюдателе (`tools/observer-transcript-parser.mjs` + схема эпизода). Отдельная задача, не входит в этот спек.
|
||||
- **Двойной клик без сервера** — если когда-нибудь понадобится: добавить генератору запекание данных, файл выродится в тех-модель «запекаемый файл» без переписывания. Сейчас YAGNI.
|
||||
- **Forest-полировка** — этап реализации (frontend-design).
|
||||
- **vis.js** — откуда его берёт старая карта (CDN или вендорено) — уточнить в плане, дашборд переиспользует тот же способ.
|
||||
@@ -1,8 +1,8 @@
|
||||
# Observer factor-analysis extension — design
|
||||
|
||||
**Версия:** 1.1
|
||||
**Дата:** 2026-05-19
|
||||
**Статус:** accepted — фаза 1 реализована (`f7f37fb`); фаза 1.1 (см. §11) в реализации
|
||||
**Версия:** 1.2
|
||||
**Дата:** 2026-05-20
|
||||
**Статус:** accepted — фаза 1 реализована (`f7f37fb`); фаза 1.1 (см. §11) реализована (commits `7f379bd..dc6d2dd`); **фаза 1.2 «instrument expansion» (см. §12) — в реализации**
|
||||
**Связано:** ADR-011 (brain governance), spec `2026-05-19-brain-governance-design.md`, Pravila §16, PSR_v1 R16, `docs/observer/`, `tools/observer-stop-hook.mjs`, `tools/observer-transcript-parser.mjs`
|
||||
|
||||
---
|
||||
@@ -125,10 +125,10 @@ Stop-событие сейчас несёт: user-level economy-verifier (agent)
|
||||
|
||||
`/brain-retro` остаётся read-only агрегатором (не мутирует JSONL). Расширения:
|
||||
|
||||
- **Вывод настоящего исхода.** Для каждого эпизода смотрит первый user-prompt **следующего** эпизода той же сессии: коррекция (`не то` / `не так` / `переделай` / `отбой` / `стоп` / `почему ты`) → исход прошлого = `rework`; одобрение/новая задача (`ок` / `спасибо` / `дальше` / `готово`) → `success`; `interrupt`-событие → `partial`; нет следующего → `unknown`. Уточнённый исход — в retro-ноте (JSONL не трогается, append-only).
|
||||
- **Вывод настоящего исхода.** Для каждого эпизода: `interrupt`-событие → `partial`; больше `error`-событий, чем `retry` (невосстановленный сбой) → `blocked`; иначе — по первому user-prompt **следующего** эпизода той же сессии: коррекция (`не то` / `не так` / `переделай` / `откати` / `сломал` / `не работает` / `revert` / … — расширенный набор) → исход прошлого = `rework`; одобрение/новая задача (`ок` / `спасибо` / `дальше` / `готово`) → `success`; нет следующего → `unknown`. `failure` детерминированно невосстановим (суждение «работа неверна И не исправлена») — отложен в фазу 2 (agent-судья). Уточнённый исход — в retro-ноте (JSONL не трогается, append-only).
|
||||
- **Группировка «эпизоды → задача».** `task_ref` по sessionId; сегментация — новая задача начинается с top-level user-prompt после `success` или после паузы.
|
||||
- **Каузальные цепочки.** Детерминированная корреляция: эпизоды, делящие `files_touched`; `error` в N → исправление того же файла в N+1. Surface как «кандидаты цепочек».
|
||||
- **Факторная матрица.** Строки — факторы (`decision_provenance.kind`, `economy_level`, `model`, `post_compaction`, бакет `task_size`, `node_chosen`, `task_classification`); столбцы — распределение `outcome`. Пример вывода: «`user_directed_method`: 40% rework против `autonomous` 12%» — прямой ответ на вопрос заказчика «моя ли вина».
|
||||
- **Факторная матрица.** Строки — факторы (`decision_provenance.kind`, `economy_level`, `model`, `post_compaction`, бакет `session_turn`, `parallel_session`, бакет `task_size`, `node_chosen`, `task_classification` — 9 осей); столбцы — распределение `outcome`. Пример вывода: «`user_directed_method`: 40% rework против `autonomous` 12%» — прямой ответ на вопрос заказчика «моя ли вина».
|
||||
|
||||
---
|
||||
|
||||
@@ -253,3 +253,117 @@ Pure-функция `detectChoiceProvenance(promptText, lastAssistantContent)`
|
||||
- Детектор «опции в потоке текста» (например, `«вариант A — короткий», «вариант B — длинный»` в inline-прозе без списка) — out, эвристика низкой надёжности.
|
||||
- Распознавание «пользователь дополнил мою опцию» (выбрал B, но изменил параметр) — out, требует семантического понимания.
|
||||
- Refine routing-gate detector (исключение `<system-reminder>` блоков из scope) — отдельная задача, см. memory `project_brain_governance_design.md`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Phase 1.2 amendment — instrument expansion (2026-05-20)
|
||||
|
||||
Реализованы 18 рекомендаций из brain-retro 2026-05-20 (план
|
||||
`docs/superpowers/plans/2026-05-20-observer-instrument-expansion.md` v1.1
|
||||
REVISION). Worktree: `.claude/worktrees/observer-v2-expansion`, ветка
|
||||
`feat/observer-v2-expansion`.
|
||||
|
||||
### 12.1. Схема v2 (расширения)
|
||||
|
||||
- **Опциональное поле `task_cost`** в episode (#2): захват `message.usage`
|
||||
agregat по Σ всех assistant-сообщений turn'а. Поля: `input_tokens`,
|
||||
`output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`,
|
||||
`web_search_requests`, `web_fetch_requests`, `iterations` (B1-verified
|
||||
shape, бонус `server_tool_use` + `iterations` поверх 4 базовых).
|
||||
Backward-compat: `V2_FIELDS` validator не расширен — старые v2-эпизоды
|
||||
без `task_cost` остаются валидны.
|
||||
- **`outcome` enum** дополнен `soft_success` (#16): next-prompt `neutral`
|
||||
интерпретируется как silent success ('no objection'). Слабее explicit
|
||||
approval, отдельно labelled.
|
||||
- **Новые `events[]` kinds:**
|
||||
- `ask_user_question` (#4) — per AskUserQuestion question с `answer_kind`
|
||||
∈ `option|custom|no_answer`. Сигнал quality предлагаемых options.
|
||||
- `subagent_invoked` (#12) — per Agent tool_use с `subagent_type` /
|
||||
`model` / `description` (80 chars).
|
||||
- `error` enriched (#7) — теперь несёт `tool` (имя инструмента,
|
||||
атрибуция через id-map) + `summary` (first 80 chars причины);
|
||||
раньше bare `message: 'tool_result reported is_error'`.
|
||||
|
||||
### 12.2. Парсер — heuristic capture (Слой 2)
|
||||
|
||||
- **`primary_rationale` arrays больше не пусты** (#6): три pure-функции
|
||||
`extractTriggers` / `extractCandidates` / `extractBoundaries` сканируют
|
||||
assistant.text на маркеры (Pravila §N, ADR-N, PSR_v1 RX,
|
||||
routing-off-phase LN, hard-floor/rule + numbered/bulleted lists ≥2).
|
||||
Conservative-broad — false positives accepted; agent-judge остаётся
|
||||
out-of-scope (фаза 2 §10).
|
||||
- **Opt-in reasoning-tag** (#11): `<!-- reasoning: triggers="..."
|
||||
candidates="..." boundaries="..." -->` HTML-комментарий в assistant.text;
|
||||
semicolon-separated значения merged в heuristic arrays через Set-dedupe.
|
||||
- **`<system-reminder>` strip в `promptText`** (#8): UserPromptSubmit
|
||||
hook injections больше не загрязняют `classifyTask` /
|
||||
`classifyPromptSignal` / routing detection.
|
||||
- **`classifyTask` vocabulary +7 classes** (#1): `analysis` / `memory-sync` /
|
||||
`regulatory-bump` / `release` / `cleanup` / `monitoring` / `planning`.
|
||||
Closes «59% other» observation.
|
||||
- **`classifyPromptSignal` vocabulary +** (#9): `correction` +'не совсем',
|
||||
'другое', 'wrong direction'; `approval` +'класс', 'well done', 'nice';
|
||||
`new_task` prefix +'теперь', 'далее', 'next', 'now'. Bug fix: JS `\b`
|
||||
не работает с Cyrillic (Cyrillic ≠ word char) — substring match для
|
||||
русских correction markers, lookahead для prefix-based new_task.
|
||||
- **`parallel_session` +OR pre-flight git fetch** (#13 PIVOT): additive
|
||||
signal — Bash command `git fetch && git log HEAD..origin/...` (Pravila
|
||||
§15.2 pre-flight) = strong signal для parallel sessions. Не overwrite
|
||||
F1 narrow-to-tool_result детектор; OR-clause.
|
||||
|
||||
### 12.3. Анализатор (Слой 4)
|
||||
|
||||
- **`session_segment_turn` axis rename** (#14): factor matrix ось
|
||||
`session_turn` → `session_segment_turn` (turns-since-last-compaction —
|
||||
что фактически и было per parser). Семантика не меняется, только имя
|
||||
для ясности.
|
||||
- **`inferOutcome` neutral → soft_success** (#16) — см. §12.1.
|
||||
|
||||
### 12.4. STATUS.md generator
|
||||
|
||||
- **Real PII counter** (#3 SIMPLIFIED): `sanitizeWithCount` в pii-filter
|
||||
+ persistent `docs/observer/.pii-counters.json` (per-month aggregation,
|
||||
bumped on each Stop-hook write) + `countPiiMatches()` reads counter.
|
||||
STATUS перестаёт врать `0 PII matches`. PII patterns themselves NOT
|
||||
changed (F7 of parallel session already extended).
|
||||
- **`Last /brain-retro: N day(s) ago`** (#10): метрика читается из
|
||||
`docs/observer/.read-counter.json` `last_read_at`.
|
||||
- **`Legacy v1 episodes (not in factor analysis)`** (#18): count of
|
||||
pre-2026-05-19T08:06 episodes без schema_version=2 — visible.
|
||||
|
||||
### 12.5. /brain-retro skill
|
||||
|
||||
- **Step 4 explicit `recordRead`** (#15): replaced abstract 'bump'
|
||||
instruction with `node tools/observer-of-observer.mjs record`. Atomic
|
||||
read-modify-write через fs.
|
||||
- **Step 8a STATUS auto-refresh** (#19): после save retro-note запускается
|
||||
status-md-generator — STATUS.md становится immediately current
|
||||
(`Last /brain-retro: 0 day(s) ago`, fresh episode count).
|
||||
|
||||
### 12.6. Ad-hoc tooling
|
||||
|
||||
- **`tools/glob-latency-investigator.mjs`** (#17): one-off script для
|
||||
расследования Glob p50=12.7s аномалии из исходного ретро. Smoke-test
|
||||
на session 553717ec: top-5 slowest все `docs/adr/**` @ 20265ms — Glob
|
||||
recursive по ADR-каталогу = apparent culprit. Не production code path.
|
||||
|
||||
### 12.7. Infrastructure
|
||||
|
||||
- **`npm run test:tools`** (B3-1, applied вне scope плана): canonical
|
||||
entry point для `tools/observer-*.test.mjs` runner. Корневой
|
||||
`package.json` script.
|
||||
|
||||
### 12.8. Not done
|
||||
|
||||
- Phase 2 (out-of-scope, см. §10 + spec brain-governance §10):
|
||||
agent-judge для true vs nominal use, `confusion_marker` /
|
||||
`chain_divergence` как real judgments, real-time in-session friction
|
||||
flags, automatic Pravila/Tooling edits from factor matrix.
|
||||
- Task 5 (hot-file two-tier) — **SKIPPED** per REVISION v1.1: F4 of
|
||||
parallel session already added full exclude of `memory/*.md`; warm-tier
|
||||
для adjacent имел diminishing return.
|
||||
|
||||
### 12.9. Регрессия
|
||||
|
||||
После всех 18 task'ов: **NNN/NNN GREEN** в `npm run test:tools`
|
||||
(baseline 232 → final NNN — заполнить в финальном commit Task 21).
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
# Карта узлов iter9 — подсистема brain governance — design
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 2026-05-20
|
||||
**Статус:** design (approved by user «рк» 2026-05-20) → awaiting written-spec review → writing-plans
|
||||
**Автор:** Дмитрий (заказчик) + Claude (Opus 4.7) via superpowers:brainstorming
|
||||
**Связано:** `docs/automation-graph.html`, `docs/automation-graph-data.js`, `docs/brain-dashboard.html`,
|
||||
ADR-011, spec `2026-05-19-brain-governance-design.md`, spec `2026-05-19-observer-factor-analysis-design.md`,
|
||||
`lefthook.yml`, memory `project_automation_map.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и разрыв
|
||||
|
||||
«Карта» (`docs/automation-graph.html` + общий `docs/automation-graph-data.js`) застыла на iter8
|
||||
(снимок 18.05.2026). 19.05.2026 приехала подсистема **brain governance (ADR-011)** + observer
|
||||
factor-analysis — её на карте нет. data.js — общий: его правка автоматически кормит и
|
||||
`docs/brain-dashboard.html` (показывает ту же карту; сам себя узлом не рисует).
|
||||
|
||||
Разрыв (подтверждён data.js + спеками 19.05 + `lefthook.yml` + git-логом):
|
||||
|
||||
1. **Устаревшие версии в 4 узлах-правилах:** `pravila v1.29→v1.33`, `claude_md v2.16→v2.20`,
|
||||
`psr_v1 v3.14→v3.17`, `tooling v2.15→v2.17`.
|
||||
2. **Подсистема brain governance отсутствует:** `router-procedure.md` v1.0; Stop-хук
|
||||
`observer-stop-hook.mjs` (пишет эпизоды + routing-gate `decision:block`); скил `/brain-retro`;
|
||||
5 контролёров lefthook jobs 11–15 (C1 l1-watcher, C2 cross-ref-checker, C3 observer-of-observer,
|
||||
C4 status-md post-commit, C5 observer-coverage-checker); observer-инфраструктура `docs/observer/`
|
||||
(episodes JSONL, STATUS.md, .read-counter, notes); внутренние `.mjs` (transcript-parser,
|
||||
routing-detector, choice-detector, brain-retro-analyzer).
|
||||
3. **NODE_META** — снимок iter8; новых узлов нет.
|
||||
|
||||
## 2. Решения (согласованы с заказчиком)
|
||||
|
||||
- **Объём:** полный iter9 (а не light-sync).
|
||||
- **Подход:** A — subsystem-level: +9 узлов в их **естественных функциональных группах**
|
||||
(контролёры — с lefthook, Stop-хук — с hooks, скил — с skills_proj), как iter A6/D3/C9.
|
||||
Внутренние `.mjs` и `brain-dashboard.html` — в паспортах, не отдельными узлами
|
||||
(отвергнуты: B fine-grained ~13 узлов; C — отдельный кластер, как ruflo).
|
||||
|
||||
## 3. 9 новых узлов
|
||||
|
||||
| id | label | group | ring·angle | size |
|
||||
|---|---|---|---|---|
|
||||
| `router_procedure` | router-procedure v1.0 | rules | 1·210 | 24 |
|
||||
| `observer_stophook` | Stop: observer-stop-hook | hooks | 4·205 | 22 |
|
||||
| `sk_brain_retro` | /brain-retro (skill) | skills_proj | 3·210 | 18 |
|
||||
| `observer_evidence` | docs/observer/ episodes+STATUS | memory | 6·204 | 16 |
|
||||
| `lh_l1watcher` | lefthook: l1-watcher (C1) | lefthook | 5·150 | 16 |
|
||||
| `lh_crossref` | lefthook: cross-ref-checker (C2) | lefthook | 5·157 | 16 |
|
||||
| `lh_obs_obs` | lefthook: observer-of-observer (C3) | lefthook | 5·164 | 16 |
|
||||
| `lh_status_md` | lefthook: status-md (C4) | lefthook | 5·171 | 16 |
|
||||
| `lh_obs_cov` | lefthook: observer-coverage (C5) | lefthook | 5·178 | 16 |
|
||||
|
||||
Углы выбраны в свободных слотах (≥7° от соседей того же кольца); не-перекрытие проверяется
|
||||
рендером (см. §8). Новых групп/цветов нет — все 9 в существующих `GROUPS`.
|
||||
|
||||
## 4. Версии-метки + NODE_META (iter9)
|
||||
|
||||
- 4 узла-правила: версии-метки → v1.33 / v2.20 / v3.17 / v2.17; их `changed` → `19.05.2026`.
|
||||
- `META_SNAPSHOT '18.05.2026'→'20.05.2026'`; `META_WINDOW '09–18.05.2026'→'09–20.05.2026'`.
|
||||
- **Методология (как iter8, честная):** числа использования **старых** узлов НЕ перемеряются —
|
||||
транскрипты Claude Code недоступны как источник в репо (задокументированный предел iter8);
|
||||
только bump окна/снимка. Новым узлам `since='19.05.2026'`, `changed='—'`, `uses`:
|
||||
- `lh_l1watcher`/`lh_crossref`/`lh_obs_obs`/`lh_status_md`/`lh_obs_cov` — git-commit-count,
|
||||
затрагивающий их триггер-glob в окне (`usesSrc:'коммиты'`), подсчитывается при реализации.
|
||||
- `observer_stophook` — число строк-эпизодов в `docs/observer/episodes-2026-05.jsonl`
|
||||
(`usesSrc:'хук (эпизоды)'`), подсчитывается при реализации.
|
||||
- `observer_evidence` — значение из `.read-counter.json`, иначе baseline 1 (`usesSrc:'observer counter'`).
|
||||
- `sk_brain_retro` — baseline 1 (`usesSrc:'интеграция'`).
|
||||
- `router_procedure` — `uses:null` (rule-like, напрямую не вызывается; `usesSrc:'—'`).
|
||||
|
||||
## 5. 12 новых рёбер (EDGES)
|
||||
|
||||
- `claude_md→router_procedure` (§3.6 SoT), `tooling→router_procedure` (§4.X реестр → шаг 3),
|
||||
`pravila→router_procedure` (§12/§14/§15 hard-floor).
|
||||
- `pravila→observer_stophook` (§16: observer + routing-тег),
|
||||
`observer_stophook→observer_evidence` (пишет эпизоды + routing-gate).
|
||||
- `pravila→sk_brain_retro` (§16: факторный анализ раз в спринт),
|
||||
`sk_brain_retro→observer_evidence` (читает эпизоды).
|
||||
- `lh_l1watcher→tooling` (C1 STRICT: settings.json↔Tooling drift),
|
||||
`lh_crossref→claude_md` (C2 STRICT: version drift §0 cross-refs; репрезентирует 5-файловую проверку),
|
||||
`lh_obs_obs→observer_evidence` (C3 warn: счётчик +54w),
|
||||
`lh_status_md→observer_evidence` (C4: генерит STATUS.md),
|
||||
`lh_obs_cov→observer_evidence` (C5 warn: покрытие + регистрация).
|
||||
|
||||
## 6. 1 новый конфликт (3-color)
|
||||
|
||||
🟢 GREEN `observer_stophook ↔ hk_verifier`: оба на Stop-event; HK1 §5.3 — коллизии нет
|
||||
(append-chain), оба способны `decision:block`, Claude Code прогоняет все Stop-хуки, любой
|
||||
block ⇒ продолжение хода. Новых 🔴/⚫ нет (подсистема спроектирована бесконфликтно).
|
||||
|
||||
## 7. Секции (NODE_SECTION) + счётчики
|
||||
|
||||
`router_procedure→E1`, `observer_stophook→E2`, `sk_brain_retro→E8`, `observer_evidence→E4`,
|
||||
`lh_l1watcher→E1`, `lh_crossref→E1`, `lh_obs_obs→E2`, `lh_status_md→E2`, `lh_obs_cov→E2`.
|
||||
|
||||
Count-комментарии: узлы 125→134; rules 4→5; hooks 11→12; skills_proj 12→13; memory 24→25;
|
||||
lefthook 10→15; «Покрывает все N узлов» 125→134.
|
||||
|
||||
## 8. Паспорта/детали + верификация
|
||||
|
||||
- Каждому из 9 узлов — полный паспорт `nd()` (desc/when/limits/reportsTo/manages/together/conflicts);
|
||||
каждому из 13 рёбер — полный `EDGE_DETAILS` (type/when/transfers/mandatory/rule).
|
||||
Источник фактов — спеки 19.05 + **как-built `lefthook.yml`**: C1/C2 STRICT (блокируют коммит);
|
||||
C3/C5 warn-only (скрипт всегда exit 0); C4 post-commit `|| true`. Внутренние `.mjs` —
|
||||
в desc/limits родителя; `brain-dashboard.html` — в паспорте `observer_evidence`.
|
||||
- **Верификация:** рендер `automation-graph.html` через Playwright MCP (0 JS-ошибок; 9 узлов видны;
|
||||
без перекрытий; паспорта открываются; рёбра + GREEN-конфликт рисуются); `brain-dashboard.html`
|
||||
грузится с теми же данными (его Карта-view показывает новые узлы); sanity `node --check` на data.js;
|
||||
сверка count-комментариев с фактической длиной массивов; обновить smoke-PNG.
|
||||
|
||||
## 9. Вне scope
|
||||
|
||||
- Нормативка (Pravila/PSR_v1/Tooling/CLAUDE.md) — НЕ правится (это задача о карте, не о нормативке).
|
||||
- **Наблюдение (фиксирую, не правлю):** CLAUDE.md v2.18 / memory утверждают «C1+C2 WARN-only via
|
||||
`|| true` jobs 11-14» — расходится с как-built `lefthook.yml` (C1/C2 STRICT). Карта отрисует факт;
|
||||
правка нормативки — отдельная задача через `claude-md-management`.
|
||||
- `brain-dashboard.html` не редактируется (кормится из общего data.js).
|
||||
|
||||
## 10. Файлы
|
||||
|
||||
- `docs/automation-graph-data.js` — NODES (+9, версии-метки ×4), EDGES (+12), CONFLICT (+1 GREEN),
|
||||
NODE_SECTION (+9), count-комментарии.
|
||||
- `docs/automation-graph.html` — NODE_DETAILS (+9), EDGE_DETAILS (+13), NODE_META (+9 + bump
|
||||
снимка/окна + `changed` на 4 узлах-правилах).
|
||||
|
||||
Коммиты: 1–2 атомарных (data.js структура + html паспорта/мета), сообщение
|
||||
`feat(map): iter9 — brain governance subsystem`.
|
||||
@@ -0,0 +1,206 @@
|
||||
# Дизайн: эпик «Финансы» — наполнение разделов карты C6 + C7 (finance-tooling)
|
||||
|
||||
**Дата:** 2026-05-20
|
||||
**Статус:** утверждён заказчиком (brainstorming, 20.05.2026)
|
||||
**Ветка исполнения:** `worktree-finance-tooling-c6-c7` (worktree от origin/main `7df4786`)
|
||||
**Связанные ADR:** ADR-012 (новый — граница finance-tooling)
|
||||
**Режим:** экономия 5%
|
||||
|
||||
---
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Закрыть (наполнить) два пустых раздела карты dev-автоматики (`docs/automation-graph.html` / `docs/automation-graph-data.js`):
|
||||
|
||||
- **C6** «Финансы — биллинг и тарификация»
|
||||
- **C7** «Финансы — бухгалтерия и налоги»
|
||||
|
||||
По решению заказчика **разделы объединяются в один комплексный эпик «Финансы»** и решаются вместе. Карта — это «мозг»-маршрутизатор: на задачу «X в финансовом домене» она должна указывать инструмент «Y». Сейчас на оба раздела не указывает ни один узел `NODE_SECTION` (они в списке «пустых разделов — будущих доменов мозга»).
|
||||
|
||||
Требование заказчика: **полное покрытие** — каждая реальная потребность домена сопоставлена с узлом (а не 1–2 точечных инструмента).
|
||||
|
||||
## 2. Контекст
|
||||
|
||||
### 2.1. Что уже есть в коде (объект домена)
|
||||
|
||||
Биллинг-подсистема Лидерры (Plan 4) — подтверждённые файлы:
|
||||
|
||||
- `app/app/Services/Billing/PricingTierResolver.php` — 7-ступенчатая тарификация (pure).
|
||||
- `app/app/Services/Billing/LedgerService.php` — двойной баланс prepaid→₽ через `bcmath`.
|
||||
- `app/app/Services/Billing/BillingTopupService.php`, `ChargeResult.php`.
|
||||
- `app/app/Models/PricingTier.php`, `LeadCharge.php`; `app/app/Repositories/PricingTierRepository.php`.
|
||||
- `app/app/Http/Controllers/Api/{AdminPricingTiersController,AdminBillingController,BillingController,TenantChargesController}.php`.
|
||||
- `app/app/Jobs/Supplier/CsvReconcileJob.php` — hourly сверка, алерт при дрейфе >5%.
|
||||
- `app/app/Services/Reports/Providers/BillingSummaryProvider.php` — отчётность.
|
||||
- `app/app/Mail/TopupSuccessNotification.php`, `ZeroBalancePausedMail` (auto-pause при нуле).
|
||||
|
||||
C6 = «как Лидерра берёт деньги с клиентов за лиды» (тарифы, списания, баланс, сверка, авто-пауза). C7 = «учёт и налоги компании» (бухгалтерия, НДС/УСН, отчётность). Бухгалтерия компании в основном вне dev-репо (1С/аутсорс) — поэтому C7 dev-tooling скуднее C6.
|
||||
|
||||
### 2.2. Нормативная поверхность маршрутизации (актуальная на 20.05.2026)
|
||||
|
||||
С iter9 (ADR-011, brain governance) маршрутизация описана явно:
|
||||
|
||||
- `docs/router-procedure.md` v1.0 — 5-шаговая процедура «task → node(s)»; **читает** 9-атрибутный реестр Tooling §4.X (generic, перечень узлов не хардкодит).
|
||||
- `docs/routing-off-phase.md` v1.1 — таблица «триггер → off-phase узел» (§4.11–§4.35) + 12 канонических связок L1–L12.
|
||||
- Наблюдатель (observer): 9-атрибутные блоки на каждый узел реестра (Tooling §0.1 шаблон) + контролёры lefthook (C1 l1-watcher name@source, C2 cross-ref-checker).
|
||||
|
||||
**Следствие:** новые узлы обязаны попасть в routing-таблицу (роутер) и получить 9-атрибутные блоки + пройти контролёров (наблюдатель). Это расширяет нормативную правку сверх классических 4 файлов.
|
||||
|
||||
## 3. Решение по объёму
|
||||
|
||||
**Гибрид с полным покрытием:** reuse-классификация существующих узлов в C6/C7 + установка релевантного marketplace-плагина + два своих проектных скила под реальные gap'ы + честная регистрация неприменимого как not-applicable/DEFERRED.
|
||||
|
||||
## 4. Архитектура решения — слои
|
||||
|
||||
| Слой | Содержание |
|
||||
|---|---|
|
||||
| **Установка (1 узел)** | `finance` plugin (knowledge-work-plugins, Anthropic) — enable в `~/.claude/settings.json` (marketplace уже добавлен; механика как operations/product-management). |
|
||||
| **Свои скилы (2 узла)** | `.claude/skills/billing-audit/` (C6) + `.claude/skills/ru-tax-accounting/` (C7). |
|
||||
| **Reuse-классификация** | ~11 существующих узлов → C6/C7 через `NODE_SECTION_SECONDARY` (основная привязка `NODE_SECTION` 1:1 не трогается). |
|
||||
| **DEFERRED / not-applicable** | warehouse-MCP плагина (Snowflake/Databricks/BigQuery) — не наш стек; SOX-скилы — нет SOX у частной РФ-компании. |
|
||||
| **Нормативка** | Tooling Прил.Н, PSR_v1, Pravila, CLAUDE.md, routing-off-phase.md, router-procedure.md, ADR-012. |
|
||||
| **Карта** | +3 узла + рёбра + secondary-классификация + META/версии. |
|
||||
|
||||
## 5. Новые узлы
|
||||
|
||||
### 5.1. #61 — `finance` plugin (knowledge-work-plugins)
|
||||
|
||||
- **Тип:** plugin (enable), Anthropic Verified, тот же marketplace, что operations #51 / product-management #42.
|
||||
- **Состав:** 9 скилов — `reconciliation`, `variance-analysis`, `financial-statements`, `close-management`, `journal-entry`, `journal-entry-prep`, `sox-testing`, `audit-support`, плюс MCP-серверы (snowflake/databricks/bigquery/slack/ms365 — http).
|
||||
- **Домашний раздел:** **C7** (primary) — плагин на ~80% бухгалтерский.
|
||||
- **Применимость к РФ-контексту (честно):**
|
||||
- ✅ применимо: `reconciliation` (сверка ledger↔банк/субледжер — cross-ref в C6 под `CsvReconcileJob`), `variance-analysis` (план/факт выручки — C6/C7).
|
||||
- ⚠️ частично (US-GAAP, у нас РСБУ): `financial-statements`, `close-management`, `journal-entry`, `journal-entry-prep` — концепция применима для внутренней управленческой отчётности; форма/план счетов отличаются.
|
||||
- ❌ not-applicable РФ: `sox-testing`, `audit-support` (SOX 404 — США; у частной РФ-компании SOX отсутствует).
|
||||
- **MCP-серверы:** **DEFERRED / не используем** — Snowflake/Databricks/BigQuery не в стеке (PG 16 + Redis); ms365/slack — не подключены. Фиксируем как not-applicable (аналог Figma MCP #44 deferred-pending).
|
||||
- **Smoke:** после enable — скилы `finance:*` доступны в списке скилов сессии.
|
||||
|
||||
### 5.2. #62 — `billing-audit` (свой проектный скил, C6)
|
||||
|
||||
- **Тип:** self-authored project skill (`.claude/skills/billing-audit/`), как `process-analysis`, `audit-portal`, `regression` — не вендоренный, линтуется.
|
||||
- **Зачем:** generic-инструменты не кодируют **денежные инварианты именно Лидерры**.
|
||||
- **Scope (чек-лист + процедура аудита при правке/ревью биллинг-кода):**
|
||||
1. сохранение суммы prepaid→₽ через `bcmath` без утечек/потери копеек;
|
||||
2. идемпотентность списания (один лид = одно списание; повтор/ретрай не дублирует);
|
||||
3. корректность резолюции 7 ступеней `PricingTierResolver`;
|
||||
4. интерпретация дрейфа `CsvReconcileJob` (порог >5%) — что значит, куда смотреть;
|
||||
5. провенанс `charge_source` (откуда возникло списание).
|
||||
- **Границы (FIN5):** ≠ `process-modeling`/`process-analysis` (#52/#53 — *поток/процесс*, billing-audit — *денежная корректность реализации*); ≠ D3 audit-security/audit-portal (#39/#40 — *безопасность/портал целиком*, billing-audit — *деньги*). Объект анализа иной.
|
||||
- **Состав:** `SKILL.md` + `references/` (инварианты, ссылки на файлы биллинга) + `evals/evals.json` (триггер-eval).
|
||||
|
||||
### 5.3. #63 — `ru-tax-accounting` (свой проектный скил, C7)
|
||||
|
||||
- **Тип:** self-authored project skill (`.claude/skills/ru-tax-accounting/`).
|
||||
- **Зачем:** `finance`-плагин — US-GAAP/SOX; РФ-специфика (РСБУ + НК РФ) им не покрывается. Закрывает налогово-учётный gap C7 явно (по решению заказчика — вместо DEFERRED).
|
||||
- **Scope:** контекст РСБУ vs управленческий учёт; налоговые режимы (НДС / УСН доходы/доходы-расходы) применительно к SaaS-выручке за лиды; маппинг billing-выручки (`lead_charges`, `LedgerService`) → налоговая база; подготовка выгрузок/документов для бухгалтера; что является налогооблагаемым событием (списание / пополнение / возврат).
|
||||
- **Границы (FIN6):** ≠ `finance` plugin #61 (тот — generic/US-механика учёта + reconciliation/variance; ru-tax — РФ РСБУ/НК специфика); ≠ D1 «Юриспруденция/договорная» (договоры/право, не налоги); ≠ D2 «Защита ПДн» (персональные данные, не налоги).
|
||||
- **Состав:** `SKILL.md` + `references/` (РСБУ/НК РФ заметки, налоговые режимы) + `evals/evals.json`.
|
||||
|
||||
## 6. Reuse-классификация (существующие узлы → C6/C7)
|
||||
|
||||
Через `NODE_SECTION_SECONDARY` (узел остаётся в своём primary `NODE_SECTION`, добавляется вторичная привязка).
|
||||
|
||||
**→ C6 (биллинг/тарификация):**
|
||||
|
||||
| Потребность | Узел |
|
||||
|---|---|
|
||||
| Биллинг-модели (LedgerService/PricingTierResolver/lead_charges/tenants) | Boost #10 |
|
||||
| Тесты денежной логики (ступени, ledger, идемпотентность, гонка lockForUpdate) | Pest #18 + pest-parallel-debugger |
|
||||
| Money-precision статанализ (bcmath, без float) | Larastan #12 |
|
||||
| Runtime-ошибки списаний / auto-pause | Sentry MCP #34 |
|
||||
| Очередь `CsvReconcileJob` / кэш баланса | Redis MCP #35 |
|
||||
| Метрики выручки/тарифов (MRR, tier-распределение) | product-management metrics-review #42 |
|
||||
| Прогноз/моделирование выручки | data-scientist #49 |
|
||||
| Себестоимость поставщика (`supplier_lead_costs`) | operations vendor-review #51 |
|
||||
| Финансовый риск / дрейф сверки | operations risk-assessment #51 |
|
||||
| State-машина charge-lifecycle / discovery | process-modeling #52 / process-analysis #53 |
|
||||
| Доки bcmath / money-lib | context7 #60 |
|
||||
| Сверка ledger↔субледжер | finance `reconciliation` #61 (cross-ref) |
|
||||
| Анализ отклонений выручки | finance `variance-analysis` #61 (cross-ref) |
|
||||
|
||||
**→ C7 (бухгалтерия/налоги):**
|
||||
|
||||
| Потребность | Узел |
|
||||
|---|---|
|
||||
| Управленческая отчётность / финмодели | data-scientist #49 |
|
||||
| Выгрузка billing→учёт (данные) | Boost #10 / Pest #18 |
|
||||
| Финансовый риск / комплаенс-трекинг | operations risk-assessment / compliance-tracking #51 |
|
||||
| Сверка / отклонения / отчётность (US-механика) | finance plugin #61 (primary) |
|
||||
| РФ РСБУ / НК РФ / НДС-УСН | ru-tax-accounting #63 |
|
||||
|
||||
> `BillingSummaryProvider.php` — это код-объект (отчётный провайдер), не узел тулчейна; упоминается как объект, на который указывают узлы, не получает номер.
|
||||
|
||||
## 7. Граница C6 ↔ C7 (FIN7)
|
||||
|
||||
- **C6 биллинг/тарификация** = начисление денег клиенту за лиды: тарифные ступени, списания, баланс, top-up, сверка поставки, авто-пауза.
|
||||
- **C7 бухгалтерия/налоги** = учёт и налоги компании: РСБУ, НДС/УСН, отчётность, налоговая база, закрытие периода.
|
||||
- Точка стыка: billing-выручка (`lead_charges`/`LedgerService`) — это **выход C6** и **вход C7** (источник налоговой базы). Скилы не пересекаются: billing-audit проверяет корректность *начисления*; ru-tax-accounting переводит выручку в *налоговый/учётный* контекст.
|
||||
|
||||
Фиксируется в **ADR-012**.
|
||||
|
||||
## 8. Нормативная поверхность (8 артефактов)
|
||||
|
||||
1. **Tooling Прил.Н** — §4.36 finance plugin / §4.37 billing-audit / §4.38 ru-tax-accounting (каждый: 9-атрибутный блок наблюдателя + проза); §0 счётчик **+3** (база сверяется из Tooling §0 «КАНОН СЧЁТЧИКОВ» на этапе нормативки — ожидаемо 60→**63**) + новая off-phase подкатегория `finance-tooling`; header bump.
|
||||
2. **PSR_v1** — R10.1 +3 строки (finance plugin в блок плагинов; 2 скила — блок скилов/note). Не UI → вне R6.0/R6.1/R14. Version bump.
|
||||
3. **Pravila** — §13.2 +абзац «Off-phase finance-tooling». Version bump.
|
||||
4. **CLAUDE.md** — §3.3 +#61/#62/#63 (однострочный индекс, пин на Tooling §4.36–§4.38); §0 cross-refs bump; §6 +абзац; §9 +entry; шапка bump. Прямой Edit (worktree-эксцепшн §5 п.10).
|
||||
5. **routing-off-phase.md** (роутер) — +3 строки routing-таблицы (триггеры → #61/#62/#63); +каноническая связка **L13** (finance-цепочка); scope-диапазон §4.11→§4.38; version v1.1→v1.2.
|
||||
6. **router-procedure.md** (роутер) — changelog-touch (узлы появляются в реестре §4.X, который процедура читает; перечень generic — структурных правок не требует).
|
||||
7. **ADR-012** — `docs/adr/012-finance-tooling.md`: граница C6↔C7; applicability finance plugin (US-GAAP/SOX vs РФ); billing-audit vs process-*/D3; ru-tax vs finance/D1/D2; DEFERRED warehouse-MCP.
|
||||
8. **Наблюдатель** — 9-атрибутные блоки (п.1) + прогон контролёров **C1 l1-watcher** (name@source consistency) + **C2 cross-ref-checker** → GREEN/WARN-only на новых узлах (контролёры warn-only `|| true`, но дрейф по новым узлам не вносим).
|
||||
|
||||
## 9. Карта (`automation-graph-data.js` + `automation-graph.html`)
|
||||
|
||||
- **+3 узла** в `NODES`: `finance_plugin` (group plugins/skills_proj), `billing_audit` (skill), `ru_tax` (skill).
|
||||
- **NODE_SECTION:** `finance_plugin: 'C7'`, `billing_audit: 'C6'`, `ru_tax: 'C7'`.
|
||||
- **NODE_SECTION_SECONDARY:** `finance_plugin: ['C6']` + reuse-привязки из §6 (Boost/Pest/Larastan/Sentry/Redis/PM/data-scientist/operations/process-*/context7 → добавить 'C6' и/или 'C7').
|
||||
- **EDGES:** связи новых узлов с соседями (billing_audit → Pest/Boost; finance_plugin → operations/PM; ru_tax → finance_plugin).
|
||||
- **NODE_DETAILS** в `automation-graph.html`: описания/`together`/`boundaries` для 3 узлов.
|
||||
- **META/версии-метки:** обновить счётчики узлов/рёбер и версию-метку узла `claude_md` (актуальная версия определяется на этапе нормативки) после нормативки.
|
||||
|
||||
## 10. Конфликт-аудит (для плана)
|
||||
|
||||
| Код | Риск | Резолюция |
|
||||
|---|---|---|
|
||||
| FIN1 | warehouse-MCP finance не под стек | DEFERRED / not-applicable, не подключаем |
|
||||
| FIN2 | SOX-скилы (sox-testing/audit-support) | not-applicable РФ (нет SOX) |
|
||||
| FIN3 | finance vs operations (vendor-review/risk) | finance = учёт/сверка/отчётность; operations = операционные процессы/риск |
|
||||
| FIN4 | finance `reconciliation` vs `CsvReconcileJob` | инструмент (скил) vs код (наш джоб) — не конфликт, скил помогает анализировать джоб |
|
||||
| FIN5 | billing-audit vs process-*/audit-portal/D3 | объект иной (деньги vs поток vs безопасность) |
|
||||
| FIN6 | ru-tax vs finance plugin vs D1/D2 | ru-tax = РФ РСБУ/НК; finance = US-механика; D1 = право; D2 = ПДн |
|
||||
| FIN7 | граница C6↔C7 | ADR-012: начисление клиенту vs учёт/налоги компании |
|
||||
| FIN8 | lint вендоренного/enabled | self-authored скилы линтуются; finance-плагин — в plugin-cache, не в репо |
|
||||
|
||||
## 11. Изоляция и исполнение
|
||||
|
||||
- **Worktree** от origin/main `7df4786` (= текущий main, superset iter9), ветка `worktree-finance-tooling-c6-c7`. Свежий worktree без gitignored-файлов — node_modules/vendor через junction, bin/.env скопированы.
|
||||
- **Коммиты явными путями** (`git commit -- <пути>`) — параллельные сессии активны (memory `feedback_git_commit_explicit_paths`).
|
||||
- **Субагенты + git** — только Sonnet/Opus (Pravila §15.1), верификация commit-базы после каждого.
|
||||
- **Pre-flight sync** (Pravila §15.2) перед правкой каждого из 8 нормативных файлов: `git fetch && git log HEAD..origin/main --oneline`.
|
||||
|
||||
**Фазы (один план):**
|
||||
|
||||
1. **Фаза 1 — C6:** скил `billing-audit` (TDD: triggers-eval first) + reuse-классификация C6 в карте.
|
||||
2. **Фаза 2 — C7:** enable finance plugin (+smoke) + скил `ru-tax-accounting` (TDD) + reuse-классификация C7.
|
||||
3. **Фаза 3 — нормативка + роутер + наблюдатель + карта + ADR:** Tooling/PSR_v1/Pravila/CLAUDE.md + routing-off-phase.md/router-procedure.md + ADR-012 + automation-graph + 9-атрибутные блоки + прогон C1/C2.
|
||||
|
||||
## 12. Тестирование / верификация
|
||||
|
||||
- Скилы: триггер-eval (как discovery-interview 20/20) — `evals.json` на оба скила; near-miss к соседним узлам (process-analysis/D3/finance plugin/D1) уходят корректно.
|
||||
- Нормативка: markdownlint + cspell (lefthook) GREEN; lychee на изменённых .md (pre-push).
|
||||
- Карта: JS-smoke (узлы парсятся, NODE_SECTION покрывает все узлы, рёбра валидны) — как iter8/iter9.
|
||||
- Контролёры: C1 l1-watcher + C2 cross-ref-checker без новых drift по добавленным узлам.
|
||||
- Перед коммитом — pre-commit (docs-only коммиты: markdownlint/cspell/gitleaks; PHP-коммиты скила-evals при наличии PHP — pint/larastan/pest).
|
||||
- Регрессия `/regression quick` перед закрытием; full — по необходимости.
|
||||
|
||||
## 13. Out of scope (YAGNI)
|
||||
|
||||
- Реальная интеграция платёжного провайдера (карты) — Б-1 blocked, отдельный ADR.
|
||||
- Подключение warehouse-MCP (Snowflake/Databricks/BigQuery) — не наш стек.
|
||||
- SOX-процедуры — неприменимо РФ.
|
||||
- Автоматизация бухгалтерии в 1С — вне dev-репо.
|
||||
- UI/функциональные правки биллинг-подсистемы — это эпик тулчейна/карты, не продукта.
|
||||
|
||||
## 14. Критерий готовности
|
||||
|
||||
- Разделы C6 и C7 карты непустые (есть узлы); 3 новых узла формализованы во всех 8 нормативных артефактах согласованно; роутер знает новые узлы; наблюдатель имеет 9-атрибутные блоки; ADR-012 фиксирует границы; регрессия GREEN; запушено `origin <ветка>:main`.
|
||||
@@ -0,0 +1,149 @@
|
||||
# Design: переделка миграции проектов + распределения лидов
|
||||
|
||||
**Дата:** 2026-05-20 · **Тип:** FEATURE epic · **Статус:** дизайн на ревью заказчика.
|
||||
**Discovery-brief:** [docs/discovery/2026-05-20-project-migration-redesign-brief.md](../../discovery/2026-05-20-project-migration-redesign-brief.md) (требования R1–R7 + алгоритм заказа/распределения).
|
||||
|
||||
## 1. Контекст
|
||||
|
||||
Два связанных изменения вокруг жизненного цикла «проект Лидерры → проект(ы) у поставщика crm.bp-gr.ru → входящие лиды»:
|
||||
|
||||
1. **Экспорт проекта** сейчас отложенный и неполный: онлайн ставится только каркас (лимит 0, регионы пусто), параметры дописывает ночной крон. Поле `regions` на портал уходит **пустым** (gap зафиксирован в [recon §2](../../discovery/2026-05-19-rt-project-form-locators.md)).
|
||||
2. **Распределение лидов** не имеет потолка получателей — один номер может уйти многим клиентам (владелец номера «сходит с ума»).
|
||||
|
||||
Гранулярность региона — **субъект РФ (коды 1..89)**, уже реализована в ЛК (Plan 6): [resources/js/constants/regions.ts](../../../app/resources/js/constants/regions.ts), [NewProjectDialog.vue:88-109](../../../app/resources/js/views/projects/NewProjectDialog.vue#L88-L109). `projects.regions INT[]` — коды субъектов; legacy `region_mask`/`region_mode` (8 ФО) — DEPRECATED, использовался только phone-фильтром.
|
||||
|
||||
## 2. Закрытые решения (brainstorming 2026-05-20)
|
||||
|
||||
| # | Под-вопрос | Решение |
|
||||
|---|---|---|
|
||||
| 1 | Scope тумблера режима экспорта | **Глобально (SaaS)** — `system_settings`, единое поведение для всех тенантов. |
|
||||
| 2 | Онлайн-режим при недоступном портале | **Ярус-3 очередь** — переиспользовать существующий `FailoverProjectChannel` (ярус1 AJAX → ярус2 форма → ярус3 ручная очередь). Пользователь не блокируется. |
|
||||
| 3 | «Вся РФ» / взрыв проектов | Создание per-субъект **оставить как есть**; от взрыва — **ручной экран очистки** в админке. Регион в ЛК — **обязателен** (валидация). |
|
||||
| 4 | Имя проекта на портал | **Строго = источник** (`signal_identifier`: домен/телефон/отправитель); тег = регион; отдельного человекочитаемого поля **нет**. |
|
||||
| 5 | Ключ конкуренции (источник+регион+день) | Сопоставляется **структурно** — через `supplier_project` (= источник × субъект). Phone-фильтр `region_mask` в `LeadRouter` **убрать** (доверяем региону проекта; для мобильных он всё равно no-op). |
|
||||
| P1 | «Вся РФ» при «требовать регион» | Явная опция «Вся РФ» в ЛК → **один пул** (1 save × 3 платформы = 3 проекта, native `regions=[]`, `tag="РФ"`). При выборе «Вся РФ» — **предупреждение «вы выбрали всю Россию» с обязательным подтверждением**. Конкретные субъекты → per-субъект save. |
|
||||
| P2 | Структура | **Один связный spec** (этот файл); фазы — в плане. |
|
||||
|
||||
## 3. Архитектура изменений (обзор)
|
||||
|
||||
Ядро — переход от модели «1 Лидерра-проект ↔ 3 supplier_projects (по слоту на платформу)» к **«1 Лидерра-проект ↔ N supplier_projects (субъект × платформа), M:N»**. supplier_project становится ключом конкуренции `(источник × субъект × платформа)`; вокруг него крутятся заказ, распределение и очистка.
|
||||
|
||||
```
|
||||
ЛК (выбор субъектов / «Вся РФ» + подтверждение)
|
||||
↓ create/edit
|
||||
ProjectService → (режим: онлайн|пакетный)
|
||||
↓
|
||||
SyncSupplierProjectJob (онлайн, сразу, полные параметры) SyncSupplierProjectsJob (пакетный крон 18:00)
|
||||
↓ per (источник × субъект): один save с флагами B1+B2+B3 (R5), портал делит лимит (R6)
|
||||
supplier_projects (subject_code) ←M:N→ projects (pivot)
|
||||
↓ заказ = max(наиб, ceil(Σ/3)) per supplier_project
|
||||
crm.bp-gr.ru
|
||||
↓ webhook лид (raw_payload[tag] = субъект)
|
||||
RouteSupplierLeadJob → LeadRouter (по supplier_project, без phone-фильтра)
|
||||
↓ cap=3 рандом из недобравших
|
||||
deals (region tag из payload) + lead_charges
|
||||
```
|
||||
|
||||
## 4. Компоненты
|
||||
|
||||
### 4.1. Режимы экспорта (R1/R2/R3)
|
||||
|
||||
- **Глобальный тумблер** `system_settings['supplier_export_mode']` ∈ `{online, batch}`, default `batch` (прод-безопасно). Тип `string`. Сид-строка, без миграции схемы.
|
||||
- **Пакетный** (`batch`) — текущий ночной `SyncSupplierProjectsJob` ([app/app/Jobs/Supplier/SyncSupplierProjectsJob.php:59](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L59)). **Крон уже `dailyAt('18:00')`** ([console.php:52-54](../../../app/routes/console.php#L52)) — R2 фактически выполнен в failover-spec 19.05.2026; формулировка brief «20:30 → 18:00» устарела. В эпике R2 = только закрепить тестом (`SupplierScheduleTest` уже это проверяет).
|
||||
- **Онлайн** (`online`) — при create/edit проекта в ЛК `ProjectService` сразу диспатчит полный sync (лимит/дни/регионы/субъекты), не каркас. При недоступном портале — стандартная эскалация `FailoverProjectChannel` в ярус-3 очередь (Q2). Пользователь ЛК не ждёт результата.
|
||||
- Резолвер режима — единая точка (напр. `SupplierExportMode::current()`), читается и `ProjectService`, и админкой.
|
||||
|
||||
### 4.2. Модель данных
|
||||
|
||||
- **`supplier_projects`** ([schema.sql:902](../../../db/schema.sql#L902)):
|
||||
- +`subject_code SMALLINT NULL` — субъект РФ (1..89); `NULL` = пул «Вся РФ».
|
||||
- UNIQUE index `(platform, unique_key)` → `(platform, unique_key, subject_code)` (NULL-субъект уникален отдельно; под `NULLS NOT DISTINCT` Postgres 15+ — пул «Вся РФ» один на источник×платформу).
|
||||
- `current_regions` (JSONB) хранит native-`regions`, переданные порталу: `[subject_code]` для per-субъект, `[]` для пула.
|
||||
- **Pivot `project_supplier_links`** (новая таблица, заменяет 3 FK-слота):
|
||||
- `project_id BIGINT`, `supplier_project_id BIGINT`, `platform VARCHAR(4)`, `subject_code SMALLINT NULL`, `created_at`.
|
||||
- PK/UNIQUE `(project_id, supplier_project_id)`; FK на обе стороны (`ON DELETE CASCADE` со стороны project, `ON DELETE CASCADE` со стороны supplier_project).
|
||||
- Колонки `projects.supplier_b{1,2,3}_project_id` ([schema.sql:808-810](../../../db/schema.sql#L808-L810)) — **DEPRECATED**, миграция данных в pivot (см. §5), затем удаление в follow-up.
|
||||
- **`deals`** ([schema.sql:1573](../../../db/schema.sql#L1573)): регион-тег из `raw_payload['tag']` (имя субъекта) → код субъекта (через словарь `regions.ts`/PHP-зеркало) → пишем в **новую колонку `deals.subject_code SMALLINT NULL`** (не путать с `region_code` ISO-3166, который phone-derived и ненадёжен для мобильных). Источник истины региона сделки = тег поставщика, не телефон.
|
||||
- **`system_settings`** ([schema.sql:566](../../../db/schema.sql#L566)) — сид `supplier_export_mode`.
|
||||
|
||||
### 4.3. Маппинг save (R5/R6/R7, Q4, P1)
|
||||
|
||||
- **R5** — один `save` с тремя флагами `srcrt+srcbl+srcmt=true` → портал создаёт 3 rt-проекта (B1/B2/B3). **Решение заказчика: вариант а** — ответ `rt-project-save` отдаёт `id` только последнего (recon §находка 2), поэтому после save **дочитать `listProjects`** ([SupplierPortalClient.php:61](../../../app/app/Services/Supplier/SupplierPortalClient.php#L61)) и найти 3 свежесозданных по `name`+`tag` → забрать все 3 `external_id`. Fallback (б) «3 раздельных save» — только если smoke варианта а провалится (см. §8 R-SAVE). Текущий код шлёт по одному флагу — переписать.
|
||||
- **R6** — убрать наш сплит `SupplierQuotaAllocator::distributeForPlatform` ([SupplierQuotaAllocator.php:73](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L73)); слать **один лимит** на (источник×субъект), портал делит на B1/B2/B3 поровну (verified: лимит 15 → по 5).
|
||||
- **R7** — `tag` = имя субъекта (не `_lidpotok`, [SupplierPortalClient.php:431](../../../app/app/Services/Supplier/SupplierPortalClient.php#L431)); native `regions=[subject_code]`; per-субъект отдельный save. **«Вся РФ» (P1)** → один save, `tag="РФ"`, `regions=[]`.
|
||||
- **Q4** — `name` = `signal_identifier` (как сейчас, [SyncSupplierProjectJob.php:159](../../../app/app/Jobs/SyncSupplierProjectJob.php#L159)); строго источник.
|
||||
- `unique_key` — расширить субъектом: `{signal_identifier}` (как сейчас) + `subject_code` уходит в отдельную колонку (§4.2), уникальность — составным индексом.
|
||||
|
||||
### 4.4. Регион → deals (R7)
|
||||
|
||||
В `RouteSupplierLeadJob` при создании Deal: взять `raw_payload['tag']` → резолв в `subject_code` (PHP-словарь субъектов, зеркало `regions.ts`) → записать `deals.subject_code`. При теге `"РФ"`/неизвестном — `NULL`. Доступно для фильтров/аналитики сделок.
|
||||
|
||||
### 4.5. Формула заказа (алгоритм brief)
|
||||
|
||||
Per `supplier_project` (= источник×субъект), для eligible-сегодня клиентов (по pivot, workday-маска, активность):
|
||||
|
||||
```
|
||||
Σ = сумма daily_limit_target клиентов
|
||||
наиб = max(daily_limit_target)
|
||||
заказ = max( наиб , ceil(Σ / 3) )
|
||||
```
|
||||
|
||||
- `ceil(Σ/3)` — ёмкость шаринга (лид продаётся ≤3 раз); `наиб` — крупнейший клиент должен добрать.
|
||||
- Заказ — потолок запроса; платим за фактически поступившие. Один лимит на (источник×субъект), портал делит (R6).
|
||||
- Переписать `SupplierQuotaAllocator::allocate` ([:38](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L38)) — pure-функция, агрегирует по pivot вместо суммы по платформам; `distributeForPlatform` удалить.
|
||||
- Примеры (verified в brief): `[5,5,10,20]→20`; `15×5+10 (16 кл.)→29`; `3×15→15`; `3×15+30→30`; `4×10→14`.
|
||||
|
||||
### 4.6. Распределение лида (cap=3, brief)
|
||||
|
||||
- `LeadRouter::matchEligibleProjects` ([LeadRouter.php:46](../../../app/app/Services/LeadRouter.php#L46)) — кандидаты по **pivot** (вместо `match($platform)` по FK-колонке), фильтры `is_active`, workday, `delivered_today < лимит`, баланс. **Убрать phone-фильтр** `phoneMatchesRegions` ([:87-93](../../../app/app/Services/LeadRouter.php#L87-L93)) — Q5.
|
||||
- `RouteSupplierLeadJob` ([RouteSupplierLeadJob.php:115](../../../app/app/Jobs/RouteSupplierLeadJob.php#L115)) — из eligible (остаток лимита > 0) выбрать **3 случайных** (cap=3), создать Deal каждому. Группировка выкинута. Дедуп по `source_crm_id`/`duplicate_of_id` — сохраняется.
|
||||
- Детерминизм тестов: рандом через инъектируемый сидируемый источник (Randomizer/closure).
|
||||
|
||||
### 4.7. Админка
|
||||
|
||||
- **Тумблер режима экспорта** (онлайн/пакетный) — в существующем разделе supplier-integration (`AdminSupplierIntegrationView.vue` + контроллер): GET текущий режим, POST смена → пишет `system_settings`.
|
||||
- **Экран «Проекты у поставщика»** (новый), строка на `supplier_project`:
|
||||
- источник (`unique_key`) · платформа · тег-субъект (`subject_code`→имя)
|
||||
- **кто заказывал** — тенанты/клиенты с активными `projects`, связанными через pivot
|
||||
- **дата последней поставки** — `max(supplier_leads.created_at)` по проекту (или по deals)
|
||||
- чекбоксы + bulk «Удалить выбранные» → `SupplierPortalClient::deleteProject(externalId)` ([SupplierPortalClient.php:97](../../../app/app/Services/Supplier/SupplierPortalClient.php#L97)) + снять локальную запись (CASCADE по pivot). Удаление — **ручное** (Q3).
|
||||
- Уважать окно портала (22:00–00:00 правки запрещены) — при попытке в окне показать ошибку/деферить.
|
||||
- **NB:** уже существует авто-джоб `CleanupInactiveSupplierProjectsJob` ([console.php:55](../../../app/routes/console.php#L55), daily 02:00) — TTL-очистка по `supplier_projects.inactive_since` (180 дней). Ручной экран — **дополнение** (немедленная очистка orphan по решению админа), не замена; авто-TTL остаётся.
|
||||
|
||||
### 4.8. ЛК (Q3b, P1)
|
||||
|
||||
- **Валидация**: нельзя сохранить проект без выбора региона (минимум один субъект **или** «Вся РФ»). Backend `ProjectController` store/update + фронт.
|
||||
- **Опция «Вся РФ»**: вернуть в autocomplete (sentinel `code:0`, сейчас отфильтрован [NewProjectDialog.vue:154](../../../app/resources/js/views/projects/NewProjectDialog.vue#L154)). Выбор «Вся РФ» — взаимоисключающий с конкретными субъектами.
|
||||
- **Предупреждение (P1)**: при выборе «Вся РФ» — модал/диалог «Вы выбрали всю Россию — проект будет получать лиды по всем регионам. Подтвердить?» с обязательным подтверждением до сохранения.
|
||||
|
||||
## 5. Данные и миграция
|
||||
|
||||
1. Добавить `supplier_projects.subject_code`, новый UNIQUE-индекс, pivot `project_supplier_links`, `deals.subject_code`, сид `system_settings.supplier_export_mode`. Запись в `db/CHANGELOG_schema.md` ([правило §4.2](../../Pravila_raboty_Claude_v1_1.md)).
|
||||
2. **Бэкофилл pivot** из существующих `projects.supplier_b{1,2,3}_project_id` (для каждого ненулевого слота → строка pivot, `subject_code=NULL` для legacy записей).
|
||||
3. RLS/роли: pivot и новые колонки — под существующую модель (`crm_supplier_worker` BYPASSRLS для sharing-flow читает pivot; tenant-проекты — под RLS). `rls-reviewer` на миграции.
|
||||
4. Удаление `supplier_b{1,2,3}_project_id` — **follow-up** после переключения всех читателей (LeadRouter, sync-jobs, admin) на pivot. В этом эпике колонки остаются (двойная запись) до зелёной регрессии — снижает риск.
|
||||
|
||||
## 6. Тестирование (TDD)
|
||||
|
||||
- **Pure**: `SupplierQuotaAllocator::allocate` — 5 verified-примеров заказа + edge (1 клиент, недобор, sms-без-keyword).
|
||||
- **LeadRouter/RouteSupplierLeadJob**: cap=3 (4+ eligible → ровно 3), <3 eligible → все, лимит-потолок (недобравшие), сидируемый рандом, дедуп.
|
||||
- **Sync**: онлайн полный sync; пакетный 18:00; per-субъект save (tag, native regions); «Вся РФ» пул (1 save, regions=[], tag РФ); R5 multi-flag; R6 нет сплита; failover ярус-3.
|
||||
- **region→deals**: тег субъекта → `deals.subject_code`; «РФ»/неизвестный → NULL.
|
||||
- **Админка**: список (кто заказывал, дата последней поставки), bulk-delete, тумблер режима; окно портала.
|
||||
- **ЛК (Vitest)**: валидация «регион обязателен»; «Вся РФ» предупреждение+подтверждение; взаимоисключение.
|
||||
- **Регрессия**: полный Supplier+Integration+Webhook suite зелёный; миграция `migrate:fresh` + бэкофилл.
|
||||
|
||||
## 7. Вне scope / будущее
|
||||
|
||||
- Per-tenant режим экспорта (сейчас глобальный, Q1).
|
||||
- Удаление DEPRECATED `supplier_b{1,2,3}_project_id` и `region_mask`/`region_mode` (follow-up после переключения читателей).
|
||||
- Автоматическая очистка проектов (сейчас только ручная, Q3).
|
||||
- Расширение `PhonePrefixService` до полного справочника субъектов (регион теперь приходит тегом — потребность снижается).
|
||||
|
||||
## 8. Риски и известные ограничения
|
||||
|
||||
- **R-SAVE (med, решение принято — вариант а):** ответ `rt-project-save` возвращает `id` последнего из 3 проектов (recon §находка 2). Подход: после мульти-save дочитать `listProjects` и сопоставить 3 свежих по `name`+`tag`. **Task 1 плана = живой smoke**, что список реально отдаёт все 3 свежесозданных; fallback (б) «3 раздельных save» — только если smoke провалится. До зелёного smoke прод-путь не переписывать.
|
||||
- **R-PIVOT (high):** замена 3-FK на M:N трогает LeadRouter, sync-jobs, admin, тесты — фазировать; двойная запись (колонки + pivot) до зелёной регрессии.
|
||||
- **R-WINDOW:** правки/удаления у поставщика запрещены 22:00–00:00 МСК — sync и cleanup уважают окно (`WindowDeferredException` уже есть).
|
||||
- **R-RANDOM:** cap=3 рандом должен быть детерминируемым в тестах (инъекция источника случайности).
|
||||
- **Не верифицировано:** реальный JSON-ответ multi-flag save (R-SAVE); поведение портала при `tag` с кириллицей-субъектом на больших объёмах.
|
||||
+3
-1
@@ -16,7 +16,9 @@
|
||||
"a11y:handoff": "pa11y-ci --config pa11y-handoff.config.json",
|
||||
"check:docs": "run-p lint:md spell links a11y",
|
||||
"sast": "semgrep --config=p/php --config=p/javascript --config=p/typescript --config=p/secrets --config=.semgrep.yml --error --time",
|
||||
"eval:llm": "promptfoo eval -c docs/ml/promptfoo-example/promptfooconfig.yaml"
|
||||
"eval:llm": "promptfoo eval -c docs/ml/promptfoo-example/promptfooconfig.yaml",
|
||||
"brain:dashboard": "node tools/brain-dashboard-server.mjs",
|
||||
"test:tools": "cd app && npx vitest run --config vitest.config.tools.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cspell/dict-en_us": "^4.4.33",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseEpisodes, normalizeEpisode, attributeNodes, filterEpisodes, groupBySession, aggregate, inferConflicts } from '../docs/observer/dashboard-core.js';
|
||||
|
||||
const v1 = {
|
||||
task_id: 'a', timestamps: { started_at: '2026-05-19T05:18:16.342Z', ended_at: '2026-05-19T06:05:55.439Z' },
|
||||
path_type: 'improvised', outcome: 'success',
|
||||
primary_rationale: { node_chosen: 'direct', hard_floor: { invoked: false, rules: [] }, task_classification: 'refactor' },
|
||||
events: [{ kind: 'tool_summary', counts: { TodoWrite: 2, AskUserQuestion: 5 } }],
|
||||
};
|
||||
const v2 = {
|
||||
schema_version: 2, task_id: 'b', task_ref: 'b',
|
||||
timestamps: { started_at: '2026-05-19T08:06:30.059Z', ended_at: '2026-05-19T08:10:43.437Z' },
|
||||
path_type: 'improvised', outcome: 'unknown', prompt_signal: 'new_task',
|
||||
decision_provenance: { kind: 'autonomous', claude_would_have_chosen: null },
|
||||
environment: { economy_level: 5, model: 'claude-opus-4-7', post_compaction: true, session_turn: 82, parallel_session: true },
|
||||
task_size: { tool_calls: 12, files_touched: 1, files: ['x'] },
|
||||
primary_rationale: { node_chosen: 'direct', hard_floor: { invoked: false, rules: [] }, task_classification: 'bugfix' },
|
||||
events: [{ kind: 'tool_summary', counts: { Edit: 5 } }, { kind: 'error', message: 'e' }, { kind: 'retry' }],
|
||||
};
|
||||
|
||||
describe('parseEpisodes', () => {
|
||||
it('parses valid JSONL lines', () => {
|
||||
const text = [JSON.stringify(v1), JSON.stringify(v2)].join('\n');
|
||||
const r = parseEpisodes(text);
|
||||
expect(r.episodes).toHaveLength(2);
|
||||
expect(r.skipped).toBe(0);
|
||||
});
|
||||
|
||||
it('skips broken lines and counts them', () => {
|
||||
const text = [JSON.stringify(v1), '{ broken', '', JSON.stringify(v2)].join('\n');
|
||||
const r = parseEpisodes(text);
|
||||
expect(r.episodes).toHaveLength(2);
|
||||
expect(r.skipped).toBe(1);
|
||||
});
|
||||
|
||||
it('skips observer_error marker lines', () => {
|
||||
const text = [JSON.stringify({ observer_error: 'hook failed' }), JSON.stringify(v1)].join('\n');
|
||||
const r = parseEpisodes(text);
|
||||
expect(r.episodes).toHaveLength(1);
|
||||
expect(r.skipped).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeEpisode', () => {
|
||||
it('normalizes a v1 episode — v2-only fields are null', () => {
|
||||
const e = normalizeEpisode(v1);
|
||||
expect(e.schemaVersion).toBe(1);
|
||||
expect(e.outcome).toBe('success');
|
||||
expect(e.environment).toBeNull();
|
||||
expect(e.decisionProvenance).toBeNull();
|
||||
expect(e.taskSize).toBeNull();
|
||||
expect(e.durationMs).toBe(Date.parse(v1.timestamps.ended_at) - Date.parse(v1.timestamps.started_at));
|
||||
expect(e.tools).toEqual({ TodoWrite: 2, AskUserQuestion: 5 });
|
||||
});
|
||||
|
||||
it('normalizes a v2 episode with all fields', () => {
|
||||
const e = normalizeEpisode(v2);
|
||||
expect(e.schemaVersion).toBe(2);
|
||||
expect(e.environment.economy_level).toBe(5);
|
||||
expect(e.errorCount).toBe(1);
|
||||
expect(e.retryCount).toBe(1);
|
||||
expect(e.taskClassification).toBe('bugfix');
|
||||
});
|
||||
|
||||
it('merges tool_summary counts across multiple events', () => {
|
||||
const e = normalizeEpisode({
|
||||
...v1,
|
||||
events: [{ kind: 'tool_summary', counts: { Read: 2 } }, { kind: 'tool_summary', counts: { Read: 3, Bash: 1 } }],
|
||||
});
|
||||
expect(e.tools).toEqual({ Read: 5, Bash: 1 });
|
||||
});
|
||||
|
||||
it('collects skill_invoked skills in order', () => {
|
||||
const e = normalizeEpisode({
|
||||
...v1,
|
||||
events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }, { kind: 'skill_invoked', skill: 'superpowers:test-driven-development' }],
|
||||
});
|
||||
expect(e.skills).toEqual(['superpowers:writing-plans', 'superpowers:test-driven-development']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attributeNodes', () => {
|
||||
const ep = (over) => normalizeEpisode({ ...v1, ...over });
|
||||
|
||||
it('maps node_chosen skill id to a graph node', () => {
|
||||
const r = attributeNodes(ep({ primary_rationale: { node_chosen: 'superpowers:systematic-debugging', hard_floor: {} } }));
|
||||
expect(r.nodeIds).toContain('sk_debug');
|
||||
});
|
||||
|
||||
it('ignores node_chosen === "direct"', () => {
|
||||
const r = attributeNodes(ep({ primary_rationale: { node_chosen: 'direct', hard_floor: {} } }));
|
||||
expect(r.nodeIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps skill_invoked events to graph nodes', () => {
|
||||
const r = attributeNodes(ep({ events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] }));
|
||||
expect(r.nodeIds).toContain('sk_wplans');
|
||||
});
|
||||
|
||||
it('maps mcp__<server>__ tool names to MCP graph nodes', () => {
|
||||
const r = attributeNodes(ep({ events: [{ kind: 'tool_summary', counts: { 'mcp__github__get_issue': 2, 'mcp__laravel-boost__database-query': 1, Read: 4 } }] }));
|
||||
expect(r.nodeIds).toContain('mcp_gh');
|
||||
expect(r.nodeIds).toContain('mcp_boost');
|
||||
});
|
||||
|
||||
it('counts signals vs attributed — builtin tools are not signals', () => {
|
||||
const r = attributeNodes(ep({ events: [{ kind: 'tool_summary', counts: { Read: 1, 'mcp__github__x': 1 } }],
|
||||
primary_rationale: { node_chosen: 'superpowers:test-driven-development', hard_floor: {} } }));
|
||||
expect(r.attributed).toBe(2); // tdd skill + github mcp
|
||||
expect(r.signals).toBe(2); // only the tdd skill and the mcp tool count as signals
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterEpisodes', () => {
|
||||
const list = [
|
||||
normalizeEpisode({ ...v1, primary_rationale: { node_chosen: 'direct', hard_floor: {}, task_classification: 'refactor' }, events: [] }),
|
||||
normalizeEpisode({ ...v2, primary_rationale: { node_chosen: 'direct', hard_floor: {}, task_classification: 'bugfix' }, events: [{ kind: 'error', message: 'e' }] }),
|
||||
];
|
||||
it('returns all with an empty filter', () => {
|
||||
expect(filterEpisodes(list, {})).toHaveLength(2);
|
||||
});
|
||||
it('filters by task classification', () => {
|
||||
expect(filterEpisodes(list, { classification: 'bugfix' })).toHaveLength(1);
|
||||
});
|
||||
it('filters to episodes with errors only', () => {
|
||||
expect(filterEpisodes(list, { withErrors: true })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupBySession', () => {
|
||||
it('groups episodes by taskRef, newest first within and across groups', () => {
|
||||
const a1 = normalizeEpisode({ ...v2, task_ref: 'S', timestamps: { started_at: '2026-05-19T08:00:00Z', ended_at: '2026-05-19T08:01:00Z' } });
|
||||
const a2 = normalizeEpisode({ ...v2, task_ref: 'S', timestamps: { started_at: '2026-05-19T09:00:00Z', ended_at: '2026-05-19T09:01:00Z' } });
|
||||
const b1 = normalizeEpisode({ ...v2, task_ref: 'T', timestamps: { started_at: '2026-05-19T07:00:00Z', ended_at: '2026-05-19T07:01:00Z' } });
|
||||
const groups = groupBySession([a1, a2, b1]);
|
||||
const s = groups.find((g) => g.taskRef === 'S');
|
||||
expect(s.episodes[0].startedAt).toBe('2026-05-19T09:00:00Z');
|
||||
expect(groups[0].taskRef).toBe('S');
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregate', () => {
|
||||
const mk = (over) => normalizeEpisode({ ...v2, ...over });
|
||||
it('counts node heat from attributed nodes', () => {
|
||||
const list = [
|
||||
mk({ events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] }),
|
||||
mk({ events: [{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] }),
|
||||
];
|
||||
expect(aggregate(list).nodeHeat.sk_wplans).toBe(2);
|
||||
});
|
||||
it('computes redirect rate', () => {
|
||||
const list = [
|
||||
mk({ decision_provenance: { kind: 'user_directed_method', claude_would_have_chosen: 'x' } }),
|
||||
mk({ decision_provenance: { kind: 'autonomous', claude_would_have_chosen: null } }),
|
||||
];
|
||||
expect(aggregate(list).redirectRate).toBe(0.5);
|
||||
});
|
||||
it('tallies path_type and outcome distributions', () => {
|
||||
const list = [mk({ path_type: 'improvised', outcome: 'unknown' }), mk({ path_type: 'regulated', outcome: 'success' })];
|
||||
const a = aggregate(list);
|
||||
expect(a.pathType).toEqual({ improvised: 1, regulated: 1 });
|
||||
expect(a.outcome).toEqual({ unknown: 1, success: 1 });
|
||||
});
|
||||
it('reports total error and retry counts', () => {
|
||||
const list = [mk({ events: [{ kind: 'error', message: 'e' }, { kind: 'retry' }] })];
|
||||
const a = aggregate(list);
|
||||
expect(a.totalErrors).toBe(1);
|
||||
expect(a.totalRetries).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferConflicts', () => {
|
||||
const conflictEdges = [{ from: 'sk_wplans', to: 'sk_debug', dashes: true, label: '⚫', title: 't' }];
|
||||
it('returns design conflicts from dashed edges', () => {
|
||||
const r = inferConflicts([], conflictEdges);
|
||||
expect(r.design).toHaveLength(1);
|
||||
});
|
||||
it('reports friction — episodes with errors attributed to nodes', () => {
|
||||
const ep = normalizeEpisode({ ...v2,
|
||||
events: [{ kind: 'error', message: 'e' }, { kind: 'skill_invoked', skill: 'superpowers:writing-plans' }] });
|
||||
const r = inferConflicts([ep], conflictEdges);
|
||||
expect(r.friction.sk_wplans).toBe(1);
|
||||
});
|
||||
it('reports correlation when an errored episode spans a conflict-edge pair', () => {
|
||||
const ep = normalizeEpisode({ ...v2, events: [
|
||||
{ kind: 'error', message: 'e' },
|
||||
{ kind: 'skill_invoked', skill: 'superpowers:writing-plans' },
|
||||
{ kind: 'skill_invoked', skill: 'superpowers:systematic-debugging' },
|
||||
] });
|
||||
const r = inferConflicts([ep], conflictEdges);
|
||||
expect(r.correlation).toHaveLength(1);
|
||||
expect(r.correlation[0].pair).toEqual(['sk_wplans', 'sk_debug']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
// Static file server for the Brain Dashboard. Serves the repo root over
|
||||
// localhost so dashboard.html can fetch() episodes-*.jsonl (file:// cannot).
|
||||
// Run: node tools/brain-dashboard-server.mjs (npm run brain:dashboard)
|
||||
import { createServer as httpCreateServer } from 'node:http';
|
||||
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
||||
import { join, resolve, extname, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const REPO_ROOT = resolve(fileURLToPath(import.meta.url), '..', '..');
|
||||
const PORT = Number(process.env.BRAIN_DASHBOARD_PORT) || 7700;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.jsonl': 'application/x-ndjson; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
export function contentType(ext) {
|
||||
return MIME[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
export function listEpisodeFiles(root) {
|
||||
const dir = join(root, 'docs', 'observer');
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir)
|
||||
.filter((f) => /^episodes-\d{4}-\d{2}\.jsonl$/.test(f))
|
||||
.sort();
|
||||
}
|
||||
|
||||
// Resolve a URL path to an absolute path inside root; null if it escapes root.
|
||||
export function resolveStaticPath(urlPath, root) {
|
||||
const clean = decodeURIComponent(urlPath.split('?')[0]).replace(/^\/+/, '');
|
||||
// Use resolve for the traversal check (canonicalizes both sides consistently)
|
||||
const normRoot = resolve(root);
|
||||
const abs = resolve(normRoot, clean);
|
||||
if (abs !== normRoot && !abs.startsWith(normRoot + sep)) return null;
|
||||
// Return join-based path so callers get root-relative path with root's own separators
|
||||
return join(root, clean);
|
||||
}
|
||||
|
||||
export function createServer(root = REPO_ROOT) {
|
||||
return httpCreateServer((req, res) => {
|
||||
const url = req.url || '/';
|
||||
if (url.split('?')[0] === '/api/episodes') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(listEpisodeFiles(root)));
|
||||
return;
|
||||
}
|
||||
let path = url.split('?')[0];
|
||||
if (path === '/') {
|
||||
// Redirect (not rewrite) so the browser's base URL becomes /docs/observer/,
|
||||
// which makes relative <script src="dashboard.js"> and ../automation-graph-data.js resolve correctly.
|
||||
res.writeHead(302, { Location: '/docs/observer/dashboard.html' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const abs = resolveStaticPath(path, root);
|
||||
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('404');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': contentType(extname(abs)) });
|
||||
res.end(readFileSync(abs));
|
||||
});
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
createServer().listen(PORT, '127.0.0.1', () => {
|
||||
console.log(`Brain Dashboard: http://localhost:${PORT}/ (Ctrl+C to stop)`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { listEpisodeFiles, resolveStaticPath, contentType } from './brain-dashboard-server.mjs';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('listEpisodeFiles', () => {
|
||||
it('returns episodes-*.jsonl filenames sorted, ignores other files', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bd-'));
|
||||
const obs = join(root, 'docs', 'observer');
|
||||
mkdirSync(obs, { recursive: true });
|
||||
writeFileSync(join(obs, 'episodes-2026-05.jsonl'), '');
|
||||
writeFileSync(join(obs, 'episodes-2026-04.jsonl'), '');
|
||||
writeFileSync(join(obs, 'STATUS.md'), '');
|
||||
expect(listEpisodeFiles(root)).toEqual(['episodes-2026-04.jsonl', 'episodes-2026-05.jsonl']);
|
||||
});
|
||||
|
||||
it('returns [] when the observer dir is missing', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bd-'));
|
||||
expect(listEpisodeFiles(root)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStaticPath', () => {
|
||||
it('resolves a path inside root', () => {
|
||||
const root = '/srv/app';
|
||||
expect(resolveStaticPath('/docs/observer/dashboard.html', root))
|
||||
.toBe(join(root, 'docs', 'observer', 'dashboard.html'));
|
||||
});
|
||||
|
||||
it('rejects path traversal with null', () => {
|
||||
expect(resolveStaticPath('/../../etc/passwd', '/srv/app')).toBeNull();
|
||||
expect(resolveStaticPath('/docs/../../secret', '/srv/app')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contentType', () => {
|
||||
it('maps known extensions', () => {
|
||||
expect(contentType('.html')).toBe('text/html; charset=utf-8');
|
||||
expect(contentType('.js')).toBe('text/javascript; charset=utf-8');
|
||||
expect(contentType('.jsonl')).toBe('application/x-ndjson; charset=utf-8');
|
||||
});
|
||||
it('falls back to octet-stream', () => {
|
||||
expect(contentType('.xyz')).toBe('application/octet-stream');
|
||||
});
|
||||
});
|
||||
@@ -26,14 +26,31 @@ export function dedupeEpisodes(episodes) {
|
||||
return [...byKey.values(), ...errors];
|
||||
}
|
||||
|
||||
/** Infer the true outcome of an episode from the next episode's opening prompt. */
|
||||
/** Infer the true outcome of an episode from its events + the next episode's prompt. */
|
||||
export function inferOutcome(episode, nextEpisode) {
|
||||
if (episode && Array.isArray(episode.events) && episode.events.some((e) => e.kind === 'interrupt')) {
|
||||
const events = episode && Array.isArray(episode.events) ? episode.events : [];
|
||||
if (events.some((e) => e.kind === 'interrupt')) {
|
||||
return 'partial';
|
||||
}
|
||||
// A turn is `blocked` only when it ENDED on an unrecovered tool failure —
|
||||
// emitted by the parser as a single `unrecovered_error` event when the
|
||||
// LAST tool_result of the turn was is_error=true. Raw error/retry counts
|
||||
// do NOT imply blocked: a TDD red→green cycle or a grep that returns
|
||||
// nothing both surface as `error` events but are intentional and
|
||||
// recovered — counting them as blocked over-reports failures (A-1 fix).
|
||||
if (events.some((e) => e.kind === 'unrecovered_error')) {
|
||||
return 'blocked';
|
||||
}
|
||||
// 'failure' (work wrong AND never corrected) is a judgment, not
|
||||
// deterministically recoverable from a transcript — deferred to the phase-2
|
||||
// agent-judge. Until then a wrong-then-corrected turn surfaces as 'rework'.
|
||||
if (!nextEpisode) return 'unknown';
|
||||
if (nextEpisode.prompt_signal === 'correction') return 'rework';
|
||||
if (nextEpisode.prompt_signal === 'approval' || nextEpisode.prompt_signal === 'new_task') return 'success';
|
||||
// Task 16: neutral next-prompt = silent success. Если operator продолжил
|
||||
// следующей instruction без correction-маркеров — это «no objection».
|
||||
// Slightly weaker signal than explicit approval — labelled `soft_success`.
|
||||
if (nextEpisode.prompt_signal === 'neutral') return 'soft_success';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
@@ -72,6 +89,23 @@ export function groupEpisodesToTasks(episodes) {
|
||||
return tasks;
|
||||
}
|
||||
|
||||
// Hot/normative files — touched by almost every turn (memory store, CLAUDE.md,
|
||||
// STATUS.md, episodes JSONL). Sharing one of these is not evidence of a causal
|
||||
// chain; it just means both turns brushed the same hot file. Excluded from the
|
||||
// shared-file signal (A-3 fix).
|
||||
const HOT_FILE_PATTERNS = [
|
||||
/(?:^|[\\/])CLAUDE\.md$/i,
|
||||
/(?:^|[\\/])MEMORY\.md$/i,
|
||||
/(?:^|[\\/])STATUS\.md$/i,
|
||||
/[\\/]episodes-\d{4}-\d{2}\.jsonl$/i,
|
||||
/[\\/]memory[\\/][^\\/]+\.md$/i,
|
||||
];
|
||||
|
||||
export function isHotFile(path) {
|
||||
const s = String(path || '');
|
||||
return HOT_FILE_PATTERNS.some((re) => re.test(s));
|
||||
}
|
||||
|
||||
/** Causal-chain candidates: an errored episode → a later episode sharing a file. */
|
||||
export function findCausalChains(episodes) {
|
||||
const sorted = episodes
|
||||
@@ -85,11 +119,13 @@ export function findCausalChains(episodes) {
|
||||
const a = sorted[i];
|
||||
const hasError = Array.isArray(a.events) && a.events.some((e) => e.kind === 'error');
|
||||
if (!hasError) continue;
|
||||
const filesA = new Set(((a.task_size || {}).files) || []);
|
||||
const filesA = new Set(
|
||||
(((a.task_size || {}).files) || []).filter((f) => !isHotFile(f))
|
||||
);
|
||||
if (filesA.size === 0) continue;
|
||||
for (let j = i + 1; j < sorted.length; j++) {
|
||||
const b = sorted[j];
|
||||
const shared = (((b.task_size || {}).files) || []).filter((f) => filesA.has(f));
|
||||
const shared = (((b.task_size || {}).files) || []).filter((f) => !isHotFile(f) && filesA.has(f));
|
||||
if (shared.length > 0) {
|
||||
chains.push({
|
||||
from: `${a.task_id}|${(a.timestamps || {}).started_at}`,
|
||||
@@ -108,11 +144,22 @@ function sizeBucket(toolCalls) {
|
||||
return n < SIZE_SMALL ? 'small' : n <= SIZE_LARGE ? 'medium' : 'large';
|
||||
}
|
||||
|
||||
const SESSION_TURN_EARLY = 10;
|
||||
const SESSION_TURN_LATE = 40;
|
||||
|
||||
function sessionTurnBucket(turn) {
|
||||
const n = Number(turn);
|
||||
if (!Number.isFinite(n)) return 'null';
|
||||
return n < SESSION_TURN_EARLY ? 'early' : n <= SESSION_TURN_LATE ? 'mid' : 'late';
|
||||
}
|
||||
|
||||
const FACTOR_FNS = {
|
||||
decision_provenance: (e) => (e.decision_provenance || {}).kind || 'unknown',
|
||||
economy_level: (e) => String((e.environment || {}).economy_level ?? 'null'),
|
||||
model: (e) => (e.environment || {}).model || 'null',
|
||||
post_compaction: (e) => String((e.environment || {}).post_compaction ?? false),
|
||||
session_segment_turn: (e) => sessionTurnBucket((e.environment || {}).session_turn),
|
||||
parallel_session: (e) => String((e.environment || {}).parallel_session ?? false),
|
||||
task_size: (e) => sizeBucket((e.task_size || {}).tool_calls),
|
||||
node_chosen: (e) => (e.primary_rationale || {}).node_chosen || 'direct',
|
||||
task_classification: (e) => (e.primary_rationale || {}).task_classification || 'other',
|
||||
@@ -136,7 +183,11 @@ export function buildFactorMatrix(episodesWithOutcome) {
|
||||
/** Full deterministic aggregation: dedup → infer outcomes → group → chains → matrix. */
|
||||
export function analyze(episodes) {
|
||||
const deduped = dedupeEpisodes(episodes);
|
||||
const normal = deduped.filter((e) => !e.observer_error);
|
||||
const allNormal = deduped.filter((e) => !e.observer_error);
|
||||
// v1 episodes lack environment / prompt_signal / decision_provenance — they
|
||||
// pollute the factor matrix and break outcome inference. Analyze v2 only.
|
||||
const normal = allNormal.filter((e) => e.schema_version === 2);
|
||||
const v1SkippedCount = allNormal.length - normal.length;
|
||||
for (const eps of bySessionSorted(normal).values()) {
|
||||
eps.forEach((episode, i) => {
|
||||
episode._inferredOutcome = inferOutcome(episode, eps[i + 1]);
|
||||
@@ -144,7 +195,8 @@ export function analyze(episodes) {
|
||||
}
|
||||
return {
|
||||
episodeCount: normal.length,
|
||||
observerErrorCount: deduped.length - normal.length,
|
||||
v1SkippedCount,
|
||||
observerErrorCount: deduped.length - allNormal.length,
|
||||
tasks: groupEpisodesToTasks(normal),
|
||||
causalChains: findCausalChains(normal),
|
||||
factorMatrix: buildFactorMatrix(normal),
|
||||
|
||||
@@ -53,6 +53,22 @@ describe('inferOutcome', () => {
|
||||
it('infers unknown when there is no next episode', () => {
|
||||
expect(inferOutcome(ep(), null)).toBe('unknown');
|
||||
});
|
||||
it('infers blocked ONLY when an unrecovered_error event is present (turn ended on error)', () => {
|
||||
const blocked = ep({ events: [{ kind: 'error' }, { kind: 'error' }, { kind: 'unrecovered_error' }] });
|
||||
expect(inferOutcome(blocked, ep({ prompt_signal: 'approval' }))).toBe('blocked');
|
||||
});
|
||||
it('does NOT infer blocked from raw error/retry count (TDD failing-test-first is not a block)', () => {
|
||||
// A turn with N errors + N retries that ends on a successful tool_result —
|
||||
// e.g., TDD red→green, or git command that legitimately fails then recovers —
|
||||
// must NOT count as blocked. The parser emits unrecovered_error iff the LAST
|
||||
// tool_result was is_error, which is absent here.
|
||||
const recovered = ep({ events: [{ kind: 'error' }, { kind: 'error' }, { kind: 'retry' }] });
|
||||
expect(inferOutcome(recovered, ep({ prompt_signal: 'approval' }))).toBe('success');
|
||||
});
|
||||
it('does not infer blocked when every error was retried', () => {
|
||||
const recovered = ep({ events: [{ kind: 'error' }, { kind: 'retry' }] });
|
||||
expect(inferOutcome(recovered, ep({ prompt_signal: 'approval' }))).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupEpisodesToTasks', () => {
|
||||
@@ -81,6 +97,62 @@ describe('findCausalChains', () => {
|
||||
const b = ep({ timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' }, task_size: { tool_calls: 1, files_touched: 1, files: ['/b.js'] } });
|
||||
expect(findCausalChains([a, b])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes hot/normative files (CLAUDE.md) from the shared-file signal', () => {
|
||||
const a = ep({
|
||||
events: [{ kind: 'error', message: 'x' }],
|
||||
task_size: { tool_calls: 1, files_touched: 1, files: ['c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md'] },
|
||||
});
|
||||
const b = ep({
|
||||
timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' },
|
||||
task_size: { tool_calls: 1, files_touched: 1, files: ['c:\\моя\\проекты\\портал crm\\Документация\\CLAUDE.md'] },
|
||||
});
|
||||
expect(findCausalChains([a, b])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes memory store .md files from the shared-file signal', () => {
|
||||
const a = ep({
|
||||
events: [{ kind: 'error', message: 'x' }],
|
||||
task_size: { tool_calls: 1, files_touched: 1, files: ['C:\\Users\\Administrator\\.claude\\projects\\proj\\memory\\reference_github.md'] },
|
||||
});
|
||||
const b = ep({
|
||||
timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' },
|
||||
task_size: { tool_calls: 1, files_touched: 1, files: ['C:\\Users\\Administrator\\.claude\\projects\\proj\\memory\\reference_github.md'] },
|
||||
});
|
||||
expect(findCausalChains([a, b])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes episodes JSONL + STATUS.md + MEMORY.md from chains', () => {
|
||||
const mk = (path, evts = []) =>
|
||||
ep({
|
||||
timestamps: { started_at: '2026-05-19T10:00:00Z', ended_at: '2026-05-19T10:01:00Z' },
|
||||
events: evts,
|
||||
task_size: { tool_calls: 1, files_touched: 1, files: [path] },
|
||||
});
|
||||
const later = (path) =>
|
||||
ep({
|
||||
timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' },
|
||||
task_size: { tool_calls: 1, files_touched: 1, files: [path] },
|
||||
});
|
||||
const errored = [{ kind: 'error', message: 'x' }];
|
||||
expect(findCausalChains([mk('/docs/observer/episodes-2026-05.jsonl', errored), later('/docs/observer/episodes-2026-05.jsonl')])).toHaveLength(0);
|
||||
expect(findCausalChains([mk('/docs/observer/STATUS.md', errored), later('/docs/observer/STATUS.md')])).toHaveLength(0);
|
||||
expect(findCausalChains([mk('/some/dir/MEMORY.md', errored), later('/some/dir/MEMORY.md')])).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('still links chains via genuinely-shared source files', () => {
|
||||
const a = ep({
|
||||
events: [{ kind: 'error', message: 'x' }],
|
||||
task_size: { tool_calls: 1, files_touched: 2, files: ['c:\\path\\CLAUDE.md', '/src/app.ts'] },
|
||||
});
|
||||
const b = ep({
|
||||
timestamps: { started_at: '2026-05-19T10:02:00Z', ended_at: '2026-05-19T10:03:00Z' },
|
||||
task_size: { tool_calls: 1, files_touched: 2, files: ['c:\\path\\CLAUDE.md', '/src/app.ts'] },
|
||||
});
|
||||
const chains = findCausalChains([a, b]);
|
||||
expect(chains).toHaveLength(1);
|
||||
expect(chains[0].sharedFiles).toEqual(['/src/app.ts']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFactorMatrix', () => {
|
||||
@@ -108,6 +180,18 @@ describe('buildFactorMatrix', () => {
|
||||
expect(m.decision_provenance.user_chose_from_options.success).toBe(1);
|
||||
expect(m.decision_provenance.user_chose_from_options.rework).toBe(1);
|
||||
});
|
||||
|
||||
it('includes session_segment_turn (bucketed, turns-since-last-compaction) and parallel_session factors', () => {
|
||||
const eps = [
|
||||
{ ...ep(), _inferredOutcome: 'success', environment: { session_turn: 3, parallel_session: false } },
|
||||
{ ...ep(), _inferredOutcome: 'rework', environment: { session_turn: 120, parallel_session: true } },
|
||||
];
|
||||
const m = buildFactorMatrix(eps);
|
||||
expect(m.session_segment_turn.early.success).toBe(1);
|
||||
expect(m.session_segment_turn.late.rework).toBe(1);
|
||||
expect(m.parallel_session.false.success).toBe(1);
|
||||
expect(m.parallel_session.true.rework).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyze', () => {
|
||||
@@ -118,4 +202,47 @@ describe('analyze', () => {
|
||||
expect(Array.isArray(result.tasks)).toBe(true);
|
||||
expect(Array.isArray(result.causalChains)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips v1 episodes (no schema_version 2) from the analysis', () => {
|
||||
const v1 = { task_id: 's-old', timestamps: { started_at: '2026-05-19T09:00:00Z' }, outcome: 'success' };
|
||||
const result = analyze([
|
||||
v1,
|
||||
ep(),
|
||||
ep({ timestamps: { started_at: '2026-05-19T11:00:00Z', ended_at: '2026-05-19T11:01:00Z' } }),
|
||||
]);
|
||||
expect(result.episodeCount).toBe(2);
|
||||
expect(result.v1SkippedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFactorMatrix — session_segment_turn axis rename (Task 14)', () => {
|
||||
it('matrix has session_segment_turn axis, NOT legacy session_turn', () => {
|
||||
const result = analyze([
|
||||
{ schema_version: 2, task_id: 's', task_ref: 's',
|
||||
timestamps: { started_at: '2026-05-20T00:00:00Z' }, events: [],
|
||||
environment: { economy_level: null, model: 'opus', post_compaction: false, session_turn: 5, parallel_session: false },
|
||||
task_size: { tool_calls: 0 },
|
||||
primary_rationale: { node_chosen: 'direct', task_classification: 'other' },
|
||||
decision_provenance: { kind: 'autonomous' } },
|
||||
]);
|
||||
expect(result.factorMatrix).toHaveProperty('session_segment_turn');
|
||||
expect(result.factorMatrix).not.toHaveProperty('session_turn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('inferOutcome — neutral → soft_success (Task 16)', () => {
|
||||
it('returns soft_success when next prompt is neutral', () => {
|
||||
const a = { events: [] };
|
||||
const b = { prompt_signal: 'neutral' };
|
||||
expect(inferOutcome(a, b)).toBe('soft_success');
|
||||
});
|
||||
it('returns unknown when no next episode', () => {
|
||||
expect(inferOutcome({ events: [] }, null)).toBe('unknown');
|
||||
});
|
||||
it('rework still wins over neutral on correction', () => {
|
||||
expect(inferOutcome({ events: [] }, { prompt_signal: 'correction' })).toBe('rework');
|
||||
});
|
||||
it('explicit success still wins over neutral on approval', () => {
|
||||
expect(inferOutcome({ events: [] }, { prompt_signal: 'approval' })).toBe('success');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Glob latency investigator — ad-hoc one-off script (Task 17).
|
||||
*
|
||||
* Brain-retro 2026-05-20 §17 noted Glob p50 = 12.7s anomaly. This script
|
||||
* parses a Claude Code session transcript (JSONL), extracts all Glob
|
||||
* tool_use → tool_result round-trips, sorts by latency desc, prints top-N
|
||||
* with the pattern (and optional path) that caused each.
|
||||
*
|
||||
* Output is intentionally compact (top-10 by default). Run on demand
|
||||
* against any session transcript when investigating Glob slowness:
|
||||
*
|
||||
* node tools/glob-latency-investigator.mjs <path/to/transcript.jsonl> [topN]
|
||||
*
|
||||
* NOT part of production code path — never imported by parser/hook/analyzer.
|
||||
* Lives in tools/ for discoverability alongside the rest of the observer
|
||||
* infrastructure.
|
||||
*
|
||||
* Security Guidance #40: pure fs reads, no exec/execSync.
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
|
||||
const path = process.argv[2];
|
||||
const topN = Number.parseInt(process.argv[3] || '10', 10);
|
||||
|
||||
if (!path) {
|
||||
console.error('Usage: node tools/glob-latency-investigator.mjs <transcript.jsonl> [topN=10]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(path)) {
|
||||
console.error(`File not found: ${path}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
|
||||
|
||||
/** Map tool_use.id → { pattern, path, ts } for Glob calls. */
|
||||
const starts = new Map();
|
||||
/** Collected round-trip latencies. */
|
||||
const events = [];
|
||||
|
||||
for (const line of lines) {
|
||||
let o;
|
||||
try { o = JSON.parse(line); } catch { continue; }
|
||||
const ts = o.timestamp ? new Date(o.timestamp).getTime() : null;
|
||||
if (!ts) continue;
|
||||
|
||||
if (o.type === 'assistant' && o.message && Array.isArray(o.message.content)) {
|
||||
for (const c of o.message.content) {
|
||||
if (c && c.type === 'tool_use' && c.name === 'Glob') {
|
||||
starts.set(c.id, {
|
||||
pattern: (c.input && c.input.pattern) || '(no pattern)',
|
||||
path: (c.input && c.input.path) || null,
|
||||
ts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (o.type === 'user' && o.message && Array.isArray(o.message.content)) {
|
||||
for (const c of o.message.content) {
|
||||
if (c && c.type === 'tool_result' && starts.has(c.tool_use_id)) {
|
||||
const s = starts.get(c.tool_use_id);
|
||||
events.push({ pattern: s.pattern, path: s.path, latency: ts - s.ts });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => b.latency - a.latency);
|
||||
|
||||
const total = events.length;
|
||||
const median = total > 0 ? events[Math.floor(total / 2)].latency : 0;
|
||||
const p95 = total > 0 ? events[Math.floor(total * 0.05)].latency : 0;
|
||||
|
||||
console.log(`Glob round-trips: ${total} | median ${median}ms | p95 ${p95}ms`);
|
||||
console.log(`\nTop ${Math.min(topN, total)} by latency:`);
|
||||
console.log('latency_ms'.padStart(11), ' pattern (path)');
|
||||
console.log('-----------', ' --------------');
|
||||
for (const e of events.slice(0, topN)) {
|
||||
const where = e.path ? ` (${e.path})` : '';
|
||||
console.log(String(e.latency).padStart(11), ` ${e.pattern}${where}`);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user