Compare commits

...

254 Commits

Author SHA1 Message Date
Дмитрий 67861270d6 docs(spec): реколлаж ruflo в иерархии — декларация vs фактический рантайм
Дизайн-спека приведения нормативки к рантайму: убрать уровень -1 «ruflo entry-point для ВСЕХ задач» (рантайм — 0 задач, рой idle, 0 enforcement); §12 Superpowers и PSR_v1 R0 → обратно hard-rule/top-gate; §14 queen-триггер сохраняется без изменений; ruflo переописывается advisory/automation-подсистемой. Утверждена заказчиком 16.05.2026.

cspell-words.txt: +реколлажирована/реколлажем/фоллбэк.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:41:02 +03:00
Дмитрий 65381f2b24 docs(audit): Sprint 3A — B1 помечен won't-do (конфликт с решением заказчика)
B4 + B5 реализованы и закрыты; B1 «Напоминания в сайдбар» откатан как
конфликтующий с прежним решением заказчика «sidebar cleanup» (5c8ad27).
Отмечено в §3 расписании, §4 таблице B и §8 approval log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:16:03 +03:00
Дмитрий 4a851a2d40 docs(admin): AdminLayout JSDoc — 4→7 nav-пунктов (Sprint 3A final review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:15:11 +03:00
Дмитрий ad9fb9dfde docs(economy): спека 5% — блок A/B3 (скоростные правила) + §5.2 актуализация
Дописана §11: 6 скоростных правил (блок A 5 пунктов + блок B п.3) внесены секцией СКОРОСТЬ в LEVELS[5] хуков; B4 (замер latency хуков) задокументирован как закрытый одноразовый bench. §5.1/§5.2 актуализированы под текущие хуки, §2 формула расширена, статус-шапка → Реализован.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:14:29 +03:00
Дмитрий eebcaf1912 docs+test(admin): ImpersonationBanner — убрать stale JSDoc + тест poll→render (B5 review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:09:29 +03:00
Дмитрий e0a3fb8d28 revert(nav): откат B1 «Напоминания» в сайдбаре — конфликт с решением заказчика
Откат a55ac9d. Audit B1 предлагал вернуть «Напоминания» в сайдбар, но
пункт был намеренно убран по требованию заказчика (commit 5c8ad27
«sidebar cleanup»; тест AppLayout.spec.ts фиксирует «Напоминания убраны
по требованию заказчика»). Решение заказчика 2026-05-16: B1 → won't-do,
пункт остаётся убранным. Восстанавливает зелёный AppLayout.spec.ts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:04:13 +03:00
Дмитрий 346c4843b0 feat(admin): ImpersonationBanner — глобальный индикатор активных сессий (audit B5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:59:43 +03:00
Дмитрий 0fa1a7394b fix(tests): redis cache store -> array driver in test env (kill quirk 72)
SupplierPortalClient::loadSession, RefreshSupplierSessionJob, CsvReconcileJob and RouteSupplierLeadJob hardcode Cache::store('redis'), bypassing phpunit.xml's CACHE_STORE=array. Under pest --parallel every worker shares the same Memurai instance and the global supplier:session key, so one worker's afterEach forget()/flush() races another worker's mid-test loadSession() -- deterministic 1-2 failures in the tests/Feature/Supplier/ subdir-only run (quirk 72).

TestCase::setUp() repoints the redis cache store at the in-process array driver: each parallel worker gets a hermetic, worker-local cache. Production keeps the real redis driver -- the override only runs under APP_ENV=testing. New RedisCacheStoreIsolationTest guards the invariant.

Verified: tests/Feature/Supplier/ --parallel 6/6 runs 43/43 (was 42/43 +1 error); tests/Unit/Supplier/ 3/3 runs 38/38; full pest --parallel 794/791/3sk/0; Pint + Larastan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:57:32 +03:00
Дмитрий 6e1d437f21 docs(test): AdminLayout.spec — header-комментарий 5→7 nav-items (B4 review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:48:09 +03:00
Дмитрий 9b1ac10309 feat(admin): AdminLayout nav — Тарифная сетка + Цены поставщиков (audit B4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:43:53 +03:00
Дмитрий ffcb9b2f8e feat(graph): iter6 — «Паспорт узла» (даты + использование) в легенде
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:41:04 +03:00
Дмитрий a55ac9dee4 feat(nav): AppSidebar — пункт «Напоминания» в группе «Работа» (audit B1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:40:00 +03:00
Дмитрий 93bfda42c9 docs(plan): Sprint 3A layout & navigation implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:38:01 +03:00
Дмитрий 658f4be133 feat(graph): iter6 SECTION 3.6 — NODE_META + DUPLICATE_GROUPS data
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:30:49 +03:00
Дмитрий d55890bec2 fix(regression): parsePest handles JSON output from pest --parallel
pest --parallel emits a single JSON line {"tool":"pest","tests":N,"passed":N,"skipped":N,...}
instead of human-readable text; the old regex-only parser returned 0/0/0sk/0 for every
parallel run. Added JSON-first branch with regex fallback; 3 new unit tests cover the
JSON path (passed+skipped, with failures, no skipped field).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 09:20:47 +03:00
Дмитрий d9ce953e53 docs(economy): спецификация и план уровня «экономия 5%»
Уровень «экономия 5%» = «0% без избыточности»: то же качество, что 0%,
вырезаны 6 пунктов дублирующей/бесполезной работы (re-read CLAUDE.md,
тесты-после-каждой-правки, gitleaks-full-history per-commit, Stop-верификатор,
авто-гейты brainstorming/plan-mode -> §12.2-floor). Уровень 0% не меняется.

cspell-words.txt: +коммитятся (валидная форма семейства коммит*).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:15:42 +03:00
Дмитрий 0465b91cac docs(regression): SKILL.md — list RED-INCOMPLETE verdict + exit codes (doc review)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:04:48 +03:00
Дмитрий 1405e00f4c feat(regression): SKILL.md — skill doc + invocation rules
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:51:56 +03:00
Дмитрий bd27047aad docs(rls): document rls-check skill <-> rls-reviewer agent boundary
Both tools check RLS compliance; the boundary "когда какой" was
undocumented (tracked as a RED conflict on the automation graph).

- .claude/skills/rls-check/SKILL.md: +section "Граница с агентом
  rls-reviewer", +bullet in "Не использовать когда", +clause in
  the frontmatter description.
- .claude/agents/rls-reviewer.md: +mirrored section "Граница со
  скилом /rls-check", +bullet in "Out of scope", +clause in
  the description.
- docs/automation-graph.html: conflict sk_rls<->ag_rls recolored
  RED->GREEN (CONFLICT edge + both nd() node entries + EDGE_META).
- cspell-words.txt: +1 pre-existing word surfaced by the cspell
  full-file scan of the now-staged SKILL.md.

Rule: one named table -> /rls-check; diff/branch/PR -> rls-reviewer.
The smoke test stays skill-only by design (running Pest in a review
subagent is slow and hits --parallel quirks 72/77).

Spec:  docs/superpowers/specs/2026-05-16-rls-tooling-boundary-design.md
Plan:  docs/superpowers/plans/2026-05-16-rls-tooling-boundary.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:48:30 +03:00
Дмитрий db8aa06f52 fix(regression): detect Windows cmd.exe "is not recognized" as missing binary
A missing cmd-based tool on Windows exits 1 with an "is not recognized"
message, not POSIX exit 127. runCheck now also matches that message so a
missing composer/npm is classified SKIPPED (verdict RED-INCOMPLETE) per
spec §8, instead of a plain failure. Code-review follow-up for Task 7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:48:25 +03:00
Дмитрий 9fd1d7cdf5 feat(regression): runCheck I/O layer + main orchestrator
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:41:22 +03:00
Дмитрий ee4969dffa feat(regression): 12-check registry (quick=6, full=12)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:31:55 +03:00
Дмитрий c9672e81e6 fix(billing): TopupDialog NaN-guard + state reset on open (Task 5 review)
Code-quality review fixups: Number.isFinite-guard в amountError/canSubmit
(очищенное number-поле не должно включать кнопку); watch(model) сбрасывает
amount/errorMsg при открытии (паттерн ReminderDialog, нет префилла/race);
комментарий про намеренный refetch в onTopupSuccess; flushPromises в spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:29:05 +03:00
Дмитрий e81cb5a1e5 feat(regression): canonical line / row / verdict formatters
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:26:45 +03:00
Дмитрий c2cb3af4c6 feat(billing): TopupDialog + Пополнить wiring (E1)
TopupDialog (сумма + пресеты + min 100 ₽ валидация) → POST
/api/billing/topup. Кнопки «Пополнить баланс» (шапка) и «Пополнить»
(BalanceCard) открывают диалог; при успехе — refresh кошелька +
транзакций + snackbar.

Sprint 2 Plan C, audit E1 (frontend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:16:08 +03:00
Дмитрий 4a7c7cdddf feat(regression): GREEN/RED/RED-INCOMPLETE verdict logic
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:14:40 +03:00
Дмитрий 47f83dac12 docs(plan): RLS tooling boundary implementation plan
8-task plan for the rls-check skill <-> rls-reviewer agent boundary:
mirrored "Граница..." sections in both tool files, conflict recolor
RED->GREEN on the automation graph (4 spots), lint sweep, Playwright
visual smoke, one atomic commit, memory sync.

Spec: docs/superpowers/specs/2026-05-16-rls-tooling-boundary-design.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:13:16 +03:00
Дмитрий 5bc6a029f2 docs(plan): automation-graph iter6 — node meta + duplicates implementation plan
4-task plan for iter6 of docs/automation-graph.html: «Паспорт узла»
legend section (since/changed/uses) for all 83 nodes + 2 footer toggle
buttons (usage heatmap, duplicate highlight). NODE_META (83 records) and
DUPLICATE_GROUPS (6 pairs D1-D5/D7) carry factual values derived from
76 session transcripts (window 09-16.05.2026) + git history; method and
raw outputs in Appendix A. cspell-words.txt += pcreator, pvalid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:11:57 +03:00
Дмитрий 0ef093f7c5 fix(billing): InvoicesTable has_pdf disabled test + formatter doc (Task 4 review)
Code-quality review fixups: тест на :disabled PDF-кнопки по has_pdf
(spec-mandated поведение без покрытия); doc-комментарий billingFormatters
дополнен InvoicesTable в списке потребителей.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:07:57 +03:00
Дмитрий ac2c794542 feat(billing): TransactionsTable + InvoicesTable real API (E3)
TransactionsTable — server-driven история транзакций (GET
/api/billing/transactions, табы → фильтр type). InvoicesTable —
GET /api/billing/invoices с empty-state (real-but-empty до Б-1).
billingFormatters почищен (drop status/format-функций), mockBilling
ужат до pending-баннера (E4).

Sprint 2 Plan C, audit E3 (frontend pt2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:56:22 +03:00
Дмитрий f924e4413c feat(regression): Vite build / Larastan / gitleaks / lychee parsers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:55:35 +03:00
Дмитрий 21debac6c4 docs(spec): RLS tooling boundary — граница rls-check скил ↔ rls-reviewer агент
Дизайн-спек устранения конфликта 🔴 RED #1 карты автоматизации:
скил /rls-check и агент rls-reviewer оба проверяют RLS без чёткой
границы «когда какой».

Решение (Подход 1 — асимметрия как граница): оставить оба, прописать
регламент. Скил — одна названная таблица + живой дымовой тест;
агент — diff/ветка/PR, только 7 статических проверок. Дымовой тест
намеренно вне агента (Pest в ревью-субагенте медленный + задевает
квирки 72/77).

Затрагивает только проектно-локальные файлы инструментов + карту —
0 правок нормативки (Pravila/CLAUDE.md/PSR_v1/Tooling).

cspell-words.txt: +скиле +скилом (падежные формы термина «скил»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:51:57 +03:00
Дмитрий b2f12cbe06 feat(regression): Pest + Vitest count parsers
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:45:32 +03:00
Дмитрий 2723261033 fix(billing): clear stale wallet on retry + retry-button test (Task 3 review)
Code-quality review fixups: loadWallet() catch-блок сбрасывает wallet в
null (нет ложного рендера устаревших данных при неудачном повторе);
тест на кнопку «Повторить» (re-fetch + переход в success-состояние).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:44:19 +03:00
Дмитрий fe9ac213b7 feat(regression): skill scaffold + resolveBinary/buildHeader/parseExit
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:40:21 +03:00
Дмитрий 112cdc82cd docs(plan): /regression skill — implementation plan (writing-plans)
9-task TDD plan implementing docs/superpowers/specs/2026-05-16-regression-skill-design.md: run.mjs split into exported pure functions (resolveBinary, parsers, computeVerdict, formatters, CHECKS registry) + main orchestrator; co-located run.test.mjs (node:test — 36 unit tests + unknown-arg subprocess test, ruflo-queen-hook.test.mjs pattern); SKILL.md; functional verification per spec §10.

Next: subagent-driven-development or executing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:33:56 +03:00
Дмитрий cc624543e9 feat(billing): BillingView wallet + BalanceCard real API (E3)
api/billing.ts (getWallet) + BillingView тянет GET /api/billing/wallet
на mount (шапка + BalanceCard, loading/error-state). BalanceCard на
реальные props с nullable-тарифом. featureLabel для feature-слагов.

Sprint 2 Plan C, audit E3 (frontend pt1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:32:59 +03:00
Дмитрий 00937b7765 docs(spec): automation-graph iter6 — dates + usage + duplicates design
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:32:35 +03:00
Дмитрий e822925ded docs(spec): /regression — amend §10, add run.test.mjs (writing-plans)
Spec §10 claimed run.mjs needs no unit harness, on the false premise that tools/*.mjs have no tests. In fact all 3 tools/*.mjs have a co-located .test.mjs (node:test). Amended §2/§3/§4/§10 + header note: run.mjs is split into exported pure functions (parsers, verdict, canonical-line, platform fork) + orchestrator, with a co-located run.test.mjs (node:test, ruflo-queen-hook.test.mjs pattern) — pure functions unit-tested, main subprocess-tested.

Aligns the spec with the economy-0% TDD mandate and the project tools/*.mjs convention before writing the implementation plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:25:10 +03:00
Дмитрий 65c5178c29 fix(billing): runwayDays clamps negative balance to 0 + type-filter test (Task 2 review)
Code-quality review fixups: runway_days клампится в 0 при отрицательном
балансе (overdrawn-тенант не должен показывать «−N дней»); (int)-каст в
wallet() для консистентности; усилены assertJsonPath на type-фильтре.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:24:34 +03:00
Дмитрий 5e6b1b651a docs(spec): /regression skill design — canonical regression sweep
Brainstorming-phase design for custom skill #1 from claude-automation-recommender: a /regression skill packaging the project regression sweep (Pest --parallel, Vitest, Larastan, vue-tsc, lint/format, lychee, gitleaks) into one invocation — two tiers (quick/full), bundled .mjs orchestrator, canonical status line, GREEN/RED exit-code verdict.

Q1-Q4 design forks approved via brainstorming; spec self-review passed. cspell-words.txt: +6 project glossary transliterations introduced by the spec. Next: superpowers:writing-plans for the implementation plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:15:19 +03:00
Дмитрий 040d25423d feat(billing): wallet/transactions/invoices read API (E3)
GET /api/billing/wallet (баланс + тариф + runway), /transactions
(пагинированный balance_transactions с фильтром type), /invoices
(saas_invoices, real-but-empty до Б-1). TariffPlan модель +
Tenant::tariff() relation + BalanceTransactionFactory.

Sprint 2 Plan C, audit E3 (backend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:08:09 +03:00
Дмитрий 7bee35768d fix(billing): topup save() rationale comment + cross-tenant test (Task 1 review)
Code-quality review fixups: документирующий комментарий про безопасность
Eloquent save() для bcmath-строки (расхождение с LedgerService raw-update);
cross-tenant isolation тест на /api/billing/topup; balance_rub_after в
assertDatabaseHas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 07:00:19 +03:00
Дмитрий c4370f6a2c refactor(graph): ruflo cluster factual recollage — 9 nodes / 16 edges (iter5)
iter4 нарисовал блок ruflo как Queen-led рой из 9 специализированных
ролей — декларация, не рантайм. iter5 приводит блок к фактической
инспекции рантайма 15.05.2026.

- -7 фиктивных ролей (Architect/Coder/Security/RLS/QA/Tester/Reviewer)
- +5 фактических узлов (10 воркеров idle, recall-хук, каталог агентов
  100 определений, slash-команды 88, плагины 0 из 20)
- рёбра 22 -> 16: убраны 3 фиктивных делегирующих ребра
- конфликт daemon<->mem_state перенацелен на memory<->mem_state
- двустороннее отображение конфликтов: правки pravila/mem_state/ag_pest
- метрики: 85->83 узла, 96->90 рёбер, 11 конфликтов без изменений

Spec: docs/superpowers/specs/2026-05-15-automation-graph-iter5-ruflo-factual-design.md (efd588f)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:48:46 +03:00
Дмитрий 44dc1025ec feat(billing): topup ledger service + POST /api/billing/topup stub (E1)
BillingTopupService кредитует tenants.balance_rub (bcmath) и пишет
append-only строку balance_transactions(type='topup'). BillingController
+ route POST /api/billing/topup под [auth:sanctum, tenant]. MVP-stub:
без платёжного шлюза (ЮKassa — post-Б-1).

Sprint 2 Plan C, audit E1 (backend).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:46:52 +03:00
Дмитрий c46d6264f3 docs(plan): Sprint 2 Plan C — Billing E1/E3 (writing-plans)
5-task план реализации audit-эпиков E1 (TopupDialog + POST
/api/billing/topup stub) и E3 (BillingView Overview на real API:
wallet/transactions/invoices). Backend: BillingController +
BillingTopupService + TariffPlan. Frontend: api/billing.ts + 4
компонента биллинга с mock на real API.

Sprint 2 Plan C. Источник: docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:40:11 +03:00
Дмитрий 6819238508 docs(plan): automation-graph iter5 — ruflo factual recollage plan
План реализации iter5 поверх spec efd588f: 2 задачи (реколлаж кластера
ruflo в automation-graph.html одним атомарным коммитом + синхронизация
memory). Полное литеральное содержание узлов/рёбер/деталей, верификация
через grep + visual smoke. +2 слова в cspell-words.txt (арг, греп).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 06:28:58 +03:00
Дмитрий c693d03a75 test(settings): ApiTab — load error-path coverage + idiomatic disabled check (review M2/M3)
Code-quality review of Task 5: adds tests for the loadApiKey/loadWebhook
catch branches (apiKeyError/webhookError -> error v-alert) and changes
the Copy-button disabled check to the idiomatic falsy form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:35:49 +03:00
Дмитрий 298a7fa9de feat(settings): ApiTab wired to api-key + webhook endpoints (closes D2-D5)
Audit D2/D3/D4/D5: all four ApiTab buttons were handler-less and the
fields were hardcoded. Adds api/apiKeys.ts + api/webhooks.ts modules and
rewires ApiTab: loads the api-key prefix + webhook settings on mount;
Copy -> clipboard + snackbar; Regenerate -> confirm dialog -> POST
regenerate (full key shown once); Save Webhook -> PUT webhook-settings;
Test Webhook -> POST test with the result in a snackbar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:27:56 +03:00
Дмитрий dc9cab300c test(api): WebhookSettings — tenant-isolation + failure-path coverage (review M2/M3/M4)
Code-quality review of Task 4: adds a cross-tenant isolation test
(verifies the where(tenant_id) guard, matching ApiKeyControllerTest)
and a test()-endpoint failure-path test (HTTP 500 -> ok=false). Drops
the @return docblock from OutboundWebhookSubscriptionFactory for
consistency with ApiKeyFactory, eliminating a baseline entry at source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:21:52 +03:00
Дмитрий 3266909346 feat(api): outbound webhook settings endpoints (closes J5 part 2)
Audit J5/D4/D5: the outbound_webhook_subscriptions table existed in
schema but had zero code. Adds the OutboundWebhookSubscription model +
factory and WebhookSettingsController with GET/PUT
/api/tenants/me/webhook-settings (one subscription per tenant; secret
generated + returned once on creation, bcrypt-hashed) and POST
/api/webhooks/test (unsigned connectivity check — HMAC-signed event
delivery is a separate post-MVP epic). Tenant-scoped via auth:sanctum +
tenant middleware.

phpstan-baseline.neon: additive-only entries for new test file
(Pest\PendingCalls\TestCall false-positives — documented project pattern)
and OutboundWebhookSubscriptionFactory method.childReturnType (same
pattern as ProjectFactory/TenantFactory/UserFactory already in baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:13:32 +03:00
Дмитрий a26f5af2da refactor(api): ApiKeyController index() excludes expired keys (review M1)
Code-quality review of Task 3: index() filtered by is_active only —
an expired-but-active key would be listed as valid. Adds an
expires_at > now() filter plus a test. Cannot occur today (regenerate
is the only write path, always +1 year) but is the correct semantic
contract for an «active key» listing.

phpstan-baseline.neon: count bumps only for ApiKeyControllerTest.php
($tenant 5→7, $user 3→5, getJson 3→4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:06:14 +03:00
Дмитрий a5e2bbbbe8 feat(api): api_keys model + GET/regenerate endpoints (closes J5 part 1)
Audit J5/D3: the api_keys table existed in schema but had zero code.
Adds the ApiKey model + factory, and ApiKeyController with GET
/api/api-keys (list active keys, key_hash hidden) and POST
/api/api-keys/regenerate (deactivate prior + create new, full key
returned once, bcrypt-hashed in DB). Tenant-scoped via auth:sanctum +
tenant middleware (RLS on api_keys). phpstan-baseline.neon updated for
Pest PendingCalls false-positives in the new test file; also removes
8 pre-existing stale ignore.unmatched entries (properties now resolved
by existing @mixin IdeHelper* docblocks — confirmed pre-existing via
git stash test before Task 3 changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:53:35 +03:00
Дмитрий 2c59a00714 refactor(settings): ProfileTab — document auth-guard assumption + tighten spec (review M1/M2)
Code-quality review of Task 2: documents why ProfileTab needs no
watch-resync of auth.user (router beforeEach awaits fetchMe before
requiresAuth navigation); tightens the save-error test to assert the
exact fallback message instead of mere truthiness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:44:17 +03:00
Дмитрий 075a661c62 feat(settings): ProfileTab wired to PATCH /api/auth/me (closes D1)
Audit D1: ProfileTab fields were hardcoded refs and the Save button had
no handler. Rewired to the auth store + a new api/auth updateProfile()
calling PATCH /api/auth/me. Single «Полное имя» field split into Имя +
Фамилия (matches users.first_name/last_name); decorative «Роль» field
removed (no such column). AuthUser type gains phone + timezone.

SettingsView.spec.ts updated: «Полное имя» assertion changed to check
for «Имя» and «Фамилия» (collateral fix for the intentional field split).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:37:40 +03:00
Дмитрий b40a76e0ff test(auth): UpdateProfileTest — 422 coverage for empty last_name (review M1)
Code-quality review of Task 1: first_name had a 422 test but last_name
(identical required rule) did not. Adds the symmetric test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:30:13 +03:00
Дмитрий f23a71b670 fix(test): pin SyncSupplierProjectsJobTest clock before 20:55 MSK cutoff
SyncSupplierProjectsJob:77 has a time-budget guard that breaks the
sync loop after 20:55 Europe/Moscow. Five of the eight tests in
SyncSupplierProjectsJobTest omitted Carbon::setTestNow(), so they
inherited real wall-clock time and silently failed (job no-ops)
every evening after 20:55 MSK -- a latent test bug since dedaae5
(Plan 3), mis-attributed to a Redis race (quirk 72) in earlier audits.
Pins beforeEach to a fixed pre-cutoff clock; the job code is correct
and unchanged. Verified: 8/8 in isolation, full suite back to green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:21:36 +03:00
Дмитрий d8d2f37598 feat(auth): PATCH /api/auth/me profile update endpoint (closes J6)
Audit J6: ProfileTab needs a full-profile update endpoint. Adds
AuthController::updateProfile (first_name/last_name/phone/timezone),
routed in the existing /api/auth auth:sanctum group; mirrors the
sibling updateNotificationPreferences. userResource() now also returns
phone + timezone so the GET /me round-trip carries them.

phpstan-baseline.neon updated for Pest PendingCalls false positives
in the new test file (same pattern as all other Feature test files).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:58:00 +03:00
Дмитрий 772cdf4109 docs(plan): Sprint 2 Plan B — Settings (D1-D5 + J5 + J6)
Plan B of the Sprint 2 split — the Settings subsystem, 5 atomic TDD
tasks: PATCH /api/auth/me profile endpoint (J6); ProfileTab rewired to
real API (D1); ApiKey model + api-keys endpoints (J5/D3); outbound
webhook settings endpoints (J5/D4/D5); ApiTab full wiring (D2-D5).
Schema delta = 0 — api_keys + outbound_webhook_subscriptions tables
already exist in schema.sql.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:51:46 +03:00
Дмитрий 61e1cffb98 fix(auth): LegalDocView v-alert role=note + trim back-link whitespace (review M-1/M-2)
Code-quality review of the legal stub pages: the always-present
informational v-alert defaulted to role=alert (assertive live-region) —
changed to role=note for a static advisory (WCAG 2.1 AA). Trimmed
cosmetic whitespace inside the back-link element.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:46:09 +03:00
Дмитрий 012053a783 feat(auth): /legal/offer + /legal/privacy stub pages (closes A7)
Audit A7: the «Оферта» / «Политика» links in the AuthLayout footer were
raw <a href> pointing at unrouted paths -> 404 via the SPA catch-all.
Adds a single DRY LegalDocView served by /legal/:doc(offer|privacy),
rendering an honest «document being finalized» stub (real legal text
needs юр. редактура — реестр K3 / blocker Б-1). Footer links upgraded
to <RouterLink> for SPA navigation. Also refreshes two stale auth-layout
doc-comments left by the /recovery removal (review M1).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:37:20 +03:00
Дмитрий 70508b6675 refactor(auth): remove orphaned /recovery RecoveryCodesView page (closes A2, A3)
Audit A2/A3: RecoveryCodesView (route /recovery) had a TODO no-op
continue handler and 8 hardcoded mock codes. Recon found the page is
orphaned — nothing in the UI navigates to /recovery. The real 2FA
recovery-codes flow lives entirely in Settings -> Безопасность
(TwoFactorCard setup wizard + RecoveryCodesCard regeneration), both
already wired to the real API. Per user decision (2026-05-15) the
orphan is deleted rather than polished.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:26:26 +03:00
Дмитрий 7a4f8c2793 docs(plan): Sprint 2 Plan A — Auth (A2/A3 orphan delete + A7 legal pages)
Sprint 2 (P1 wave 1) split into 3 sub-plans per writing-plans
scope-check (Auth / Settings / Billing — independent subsystems).
Plan A covers the Auth subsystem:
- A2/A3: delete orphaned /recovery RecoveryCodesView (real flow lives
  in Settings -> Безопасность; user-approved deletion 2026-05-15).
- A7: /legal/offer + /legal/privacy stub pages via one DRY LegalDocView.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:21:30 +03:00
Дмитрий 1fd6f7f597 fix(security): harden impersonation/webhook/tenant — audit A2/A3/B3/C2
- A2: impersonation _dev_plain_code в ответе init только в local/testing
- A3: X-Tenant-Id принимается только в local/testing (anti-spoof тенанта)
- B3: WebhookReceiveController isHmacRequired() default false→true (fail-secure)
- C2: SupplierWebhookController per-IP rate-limit 600/min (DoS-guard)
- WebhookReceiveTest обновлён под B3 (отсутствие настройки → 401)

Tests: 70/70 passed (323 assertions) — Webhook/Impersonation/Tenant suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 19:16:13 +03:00
Дмитрий cc7ec0d749 docs(tooling): sync v2.1 header version + history row (review) 2026-05-15 17:59:50 +03:00
Дмитрий 8b47aa5a4d docs(sync): PSR_v1 v3.1 + Tooling v2.1 — §14 queen-trigger cross-ref 2026-05-15 17:53:44 +03:00
Дмитрий fff25605d0 fix(claude_md): §1 two explicit hard-rules + §0 v1.15 cell relabel (review) 2026-05-15 17:50:21 +03:00
Дмитрий 2722f60420 docs(claude_md): §14 queen-trigger refs — §1/§3.5/§0 (v2.1) 2026-05-15 17:44:12 +03:00
Дмитрий 3b8a5184c7 fix(pravila): §0 — clarify §12/§14 non-conflict (review) 2026-05-15 17:39:40 +03:00
Дмитрий efd588f661 docs(spec): automation-graph iter5 — ruflo factual recollage design
Design spec for reworking the ruflo cluster on docs/automation-graph.html
to reflect factual runtime state (live MCP + filesystem inspection)
instead of the normative declaration: 7 fictional swarm roles removed;
real footprint added as summary nodes (100-agent catalog, 88 slash
commands, recall hook, "0 plugins / 0 skills" node); broken daemon
(spawn claude ENOENT) and idle hive (0 tasks) made explicit; fictional
delegation edges dropped. Map 85->83 nodes / 96->90 edges / 11 conflicts.

cspell-words.txt: +10 terms (Russian inflections + ENOENT).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:35:59 +03:00
Дмитрий 1f9b9ab788 docs(pravila): +§14 Ruflo Queen routing hard-rule (v1.15) 2026-05-15 17:34:00 +03:00
Дмитрий fb883148b6 feat(ruflo): register queen-trigger hook in .claude/settings.json 2026-05-15 17:28:21 +03:00
Дмитрий 22056baabc fix(ruflo): queen-hook isDiscussion — word-boundary guard (review) 2026-05-15 17:25:09 +03:00
Дмитрий dc6caea99f feat(ruflo): queen-trigger UserPromptSubmit hook (TDD) 2026-05-15 17:17:27 +03:00
Дмитрий d21b6556d2 docs(plan): ruflo queen-trigger + delegation implementation plan 2026-05-15 17:12:12 +03:00
Дмитрий cce3baea49 docs(spec): ruflo queen-trigger + delegation hard-rule design
Brainstorming output: trigger words queen/королева -> unconditional ruflo
Queen routing (Pravila §14, new explicit hard-rule), enforced via
tools/ruflo-queen-hook.mjs UserPromptSubmit hook. Broader: proactive
ruflo-spawn proposal for non-trivial tasks. Cost-gate: preview + confirm
before paid hive-mind spawn --claude.

cspell-words.txt: +9 Russian IT-slang inflections for the spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 17:00:05 +03:00
Дмитрий 0f94c21332 fix(docs): Tooling_v8_3 — битую ссылку на удалённый форк → backtick-спан
Удаление docs/automation-graph-ruflo.html (automation-graph iter4, d18b60f)
оставило битую markdown-ссылку в §«Связано». Конвертирована в backtick-спан
(как упоминания того же форка в CLAUDE.md) + нота «влит и удалён» —
исторический факт сохранён, pre-push lychee проходит.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:37:44 +03:00
Дмитрий d18b60f4ae chore(graph): remove automation-graph-ruflo.html fork — merged into main map (iter4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 16:06:16 +03:00
Дмитрий fcdd5b5f14 docs(graph): refresh rule nodes + §12/sub-policy to v2.0 normative (iter4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:56:36 +03:00
Дмитрий 6c3640c45b docs(plan): ruflo H7 implementation addendum — onnxruntime dedupe
Records the key divergence found during subagent-driven execution: the
H7 fix needed onnxruntime-node dedupe in addition to the getBridge patch
(two incompatible onnxruntime-node versions => DLL collision). Documents
3 residual ruflo-alpha quirks and the post-ruflo-update re-apply step.

cspell-words.txt +dlopen (ERR_DLOPEN_FAILED token).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:50:11 +03:00
Дмитрий 21f81ed6ea fix(graph): ruflo delegation nodes — legacy refs to manages slot (iter4 Task 1 review)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:48:22 +03:00
Дмитрий cd04cd6336 feat(ruflo): register UserPromptSubmit advisory recall hook
Wire tools/ruflo-recall-hook.mjs into .claude/settings.json so ruflo
memory recall is injected per prompt. Project-scoped, fail-open.
Absolute path (forward slashes) — robust vs Windows shell var expansion.
Verified: hook recalls stored entries, ~1.55s latency (under 3500ms cap).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:45:54 +03:00
Дмитрий 7e87324dde feat(graph): ruflo orchestrator overlay — +12 nodes / +22 edges / 3 conflicts (iter4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:34:39 +03:00
Дмитрий 08d5ff1151 feat(ruflo): UserPromptSubmit advisory recall hook
Hook script that runs ruflo memory search per prompt and injects top
matches as additionalContext. Fail-open (error/timeout -> empty inject,
exit 0, never blocks). Pure-function core unit-tested via node --test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:34:33 +03:00
Дмитрий ef71bce0a2 feat(ruflo): ruflo-h7-patch.mjs also dedupes onnxruntime-node
The H7 fix needs two operations on the global ruflo install: the
getBridge() patch AND disabling the duplicate nested onnxruntime-node
(@xenova/transformers' 1.14.0 vs the hoisted 1.24.3 — DLL name collision
=> ERR_DLOPEN_FAILED). The re-apply script now does both so the whole
fix survives a ruflo update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:28:18 +03:00
Дмитрий b8ef4a0a7e docs(plan): automation-graph iter4 — ruflo big-bang merge implementation plan
5 задач: ruflo-наслой (12 узлов / 22 ребра / 3 конфликта) → рефреш под
нормативку v2.0 (4 узла-правила + §12/sub-policy) → удаление форка →
visual smoke → синхронизация memory. 3 атомарных коммита реализации.
cspell-words.txt +2 словоформы (тулбар/скила).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:24:39 +03:00
Дмитрий be755dd8eb fix(ruflo): harden ruflo-h7-patch.mjs — argv guard + unknown-flag rejection
Code-review fixes: guard pathToFileURL against undefined argv[1];
reject unrecognised flags with exit 2 before any filesystem access
(prevents a --revert typo from silently applying the patch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:36:56 +03:00
Дмитрий 1052ddfc97 feat(ruflo): H7 patch re-apply script (getBridge -> null)
Idempotent script that forces @claude-flow/cli getBridge() to return null
so ruflo memory ops use the persisting raw sql.js path. Pure-function core
unit-tested via node --test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:27:14 +03:00
Дмитрий c6c6e8c0cc docs(plan): ruflo memory H7 fix + advisory hook — implementation plan
8-task plan for the approved design (spec a6649e4):
- D1 (Tasks 1-3): install standalone claude CLI, verify spawn --claude
- D2 (Tasks 4-5): TDD ruflo-h7-patch.mjs, apply patch, verify round-trip
- D3 (Tasks 6-7): TDD ruflo-recall-hook.mjs, register UserPromptSubmit hook
- Task 8: memory update + push

cspell-words.txt +3 entries used by the plan.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:15:14 +03:00
Дмитрий a6649e4696 docs(spec): ruflo memory H7 fix + advisory hook — design
Design for 3 deliverables (brainstorming output):
- D1: install standalone claude CLI to unblock hive-mind spawn --claude
- D2: fix H7 memory-persistence bug — patch getBridge() so memory ops
  use the persisting raw sql.js path instead of the non-flushing
  AgentDB-v3 bridge
- D3: UserPromptSubmit advisory hook injecting ruflo memory recall

cspell-words.txt +11 Russian IT-slang inflections used by the spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 14:00:47 +03:00
Дмитрий 55696e5b40 docs(spec): automation-graph iter4 — ruflo big-bang merge design
Дизайн слияния ruflo-наслоя в каноническую docs/automation-graph.html.
Решения brainstorming: одна карта (форк удаляется), честный гибрид
(Queen уровнем −1 + конфликт-маркер «декларация ≠ parallel subsystem»),
полный рефреш под нормативку v2.0. +12 узлов / +22 ребра / 8→11 конфликтов.
cspell-words.txt +4 словоформы (форке/наслой/нормативке/рефреш).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 13:25:37 +03:00
Дмитрий 5ac2961698 feat(ruflo): activate runtime — daemon-as-service + hive-mind + real embeddings
Полная активация ruflo runtime (заказчик: «Активировать ruflo runtime» →
«Полная (daemon-as-service + hive-mind spawn)»). Закрывает paper/runtime gap
из Phase 3-4 нормативной инверсии.

Что активировано:
- ruflo установлен глобально (npm i -g ruflo — стабильное дерево вместо
  ephemeral npx-cache; решает module-resolution для embeddings)
- Daemon ACTIVE под PM2 (ruflo-daemon, 5 workers); reboot-survival через
  Windows Task Scheduler PM2-ruflo-daemon (pm2 resurrect onlogon — ruflo
  native install-supervisor только launchd/systemd, pm2-windows-service
  deprecated+broken non-interactive → Task Scheduler fallback)
- Hive-mind ACTIVE — Queen-led (hierarchical-mesh/byzantine) + 9 worker-агентов
- Memory init — sql.js .swarm/memory.db + реальные embeddings
  Xenova/all-MiniLM-L6-v2 384-dim (sharp/libvips fix: @xenova/transformers
  hard-deps sharp, prebuilt libvips timeout'ит — curl tarball в npm-cache/_libvips)

Repo-изменения:
- .gitignore +.swarm/ +ruvector.db (ruflo runtime state, не tracking)
- CLAUDE.md §3.5 + §6 «Runtime state» — paper-level → runtime active
- Tooling §4.10 «Runtime state» — аналогичный sync

Verification:
- Phase A gate: Pest 742/739/3sk/0 + Vitest 92f/774/3sk/0 + vue-tsc 0 ✓
- ruflo doctor: 10 passed / 7 warnings (alpha/optional)
- Pest --parallel post-activation: 0 регрессий от ruflo. 1 intermittent error
  классифицирован pest-parallel-debugger агентом (11 runs) как quirk 72 —
  ruflo grep-подтверждённо не трогает Redis :6379, worker-jitter лишь
  усиливает частоту pre-existing flake (suite green 739/739/0/3 5× verified)

Известные alpha-bugs (документированы):
- ruflo memory store CLI не персистит между invocations (in-memory sql.js)
- daemon worker-jitter усиливает Pest quirk 72 — пауза pm2 stop ruflo-daemon
  на baseline regression
- $-расход near-zero: ruflo doctor «No API keys found», daemon не делает
  платных LLM-вызовов; cap $10/день в .env.local + PM2 env как belt

Daemon-resurrect helper: C:\Users\Administrator\ruflo-daemon-resurrect.cmd
(machine-level, вне репо). Effective state: runtime активен.

Related: ruflo big-bang Phase 3-4 нормативная инверсия (9c3057b/d30cbeb/
5df88a1/f65a8d7/6287561), spec/plan 2026-05-15.
2026-05-15 12:31:53 +03:00
Дмитрий 6287561fce docs(sync): Phase 4 cross-refs sync + CHANGELOG_claude_md.md +v2.0 entry — ruflo big-bang Day 4
Ruflo big-bang Phase 4 Task 4.1 — закрывает нормативную инверсию.

Изменения:
- CHANGELOG_claude_md.md: +v2.0 entry (полное описание Phase 3-4 — 4
  normative rewrites Pravila v1.14 / PSR_v1 v3.0 / CLAUDE.md v2.0 /
  Tooling v2.0 + effective-state candor)
- CLAUDE.md §6: «Tooling v2.0 (pending)» → «(commit f65a8d7)»
  («(pending)» annotation stale после всех 4 Phase 3 commits)
- PSR_v1 история версий v3.0 entry: «CLAUDE.md/Tooling v2.0 (pending)»
  → commit hashes 5df88a1/f65a8d7
- cspell-words.txt: +«спеке» (Russian locative inflection of «спека»)

Cross-refs audit (plan §4.1.1): проверены v1.13/v2.1/v1.17/v1.93 refs
во всех 4 normative files — все current-state cross-refs корректно
bump'нуты в Phase 3 commits; остаточные старые версии встречаются
только в frozen changelog entries + «Введено в vX» исторических
маркерах + «vX+» forward-compat нотации (не stale).

Phase 3-4 завершён: Pravila v1.14 (9c3057b), PSR_v1 v3.0 (d30cbeb),
CLAUDE.md v2.0 (5df88a1), Tooling v2.0 (f65a8d7), sync (this).

Related: ruflo v3.7.0-alpha.38 integration via spec/plan 2026-05-15
(e55572e/a68a0a0/18c4463/9bd1bae); Phase 1-2-5-6-7 prior session.
2026-05-15 11:22:14 +03:00
Дмитрий f65a8d79ec docs(tooling): §0 35 → 55 + new §4.10 Orchestration layer (ruflo) — v2.0 (ruflo big-bang Day 3)
Ruflo big-bang Phase 3 Task 3.4 (финальный). Major bump v1.17 → v2.0:
ruflo формализован как четвёртая off-phase подкатегория «orchestration».

Изменения:
- Header v1.17 → v2.0, date 15.05.2026
- §0 summary table: +row «ruflo orchestration layer» (+20 plugins);
  count «35 формализованных позиций» + 20 ruflo plugins = 55 total
- §0 «Назначение» line — sync stale «33» (pre-v1.17 oversight) → 35+20=55
- §4.9 +note «Категории off-phase tools (v2.0)» — 4 подкатегории
  (UI-пул / infrastructure / debug-runtime / orchestration)
- §4.10 (new) «Orchestration layer (ruflo) — entry-point иерархии»:
  npm package + repo + namespace, 20 plugins / ~210 MCP tools / 60+
  agents, архитектурная роль (entry-point level −1), категория,
  установка (commit 55c49c9), cost-budget, runtime state candor
  (daemon/swarm/memory НЕ активны — opt-in MCP, paper-level), IPFS
  gateway risk, Связано-links
- §11/§12 — sync stale «33» → «35» (pre-existing v1.17 oversight)
- История версий: +v2.0 table row + footer note

Effective-state candor: §4.10 явно фиксирует — scaffold installed,
MCP server в .mcp.json, но daemon/swarm/memory не initialized; ruflo
доступен как opt-in MCP (7-й из 7), не enforcing Queen-led overlord.

Phase 3 завершён (4/4 normative rewrites): Pravila v1.14 (9c3057b),
PSR_v1 v3.0 (d30cbeb), CLAUDE.md v2.0 (5df88a1), Tooling v2.0 (this).
Pending Phase 4: cross-refs sync + CHANGELOG_claude_md.md +v2.0 entry.

Related: ruflo v3.7.0-alpha.38 integration via spec/plan 2026-05-15
(e55572e/a68a0a0/18c4463/9bd1bae).
2026-05-15 11:18:09 +03:00
Дмитрий 5df88a1310 docs(claude_md): §1 +уровень −1 ruflo + §3.5 orchestration + §5 п.10 sub-policy note + §6 ruflo phase — v2.0 (ruflo big-bang Day 3)
Ruflo big-bang Phase 3 Task 3.3. Major bump v1.93 → v2.0: 8-level → 9-level
priority chain, ruflo Queen-led routing на уровне −1 (entry-point).

Изменения:
- Header v1.93 → v2.0 (architectural inversion description + полный
  legacy tail v1.93→v1.86 preserved)
- §0 cross-refs: Pravila v1.13 → v1.14 (commit 9c3057b), PSR_v1 v2.1 →
  v3.0 (commit d30cbeb), Tooling v1.17 → v2.0 (§4.10 Orchestration layer)
- §1 priority chain: +уровень −1 «ruflo Queen-led routing (entry-point)»
  над уровнем 0; уровни 0-6 byte-identical (становятся execution layer);
  +trailing explanation
- §3 title «35 инструментов» → «35 + ruflo orchestration layer»;
  +§3.5 (new) «Off-phase orchestration: ruflo»; §3.5 «Заметки к
  settings.json» renumber → §3.6
- §5 п.10: +inline sub-policy note (claude-md-management остаётся
  preferred channel через ruflo routing; ruflo agents могут править
  напрямую при явном routing-decision)
- §6: +2026-05-15 ruflo big-bang integration paragraph над «Post-MVP»
- §9: +v2.0 entry

Effective-state candor: §3.5/§6/header/§9 явно фиксируют paper-level
architectural commitment — ruflo daemon/swarm/memory НЕ initialized
2026-05-15; ruflo доступен как opt-in MCP tool, не enforcing Queen-led
overlord. Технические компенсаторы (gitleaks/RLS/dev DB) сохраняются.

Прямой Edit per plan §1.4 — user-authorized exception к §5 п.10
(claude-md-management обязательный канал не применён по решению
заказчика для нормативной инверсии).

Pending Phase 3 sibling: Tooling v2.0. Phase 4: cross-refs sync +
CHANGELOG_claude_md.md +v2.0 entry + «(pending)» annotations cleanup.

Related: ruflo v3.7.0-alpha.38 integration via spec/plan 2026-05-15
(e55572e/a68a0a0/18c4463/9bd1bae); Phase 3 commits 9c3057b/d30cbeb.
2026-05-15 11:07:51 +03:00
Дмитрий d30cbeba10 docs(psr_v1): R0 stack-gate → sub-policy paired-stack delegation pattern — v3.0 (ruflo big-bang Day 3)
Ruflo big-bang Phase 3 Task 3.2. Major bump: R0 «единый stack и обязательный
gate» → «Sub-policy: paired-stack delegation pattern (под ruflo Queen-led
routing)».

Изменения:
- R0 title rewrite (sub-policy framing)
- R0.1 «Уровень и головенство» — добавлен top row «−1. ruflo Queen-led
  routing (entry-point, v3.0+)»; PSR_v1 row «— это и есть stack» → «sub-policy
  ruflo routing»
- R0.2 «Обязательный gate» — первый параграф переписан: ruflo первой,
  stack-gate как sub-policy через routing-decision. Subsequent R0.2 sub-points
  + ASCII gate diagram сохранены (semantic tension — diagram pre-v3.0
  visualization, кандидат на follow-up polish)
- R0.6 «Hard-стоп даже в Auto mode» — добавлен пункт 11 (sequential
  continuation после v2.0 R15 removal; spec литерально писал «п.12», но
  фактический list содержит 1-10, sequential = 11): «ruflo Queen routes
  task как autonomous swarm, но human absent для review — pause до review»
- Принцип-аксиома (line 10) переформулирован под ruflo: stack — головной
  при решении задач, маршрутизированных в paired-stack sub-policy через
  ruflo (entry-point −1)
- Header version v2.1 → **v3.0**, date 13.05.2026 day +1 → 15.05.2026
  afternoon, summary paragraph + narrative tail
- История версий: v3.0 entry на верху (sequential continuation note)
- Cross-refs: CLAUDE.md v1.88+ → v2.0+, Pravila v1.11+ → v1.14+ (commit
  9c3057b), Tooling v1.16+ → v2.0+ (§4.10 Orchestration layer)

R0.3 «Структура stack'а», R0.4.A SoT cross-ref на Pravila §12.3, R0.4.B
live-команды table, R0.5, R1-R14 правила — preserved untouched.

Pending Phase 3 sibling: CLAUDE.md v2.0, Tooling v2.0. Phase 4: cross-refs
sync + CHANGELOG_claude_md.md +v2.0 entry.

Related: ruflo v3.7.0-alpha.38 integration via spec/plan 2026-05-15
(e55572e/18c4463/9bd1bae/9c3057b).
2026-05-15 10:59:02 +03:00
Дмитрий 9c3057b473 docs(pravila): §12 hard rule → sub-policy + §5 ПДн execution-layer note — v1.14 (ruflo big-bang Day 3)
Ruflo big-bang Phase 3 Task 3.1 — переводит §12 Superpowers из «hard rule» в
«sub-policy под ruflo Queen-led routing» (routing preference для interactive
turns; не absolute block). §12.2 карта 14 типов задач + §12.3 exclusions SoT
+ §12.4 details сохранены содержательно — меняется только framing.

§5 ПДн получает execution-layer note: gitleaks pre-commit фильтр —
технический compensator, не зависит от regulatory hierarchy, продолжает
работать выше ruflo routing.

§0 priority chain + §0 «Особый статус §12» note синхронизированы с
sub-policy framing. PSR_v1 cross-refs в §11.5/§13.2/§13.9/§13.10 bumped
v2.0/v2.1 → v3.0+ (R0 → sub-policy). CLAUDE.md → v2.0+, Tooling → v2.0+
в changelog block.

Pending Phase 3 (sibling normative rewrites): PSR_v1 v3.0, CLAUDE.md v2.0,
Tooling v2.0. Phase 4: cross-refs sync + CHANGELOG_claude_md.md +v2.0 entry.

Related: spec docs/superpowers/specs/2026-05-15-ruflo-integration-design.md
(e55572e+a68a0a0), plan docs/superpowers/plans/2026-05-15-ruflo-big-bang-integration.md
(18c4463+9bd1bae), Phase 2 install 55c49c9, map fork 796d814.
2026-05-15 10:50:09 +03:00
Дмитрий 9bd1baedef fix(plan): broken relative links in §4.1.2 CHANGELOG entry template
Phase 6 lychee regression выявил 2 broken-link в
docs/superpowers/plans/2026-05-15-ruflo-big-bang-integration.md:680 —
template для CHANGELOG_claude_md.md entry имел relative paths
`(specs/...)` и `(plans/...)` которые резолвились в
docs/superpowers/plans/specs/... и docs/superpowers/plans/plans/...
(double-prefix, файлы не существуют).

Fix: changed к correct relative form:
- specs/... → ../specs/... (parent dir)
- plans/... → 2026-05-15-ruflo-big-bang-integration.md (same dir, bare
  filename)

Per Pravila §4.7 п.4 + memory quirk 76: relative paths from plans/specs
require explicit `../<sibling>/` или bare filename для same-dir.

Lychee post-fix: 487 OK / 0 errors (was 485 OK / 2 errors pre-fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:29:28 +03:00
Дмитрий 796d814e62 feat(graph): automation-graph-ruflo.html — fork iter3 с ruflo overlay
User's primary asked deliverable: «отдельный проект карты с его внедрением»
(2026-05-15 session). Fork of docs/automation-graph.html iter3 (commit
8a22cc4) with ruflo big-bang overlay (TO-BE structure).

Map additions vs source:
- GROUPS: +ruflo (orange #ff8800, top-of-hierarchy semantic)
- NODES: +10 ruflo (Queen в верхнем-левом углу за пределами radial-sector
  + 9 swarm-roles в circle ~180px вокруг Queen: Architect, Coder,
  Security, RLS-reviewer, QA, Tester, Reviewer, Memory-keeper, Daemon-worker)
- EDGES: +18
  * 9 Queen → swarm (подчиняет)
  * 4 Queen → group-centroids pravila/claude_md/psr_v1/tooling
    (перенял sub-policy)
  * 5 swarm → legacy execution-layer (делегирует TDD/RLS/HNSW/PM2)
- CONFLICTS: +3 BLACK «возник на практике»
  * Queen ↔ pravila: alpha-tool overrides hard-rule §12
  * daemon ↔ mem_state: static .md vs HNSW dual-system синхронизация
  * Queen ↔ mcp_pw: IPFS gateway flaky (Pinata + Cloudflare failed
    2026-05-15) → plugin discovery offline риск
- HTML: comment header с source/spec/plan refs; title updated
- footer cat-legend: +🌊 ruflo Queen + swarm item (filter-key
  group:ruflo)

NOT in scope этого commit'а:
- subPolicy:true overlay для 73 legacy nodes (polish item, plan §5.4)
- Visual smoke в Edge — manual task для пользователя
- Phase 3-4 normative file rewrites — DEFERRED to separate session

cspell vocab additions для Phase 3-5 normative rewrites (lowercase per
user-dict case rule): sub-policy, queen-led, hive-mind, orchestrator,
autopilot, poincaré.

Refs:
- spec docs/superpowers/specs/2026-05-15-ruflo-integration-design.md
  (commit e55572e — base + a68a0a0 — Phase 1 sync)
- plan docs/superpowers/plans/2026-05-15-ruflo-big-bang-integration.md
  (commit 18c4463 — base + a68a0a0 — Phase 1 sync)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:27:47 +03:00
Дмитрий 55c49c9889 feat(ruflo): install scaffold + MCP entry + cost-budget — Phase 2 install
ruflo v3.7.0-alpha.38 installed via npx ruflo init --full --no-global
--with-embeddings --force. 86 files / 9 directories scaffolded.

Successful artifacts (kept, gitignored):
- .claude-flow/ — V3 runtime (config.yaml, data/, logs/, sessions/)
- .claude/agents/ — +23 ruflo agent subdirs (analysis, architecture,
  browser, consensus, core, custom, data, development, devops,
  documentation, flow-nexus, github, goal, optimization, payments, sona,
  sparc, specialized, sublinear, swarm, templates, testing, v3)
  — auto-regenerable via ruflo init, не tracking
- .claude/commands/ — 10 ruflo slash-commands (gitignored)
- .claude/helpers/ — ruflo CLI helpers (gitignored)

Restored from backups (ruflo init --force overwrote, intentional plan §3
will rewrite manually):
- CLAUDE.md (76068 bytes / 280 lines — original restored from
  CLAUDE.md.pre-ruflo.bak; Phase 3 Task 3.3 will manually add ruflo
  level −1 chapter)
- .claude/settings.json (2681 bytes — original restored from
  .claude.pre-ruflo.bak/settings.json; Phase 2 Task 2.10 will manually
  add memory reindex PostToolUse hook)
- .mcp.json (3718 bytes — git checkout HEAD; now extended manually with
  ruflo entry below)

Custom subagents preserved untouched:
- .claude/agents/pest-parallel-debugger.md
- .claude/agents/rls-reviewer.md
- .claude/skills/ untouched

This commit changes (tracked):
- .gitignore — +21 ruflo paths (.claude-flow/, CLAUDE.local.md, agent
  subdirs, commands/, helpers/, backups, transient logs)
- .mcp.json — +ruflo entry (7th MCP server: playwright + github +
  laravel-boost + semgrep + sentry + redis + ruflo). stdio mode,
  Task 1.6 verified no port-conflict.

Not committed (gitignored):
- .env.local — RUFLO_DAEMON_MAX_USD_PER_DAY=10 (spec §7 cost-budget)
- CLAUDE.md.pre-ruflo.bak — backup, kept on disk
- .claude.pre-ruflo.bak/ — backup, kept on disk

Out of scope Phase 2 (deferred decision):
- Task 2.5 settings.json enabledPlugins.ruflo-* — plan based on
  misunderstanding (ruflo is not a Claude Code plugin, it's MCP server +
  CLI; «plugins» внутри ruflo управляются `ruflo plugins install`, не
  через ~/.claude/settings.json). Skipped.
- Task 2.8 PM2 daemon-as-service — deferred to Phase 6 (post-regression
  verification что ruflo MCP не ломает существующие tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:22:13 +03:00
Дмитрий a68a0a0ccb chore(spec): Phase 1 pre-flight findings — sync spec + plan + cspell vocab
Phase 1 Task 0 verifications executed on 2026-05-15 against live Windows
Server 2022 + native PowerShell elevation + Node.js stack:

- Task 1.1 npm view: ruflo v3.7.0-alpha.38 (not alpha.33 as spec assumed),
  MIT, repository.url = ruvnet/claude-flow.git (rename Jan-2026 incomplete
  in npm metadata; plugin namespace also remains @claude-flow/*).
- Task 1.2 CLI: 33+ subcommands available — init, mcp, plugins, daemon,
  doctor, hive-mind (Queen-led consensus), autopilot, claims, cleanup, etc.
- Task 1.3 plugins list: 20 plugins in IPFS-registry (not 32 as spec
  estimated). Registry CID QmeXmAdbWVvT84GfDXPD2Vg1HWhiTW2VdZfRLhkS96KkX2
  fetched via IPFS — gateway.pinata.cloud + cloudflare-ipfs.com FAILED,
  only ipfs.io worked. 6 core + 1 command + 13 integration. 11 CRM-relevant;
  9 nichе (medical/legal/financial/quantum). User decision gate confirmed
  «full big-bang — all 20» despite material delta from spec.
- Task 1.4 disk: 67 GB free (>> 5 GB requirement).
- Task 1.5 elevation: TRUE — pm2-service-install без эскалации заказчику.
- Task 1.5.2 PM2 not yet installed.
- Task 1.6 MCP: stdio mode confirmed (INFO [claude-flow-mcp] Starting
  in stdio mode) — no port conflict with existing MCP entries. Resolves
  spec §12 Q5.

Material changes vs original spec/plan:
- 32 → 20 plugins (1.6× smaller actual scope)
- 100+ → 60+ agents (per npm description)
- Plugin namespace ruflo-* → @claude-flow/* (legacy)
- Added §10.3 risks #11 (IPFS gateway), #12 (alpha version inconsistency
  3.0.0-alpha.1..8), #13 (namespace mismatch documentation cost)
- §3 rewritten with concrete 20-plugin table and CLI subcommand list
- §12 Q1/Q4/Q5/Q7 marked RESOLVED with concrete answers
- §12 +Q11 (IPFS) +Q12 (version inconsistency)

cspell vocab additions: ruvector, ipfs, xenova, onnxruntime (lowercase
per user-dict case rule, see commit e55572e for prior precedent).

Plan synced: alpha.33 → alpha.38 (replace_all), 32 plugins references
patched at 8 specific locations. Tooling §0 row description updated:
+20 plugins (35 → 55 formalized), not +32 (35 → 67).

Awaiting user OK for Phase 2 (destructive scaffolding starts at Task 2.1
CLAUDE.md backup + Task 2.2 npx ruflo init).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:50:53 +03:00
Дмитрий 0ae92e2937 test(admin): G2 review fix — coverage for load() fetchError path
Code-review fix для commit e0bbf4d (G2 AdminSupplierPricesView errors):

I-2 (load() coverage gap): Добавлен 1 test «load() sets fetchError when
axios.get rejects». Раньше load() error handling (try/catch + fetchError
ref + v-alert warning) реализован но без test coverage. Reviewer flagged
как low-risk gap. Now covered.

Tests 8/8. Регрессий 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:31:36 +03:00
Дмитрий 18c4463ddd docs(plan): ruflo big-bang integration — 7-phase implementation plan
Spec reference: docs/superpowers/specs/2026-05-15-ruflo-integration-design.md
(commit e55572e). Plan implements full architectural big-bang per user
choice (Approach A + «чистый верх» + map fork + cost-benefit table +
compressed in-session execution path).

Structure:
- Phase 1 Pre-flight Task 0 (~15min): 6 tasks verifying ruflo CLI/plugins
  list/MCP smoke/disk/elevation → spec §12 Q1-Q10 resolution + commit.
- Phase 2 Install (~20min): backup CLAUDE.md, ruflo init, .gitignore,
  .mcp.json, settings.json plugins, .env.local cost-budget, optional
  PM2 daemon-as-service. Memory reindex hook (Task 2.10).
- Phase 3 Rewrite 4 normative files (~25min, parallel subagents):
  Pravila v1.13→v1.14, PSR_v1 v2.1→v3.0, CLAUDE.md v1.93→v2.0,
  Tooling v1.17→v2.0. 4 atomic commits. cspell vocab prep Task 3.0.
- Phase 4 Cross-refs sync (~10min): CHANGELOG +v2.0 entry, version drift
  check across 4 normative files.
- Phase 5 Map fork (~20min): docs/automation-graph-ruflo.html fork iter3
  with ruflo group + Queen + 9 swarm-roles + 4 Queen→centroid edges +
  3 new BLACK conflicts + footer cat-legend. 73 legacy nodes get
  subPolicy flag + opacity 0.7.
- Phase 6 Regression (~15min): Pest 742+, Vitest 736+, lychee, gitleaks,
  vue-tsc, phpstan, ruflo doctor, pm2 status.
- Phase 7 Closure (~5min): CHANGELOG regression numbers, push origin
  main, memory update.

Self-review: spec coverage 13/14 sections mapped to tasks; §6 memory
bridge hook gap closed by adding Task 2.10; cspell prep gap closed by
Task 3.0; 4 [TBD reference] placeholders documented as runtime
substitutions with explicit owner/timing.

Total compressed: ~110min in single session.

Decision gates: Phase 1 pre-flight critical fails → STOP plan + escalate.
Phase 2 step 2.2.4 CLAUDE.md modification by init → CRITICAL revert from
backup + escalate. Phase 6 regression fail → Day 7 closure NOT executed
until 0 failed.

Awaiting user choice: Subagent-Driven (recommended for parallel Phase 3)
vs Inline Execution.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:30:01 +03:00
Дмитрий e0bbf4d134 fix(admin): G2 — error/success handling in AdminSupplierPricesView save
axios.patch теперь в try/catch с extractErrorMessage() helper. Per-row
ошибки — reactive errorMessages: Record<number, string> отображаются как
v-icon mdi-alert-circle с v-tooltip рядом с кнопкой «Сохранить».
Success — v-snackbar (3s timeout, color=success, bottom-right) с именем
поставщика.

Retry на той же строке очищает предыдущий error перед новым axios.patch.

load() тоже обёрнут — fetchError ref + v-alert warning сверху таблицы.

+3 Vitest specs (save error / save success / retry clears error).
Регрессий 0.

Closes audit ID G2 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:26:50 +03:00
Дмитрий 0047aa4ccd test(admin): G1 review fixes — mock cleanup + successToastOpen coverage
Code-review fixes для commit 72a0064 (G1 AdminPricingTiersView errors):

I-1 (mock leak risk): Добавлен afterEach(() => vi.clearAllMocks()) в
новый describe block. Раньше axios.isAxiosError.mockReturnValue(true)
оставался активным после run'а нового describe. Сейчас нет других
тестов после G1 describe в файле — но future-proof против перестановки
test order.

I-2 (coverage gap): Оба success теста (submit + confirmDelete) теперь
assert vm.successToastOpen === true. Раньше тест мог пройти, если
кто-то забыл successToastOpen.value = true в impl — message set, но
snackbar не открыт. Now covered.

Tests 9/9. Регрессий 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:20:51 +03:00
Дмитрий e55572e22c docs(spec): ruflo big-bang integration design v0.1 + cspell vocab
Full architectural inversion: ruflo Queen-led routing as top entry-point,
existing Pravila §12 / CLAUDE.md §5 п.10 / Pravila §5 ПДн / PSR_v1 R0
become sub-policies. 14 sections: goals, architecture (8→9 levels),
scope (32 plugins), big-bang sequencing (~1.5h compressed in-session),
map fork, memory bridge HNSW, cost-budget controls (\$10/day cap),
Windows daemon, safety walls, cost-benefit deliverable (9 benefits +
8 costs + 10 risks), verification, open questions (10 Q's pre-flight
Task 0), termination, self-review.

Brainstorming via superpowers:brainstorming, economy 0%. User chose
Approach A (Full big-bang) + «чистый верх» architectural model +
map fork (vs side-by-side / new layout) + cost-benefit table deliverable
+ compressed in-session execution path (vs 7-day staged).

cspell-words.txt additions (lowercase per user-dict case rule):
ruflo, ruvnet, hnsw, sona, ruvllm, многоагентный, форк, форка, bak.

Awaiting user review of written spec before invoking writing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:19:24 +03:00
Дмитрий 72a00641fa fix(admin): G1 — error/success handling in AdminPricingTiersView submit/delete
axios.post/delete теперь обёрнуты в try/catch с extractErrorMessage()
хелпером из api/client.ts (same pattern as AdminSystemView.vue:32-45).
errorMessage отображается в v-alert (closable, type=error, tonal),
successMessage — в v-snackbar (color=success, 4s timeout).

На failed submit диалог остаётся открытым чтобы пользователь мог
исправить и повторить (UX-pattern). saving=false гарантированно
сбрасывается в finally.

+4 Vitest specs (submit error / submit success / delete error / delete success).
Регрессий 0.

Closes audit ID G1 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:12:19 +03:00
Дмитрий e8d5025656 fix(projects): C5 — replace window.alert() with v-snackbar in BulkActionsBar
window.alert блокирует UI thread, не accessible (a11y), breaks браузерный
automation (Playwright/Selenium). Заменено на v-snackbar (timeout 6s,
color warning, location bottom-right, кнопка «Закрыть»). Текст идентичен:
«Применено: N. Пропущено: M (конфликт с уже доставленными лидами).»

+2 Vitest specs (snackbar opens / snackbar НЕ opens at skipped=0).
window.confirm для pause/resume/archive намеренно оставлен — это
deliberate blocking прерывание для деструктивных операций (UX-pattern).

Closes audit ID C5 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:02:47 +03:00
Дмитрий 061532c53a refactor(kanban): C4 review fixes — array-revert test coverage + JSDoc
Code-review fixes для commit 9068005 (C4 KanbanView DnD persist):

I-2 (test coverage gap): Revert test «onColumnChange reverts...» теперь
seed'ит deal в dealsByStatus['hot'] до вызова onColumnChange (имитируя
vuedraggable mutation pre-event). После failed transition — assert
карточка удалена из hot + восстановлена в new. Раньше array-revert
branch в KanbanView.vue:80-87 (splice + push) имел 0 test coverage —
findIndex возвращал -1, splice silent. Теперь coverage 100%.

I-3 (stale JSDoc): File-header comment в KanbanView.vue lines 7-16
обновлён — описывает actual behavior после Task 2 (optimistic + API call
+ revert). Раньше явно врал «не входит в этот коммит: PATCH /api/deals/
{id}» когда POST /api/deals/transition уже реализован.

Регрессий 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:58:11 +03:00
Дмитрий 9068005566 feat(kanban): C4 — persist DnD status changes via POST /api/deals/transition
Drag-drop между колонками теперь сохраняется в БД через существующий
DealBulkActionController@transition endpoint (single-element массив).
Optimistic UI update (statusSlug меняется сразу) + revert-on-fail с
toast «Не удалось переместить — восстановлен исходный статус».

Без auth.user.tenant_id (dev/demo без login) — local-only mode, API не
зовётся (graceful degradation).

+3 Vitest specs в KanbanView.spec.ts (success / revert / no-auth skip).
Pest covered by existing DealTransitionTest. Регрессий 0.

Closes audit ID C4 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:51:21 +03:00
Дмитрий c09c52ea76 refactor(deals): C2 review fixes — watcher-driven draft + named toggles
Code-review fixes для commit 4e77947 (C2 FilterChip popovers):

I-4 (latent interaction bug): Удалена двойная open-path в FilterChip
activator. v-menu сам управляет projectMenuOpen/managerMenuOpen через
activatorProps. Draft-state копируется при menu open → true через
watch(menuOpen, ...). Раньше:
- Activator click: menuOpen=true
- @click on FilterChip: onRedesignFilterClick → menuOpen=true (duplicate)
- Re-click для close: activator toggles false → onRedesignFilterClick
  forces true back → menu не закрывается.

I-2 (inline toggle extract): Multi-line ternary @click заменён на
named methods toggleProjectDraft(proj) / toggleManagerDraft(name).
Консистентно с existing clearProjectDraft / clearManagerDraft. Также
unit-testable независимо от template.

onRedesignFilterClick остаётся для Status chip read-only behavior (P2
backlog Sprint 5). defineExpose обновлён: убран onRedesignFilterClick,
добавлены toggleProjectDraft/toggleManagerDraft/clearProjectDraft/
clearManagerDraft (для будущих spec'ов).

Vitest 3/3 C2-specs обновлены на прямой trigger projectMenuOpen=true
+ $nextTick (watcher seeds draft). Регрессий 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:43:49 +03:00
Дмитрий 4e779471fd feat(deals): C2 — wire FilterChip popovers (Проект/Менеджер) with v-menu
Заменён dead-stub onRedesignFilterClick (console.log only) на работающие
v-menu popover'ы. Project и Manager chip'ы открывают v-card с v-list checkbox-
multi-select, бинд на projectMenuDraft/managerMenuDraft → Применить → перенос
в существующие filterProjects/filterManagers refs. Status chip остаётся
read-only (P2 backlog Sprint 5).

+3 Vitest specs в DealsViewRedesign.spec.ts (toggle menu / apply selection /
empty state). Регрессий 0.

Closes audit ID C2 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:31:42 +03:00
Дмитрий 3d32ed52bd docs(plan): Sprint 1 — 5 P0 UI fixes (C2/C4/C5/G1/G2) implementation plan
Atomic TDD plan, 5 tasks, each task: file:line targets + red test scaffold +
green implementation code + verification commands + commit message draft.

- C2 DealsView FilterChip popovers (Проект/Менеджер) — v-menu wrapping
- C4 KanbanView DnD persist через POST /api/deals/transition
- C5 BulkActionsBar window.alert() → v-snackbar
- G1 AdminPricingTiersView submit/delete try/catch + v-alert + snackbar
- G2 AdminSupplierPricesView save per-row error + tooltip + snackbar

0 schema changes. Reuses existing endpoints + extractErrorMessage helper.
Sprint Acceptance: Pest 749+/Vitest 92+/0 regressions/5 atomic commits.

+1 cspell entry: unpushed (dev-process vocab).

Source spec: docs/superpowers/specs/2026-05-15-portal-audit-design.md (e978b33).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:22:37 +03:00
Дмитрий e978b33cdd docs(spec): portal-wide audit & proposals — 70 items, 6-sprint schedule
Comprehensive audit of Лидерра portal from user's perspective:
- 4 parallel Explore-agents (auth/app-user/admin/shared) → 100+ raw findings
- Router (26 SPA) vs AppSidebar (7 items) vs AdminLayout (5/7 admin routes) coverage
- ТЗ v8.5 §6 CSV-import gap analysis: schema partially ready, code 0% implemented
- Cross-ref with Открытые_вопросы v1.83 (87/71 /11 ⏸)
- Playwright MCP browser smoke (login flow + console + network)

Output: 70 atomic IDs in 11 categories (A-K), groupable to ~30-35 epics,
scheduled across 6 sprints by priority P0 → P1 → P2 → P3 → 🆕 NEW → 🧹 CLEAN.

Sprint 1 (P0, ~2 days): C2 FilterChip popovers + C4 Kanban DnD persist +
  C5 BulkActionsBar window.alert→snackbar + G1+G2 admin error handling.
Sprint 4 (🆕 H1-H9, ~5 days): CSV-import module per ТЗ §6 (исторические
  лиды + проекты from crm.bp-gr.ru → tenant в Лидерре, idempotent через
  webhook_dedup_keys advisory-lock, transaction type historical_import).

Approval: Дмитрий 2026-05-15 night «всё в работу, спринты по приоритету»
через superpowers:brainstorming flow. Next: writing-plans for Sprint 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:11:49 +03:00
Дмитрий aa3976380d fix(plan-6): replace broken absolute memory-link with plain-text reference (pre-push lychee unblock) 2026-05-15 08:10:33 +03:00
Дмитрий 8a22cc45c5 docs(graph): iter3 closure — spec + plan + smoke evidence + cspell terms
iter3 «Automation Graph — interactive highlighting» закрыт.
8 implementation commits ef88435..f0d3d49 (6 feat + 2 fix).
Smoke 12/12 PASS via Playwright (raw JSON + 2 screenshots).
markdownlint/cspell/lychee — clean. Final cross-commit review: APPROVED.

+spec/plan: docs/superpowers/{specs,plans}/2026-05-15-graph-*.md
+smoke evidence: docs/smoke-2026-05-15-graph-highlighting-scenario{2,9}.png
+cspell: NEIGHBOURS / neighbour / BFS / DFS (iter3 vocabulary)

iter4 backlog (non-blocking): I-1 falsy-coercion line 1531, dead var
highlightedNode, SECTION 6 comment update, optional rAF-throttle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 07:04:57 +03:00
Дмитрий f0d3d492a7 feat(graph): btn-clear + search input integration with highlight state 2026-05-15 06:30:55 +03:00
Дмитрий a37d32d3f7 fix(graph): use setSelectedNode API instead of direct state mutation (code review) 2026-05-15 06:28:03 +03:00
Дмитрий b9917a90d4 feat(graph): network click → selectedNode + toggle on repeat 2026-05-15 06:24:06 +03:00
Дмитрий d2fa107d11 feat(graph): legend click delegation — toggle filter + apply highlight 2026-05-15 06:20:22 +03:00
Дмитрий ac2d173089 feat(graph): SECTION 8 — state + indices + opacity computations (infra) 2026-05-15 06:12:43 +03:00
Дмитрий 0bd55b2dbd feat(graph): add data-filter-key to 12 .cat-item elements 2026-05-15 06:07:16 +03:00
Дмитрий 0b6694e802 fix(graph): add intent comment between split .cat-item rules (code review) 2026-05-15 06:04:27 +03:00
Дмитрий ef88435348 feat(graph): CSS rules for interactive legend (.cat-item hover/active states) 2026-05-15 06:00:14 +03:00
Дмитрий e8cc1f1105 docs(plan-6): regions subject-level — design spec + implementation plan
PDD regions feature (commits 4f60add..f982046) shipped с 32-bit маской на
31 субъект, incompatible со schema's 8-битным region_mask CHECK 0..255 →
500 on POST. Interim A (commit b1c3efa) откатил UI; этот эпик возвращает
поле в правильной модели.

Approach 2 — dual-write transition:
- Add projects.regions INT[] (89 codes, GIN-indexed)
- region_mask/region_mode legacy preserved для PhonePrefixService/LeadRouter
  compatibility (Plan 6.5 cleanup)
- Direct copy в supplier_projects.current_regions без bitmask conversion
- UI: <v-autocomplete> с 89 subjects + federal district subtitle

Spec — 14 sections (scope, approach, schema, REGIONS, validation, UI,
outbound, data flow, migration, testing, error, assumptions, OOS, refs).

Plan — 6 tasks (12 new tests, 3 PDD tests refactored):
- Task 0: orientation + baseline
- Task 1: schema delta v8.20 (1 commit)
- Task 2: REGIONS const 31→89 (1 commit) — 89 entries inline по
  конституционному порядку
- Task 3: backend (Store/Update/Service/Model + 5 Pest)
- Task 4: outbound adapter (SyncSupplierProjectsJob + 2 Pest)
- Task 5: frontend (Project type + NewProjectDialog + PDD + 5 Vitest)
- Task 6: regression sweep + close

Key insight (from brainstorming): SupplierProjectDto::regions уже
типизировано array<int, int|string> — supplier API contract supports
89 codes натively, не нужно изменений downstream.

5 ASSUMPTIONS marked в spec §12 (regions order, Москва/МО separate,
existing projects→[], dual-write window, UI subtitle vs subheader) —
confirmed via brainstorming session.

Drive-by: cspell-words.txt +1 entry «федокруг» (term проекта,
используется в spec и других docs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:31:23 +03:00
Дмитрий 700814c389 chore(env): switch QUEUE_CONNECTION to redis (CLAUDE.md §2 compliance)
Job dispatch fell с SQLSTATE[42P01] "Undefined table: jobs" when
QUEUE_CONNECTION=database, потому что db/schema.sql не содержит таблиц
jobs/job_batches (CLAUDE.md §6 claim "3 default Laravel-миграции удалены"
не имел эквивалента для jobs в нашей schema; verified via
Schema::hasTable('jobs') = false).

Switch to redis — соответствует prod spec CLAUDE.md §2 "Кэш/очереди = Redis 7"
и существующему Memurai service (Redis 7-compat) per memory quirk #35
(PONG verified Task 4).

Verified end-to-end:
- php artisan config:clear
- config('queue.default') = redis
- Queue::connection('redis') instanceof Illuminate\Queue\RedisQueue
- SyncSupplierProjectsJob::dispatch(1) → Redis::llen('queues:default')
  delta=1 (before=0, after=1, cleanup successful)
- Pest --parallel 742/739/3sk/0
- Vitest 758/3sk/0

Local app/.env (gitignored) уже на redis с прошлой сессии; этот commit
синхронизирует normative .env.example для new env setups.

Note: db/schema.sql миграция jobs/job_batches таблиц отложена (redis driver
= no DB queue tables needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:28:54 +03:00
Дмитрий b1c3efa1e1 fix(projects): #909 СОЗДАТЬ кнопка — apiClient + interim A regions
Root causes:
1. Default axios без withXSRFToken не отправлял CSRF header → 419 silent
   fail (catch ловил только 422).
2. PDD regions UI (commits 4f60add..f982046) использовал 32-bit маску,
   несовместимую с schema's 8-битным CHECK chk_projects_region_mask_range
   → 500 silent fail.

Changes (NewProjectDialog.vue):
- Replace default axios import с apiClient + ensureCsrfCookie +
  extractErrorMessage из api/client.ts (same pattern как NewDealDialog).
- await ensureCsrfCookie() перед mutating; apiClient.post/patch.
- Remove regions <v-autocomplete> + selectedRegions ref + inverted
  region_mode watcher (interim A — proper 89-codes реализация в Plan 6).
- Add general error banner для non-422 ошибок (419/401/500/network).
- form.region_mask=255 + region_mode='include' (schema default = вся РФ).

Changes (EditProjectDialog.spec.ts):
- Switch mock с default axios на apiClient (cascading from above).

Verified: Pest 742/739/3sk/0, Vitest 758/3sk/0, vue-tsc 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 19:28:33 +03:00
Дмитрий f9820460fa feat(pdd): regions multi-select autocomplete + bitmask binding
Реализует Out-of-plan «Region multi-select autocomplete» из parent PDD spec.
Spec: 4f60add. Plan: 159ed3e.

Component (ProjectDetailsDrawer.vue):
- import REGIONS из constants/regions
- selectedRegions: Ref<number[]> + selectableRegions (filter code !== 0
  для исключения «Вся РФ» sentinel — fixes latent NewProjectDialog bug)
- maskToCodes(mask): reverse-decompose bits 1..31
- reseedFromProject: +selectedRegions.value = maskToCodes(form.region_mask)
- watch(selectedRegions): forward-encode mask + mode (include при empty, exclude иначе)
- Template: v-autocomplete multi+chips+clearable между Лимитом и Днями

Tests (ProjectDetailsDrawer.spec.ts): 17 passed (14 prior + 3 new):
- renders region chips when project has non-zero region_mask
- selecting regions encodes mask + sets mode=exclude on save
- clearing all regions resets mask=0 + mode=include on save

NB: config.global.plugins = [createVuetify()] добавлен в spec.ts — v-autocomplete
требует Vuetify defaults provide context. Все 17 PDD tests + 8/1sk ProjectsView
integration green (0 regressions).

Backend без изменений (region_mask + region_mode payload уже в Task 5 onSave).
2026-05-14 17:51:56 +03:00
Дмитрий 159ed3eb86 docs(plan): PDD regions field — 1 TDD task + verify sweep
Implementation plan для regions multi-select autocomplete в PDD
(spec: 4f60add docs/superpowers/specs/2026-05-14-pdd-regions-field-design.md).

Task 1 (atomic TDD):
- Step 1: read current state
- Step 2: append 3 failing tests (chips render / select-encodes / clear-resets)
- Step 3: verify 3 RED
- Step 4: implement (REGIONS import + selectedRegions ref + maskToCodes
  helper + watch + reseed line + template autocomplete)
- Step 5: 17 PDD tests pass
- Step 6: vue-tsc + ESLint 0 errors
- Step 7: ProjectsView integration tests still 8/1sk
- Step 8: atomic commit

Task 2 (verify-only):
- Full vitest suite 92f/758+3sk
- Vite build sanity
- Visual smoke 8-step handoff to user

Spec coverage: 100% (verified inline in plan §Self-Review).
Out-of-plan: composable extraction / NewProjectDialog backport TODO / bigint /
mobile — all explicitly deferred.

NB env quirk: Write/Edit may silently fail on cyrillic-path — workaround
через ASCII-Temp + PowerShell Copy-Item задокументирован в plan header.
2026-05-14 17:44:36 +03:00
Дмитрий 4f60add187 docs(spec): PDD regions field — autocomplete + bitmask binding
In-place port региона multi-select autocomplete в ProjectDetailsDrawer.
Закрывает Out-of-plan «Region multi-select autocomplete» из parent spec
(2026-05-14-project-details-drawer-design.md §7).

Подход A (утверждён 2026-05-14):
- v-autocomplete :items="REGIONS.filter(r => r.code !== 0)" (без sentinel)
- reverse-decompose existing region_mask в codes[] при reseedFromProject
- watch selectedRegions → encode mask + mode (include когда пусто, exclude иначе)
- 3 новых vitest case: render chips / select-encodes / clear-resets

Backend без изменений (region_mask + region_mode payload уже в Task 5 onSave).
Backport reverse-decompose в NewProjectDialog (TODO line 172) — out of scope.

cspell-words.txt +1 (иммутабельны).
2026-05-14 17:40:43 +03:00
Дмитрий 0d7f505185 docs(spec): PDD §7 Out-of-scope expanded with reviewer-flagged polish-debt
After SDD execution (9d88955..c5814ec) reviewers flagged 11 non-blocking
issues across Tasks 2/5/6/7/8/9. User decision 2026-05-14: ship as-is, defer
all polish to Plan 6+. Spec §7 расширен 3 кластерами:
- Token drift (4 hardcoded hex × #0f6e56/#f59e0b/#dc2626/480px → CSS vars)
- UX gaps (network-error snackbar / drawer a11y role+aria / Lucide icons)
- Test hardening (testid symmetry / clearAllMocks / .length / comment fix)
+ Cross-cutting silent-error pattern + Sentry breadcrumbs (Б-1 pending).

Полный feature функционально работает (Vitest 92f/755+3sk/0, vue-tsc 0,
ESLint 0, Vite build 2.50s). Polish-debt не блокирует ship.
2026-05-14 17:22:58 +03:00
Дмитрий 2ad35cac72 chore(graph): T11 — Style Guide v2 re-rewrite (clarity for non-tech reader)
Дмитрий обнаружил regression в visual smoke iter2: T2-T5 rewrite сохранил тех-жаргон. Пример MCP: semgrep when «Фаза 3 pre-production: при ревью кода (sk_coderev), при CI перед релизом» — непонятен нон-tech reader'у («Фаза 3»/«pre-production»/«sk_coderev»/«CI»).

Применены 4 новых правила Style Guide v2:
- Фазы 0-3 раскрыты («нулевая/первая/вторая/третья фаза» + контекст)
- Аббревиатуры в скобках с переводом (CI/BYPASSRLS/SAST/XSS/SQLi/PR/RLS/MCP/READ-ONLY/ПДн)
- Узловые ID запрещены — «(sk_coderev)» → «(скил code-review)», «(mcp_redis)» → «(MCP-сервер redis)»
- Английские тех-термины переведены (production→боевая среда, pre-production→перед запуском, race condition→гонка, off-phase debug-runtime→вне основных фаз — для отладки во время работы, subdir-only→из подкаталога)

Затронуты узлы: claude_md/sk_coderev/mcp_boost/mcp_semgrep/mcp_sentry/mcp_redis + label конфликтного ребра ag_pest↔mcp_redis + EDGE_DETAILS для psr_v1→upm/mcp_21st + claude_md→mcp_sentry/mcp_redis.

NODE_DETAILS=73 (intact), EDGES=74 (intact), EDGE_DETAILS=71 (intact), conflict edges=8 (intact). JS syntax OK 89440 chars.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 17:15:39 +03:00
Дмитрий c5814ecc9c test(projects): integration tests for drawer × bulk-bar mutual exclusion 2026-05-14 17:14:12 +03:00
Дмитрий bfdab40d88 feat(projects): integrate ProjectDetailsDrawer + swap bulk-bar condition >=2
Task 8 of project-details-drawer plan (2026-05-14):
- ProjectsView.vue: import ProjectDetailsDrawer + computed
  - singleSelectedProject computed (Project|null when selectedIds.size === 1)
  - onDrawerClose/onDrawerSaved handlers (clearSelection / fetch)
- Template: BulkActionsBar condition > 0 → >= 2 (mutual exclusion with drawer)
- Template: mount <ProjectDetailsDrawer> with :project / @close / @saved bindings
- Template: .has-drawer class on .projects-view root when single selected
- Style: .projects-view padding-right 480px transition for push effect
- Test: ProjectsView.spec.ts pre-existing 'shows BulkActionsBar' case updated
  to assert >=2 contract (selects 2 projects); 14 PDD tests + 3 view tests
  + 1 skip + toolbar tests all green

Vitest: 3 files / 20 passed / 1 skipped / 0 failed
2026-05-14 15:02:33 +03:00
Дмитрий ae6a370b06 feat(pdd): Delete button with confirm + archive + close 2026-05-14 14:54:55 +03:00
Дмитрий 8aca5b1ba9 feat(pdd): Pause/Resume button with toggleActive + dynamic label 2026-05-14 14:48:24 +03:00
Дмитрий 86b18fc396 feat(pdd): Save action — PATCH /api/projects/{id} + 422 errors 2026-05-14 14:41:28 +03:00
Дмитрий f47ace40f4 feat(pdd): reseed form on project.id change 2026-05-14 14:35:54 +03:00
Дмитрий 66d0d48adf feat(pdd): emit close on X/Cancel/ESC 2026-05-14 14:28:49 +03:00
Дмитрий fa01951d27 feat(pdd): render project name/limit/days form fields 2026-05-14 14:21:07 +03:00
Дмитрий 7d77187eb3 test(pdd): scaffold ProjectDetailsDrawer + null-project no-open test 2026-05-14 14:13:52 +03:00
Дмитрий fb235e9d8d docs(plan): ProjectDetailsDrawer — 10 atomic tasks (TDD-strict)
Implementation plan для side-panel редактирования single-selected проекта
на /projects (spec: 9d88955 docs/superpowers/specs/2026-05-14-project-details-drawer-design.md).

10 tasks:
 1. Scaffold + null-project no-open test
 2. Render name/limit/days fields
 3. Close emits (X / Cancel / ESC × 2 negative case)
 4. Form reseed on project.id change
 5. Save — PATCH /api/projects/{id} + 422 errors
 6. Pause/Resume + label switch
 7. Delete with confirm
 8. ProjectsView wire (condition >0 → >=2, drawer mount, computed, .has-drawer CSS)
 9. ProjectsView integration tests (5 cases: 0/1/2 selected + close + missing id)
10. Full regression + visual smoke (9 manual checks)

Каждая task: failing test → verify FAIL → impl → verify PASS → commit (TDD-strict).
9 кодовых commits + Task 10 verification only.

Coverage: 16 spec cases (11 unit + 5 integration) реализуются полностью.
Out of plan: confirm dialog при dirty Cancel / optimistic update / mobile / region
autocomplete (region_mask payload-only в Save, UI порт в отдельный sweep).

cspell-words.txt +1 (pdd) — namespacing prefix data-testid'ов компонента.

NB env quirk: Write/Edit tools silently fail on cyrillic repo path —
workaround через ASCII-Temp + PowerShell Copy-Item задокументирован в шапке плана.
2026-05-14 13:38:04 +03:00
Дмитрий 9d889558d3 docs(spec): ProjectDetailsDrawer push-mode design + mockup
Design spec + интерактивный HTML mockup для side-panel редактирования
проекта при выборе одного проекта на /projects.

Поведение:
- selectedIds.size === 1 → drawer справа (480px, push-mode, grid сдвигается)
- selectedIds.size >= 2 → BulkActionsBar внизу (условие в ProjectsView.vue:78
  меняется > 0 → >= 2)
- 0 selected → ни drawer, ни bulk-bar

Footer drawer:
- Слева (destructive): Приостановить (toggle-active) + Удалить (soft-archive)
- Справа (form actions): Отмена (close+clearSelection) + Сохранить
  (PATCH /api/projects/{id})

Backend без изменений — используются существующие endpoints PATCH/DELETE/
toggle-active. Pinia store useProjectsStore уже имеет update/toggleActive/
archive методы.

Прецеденты: DealDetailDrawer.vue (overlay-вариант); push-mode здесь — custom
aside + CSS transform/padding-right, без Vuetify teleport.

Mockup: 3 состояния через JS-toggle (0/1/2+ selected), Forest palette
(Teal #0F6E56, ivory #F6F3EC, noir #012019). Phone masked под 152-FZ ПДн.

cspell-words.txt +1 (юнит) — для упоминания юнит-тестов в spec §6.

Open questions: 0 (все 5 UX-решений утверждены заказчиком 2026-05-14).
2026-05-14 13:33:27 +03:00
Дмитрий 3cd4ac7c59 feat(graph): 3-color conflicts render + sort 🔴🟢 + footer cat-legend
.conflict-item теперь использует динамический bg из CONFLICT_TYPES[type].bg, эмодзи-префикс + цветной name из CONFLICT_TYPES[type].color. Сортировка через CONFLICT_TYPES[type].rank (RED=1, BLACK=2, GREEN=3) — 🔴 не закрыт правилом →  возник на практике → 🟢 закрыт правилом. Footer cat-legend заменил 1 «— конфликт» бэйдж на 3 цветных. Iter2 spec §4.3 — last code task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:16:56 +03:00
Дмитрий 8b0da60114 feat(graph): edge legend render + click handler for 7-field edge profile
#legend-panel разделён на 2 containers: #legend-node-content (existing) + #legend-edge-content (new, hidden default). На click по ребру открывается edge layout с 7 полями (источник/получатель/тип связи/когда/что передаёт/обязательность/регламент). showLegend переименована в showNodeLegend; новая showEdgeLegend. Click handler dispatches node vs edge. Iter2 spec §5.4, §5.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:14:20 +03:00
Дмитрий 32396d97de feat(graph): EDGE_DETAILS data structure (5-field profile for all edges)
Новый объект EDGE_DETAILS — для каждого ребра 5 полей (type/when/transfers/mandatory/rule). Источник и получатель derived from from/to при рендере в T8. Покрытие 100% — все рёбра имеют запись. Тип связи: enum из 9 (содержит/подчиняет/координирует/читает/запускает/документирует/триггерит/альтернатива/конфликт). Iter2 spec §5.1, §5.2, §5.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:11:12 +03:00
Дмитрий cec1a0c979 fix(graph): T6 — remove orphan hookify_plugin.conflicts[1] (economy-mode item)
T6 spec review нашёл orphan item: hookify_plugin conflicts array имел 2 items (PreToolUse:CLAUDE.md-warn + economy-mode хук), но в spec §4.2 классифицирован только первый (8 рёбер, hookify↔hk_economy не среди них). Item 2 без canvas edge counterpart. Remove restores invariant: 16 NODE_DETAILS conflicts items = 8 edges × 2 sides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 13:04:01 +03:00
Дмитрий 93ca58896f feat(graph): 3-color conflicts data (CONFLICT_TYPES + 2 new edges + reclassify 6)
CONFLICT_TYPES enum (RED/BLACK/GREEN с color/bg/emoji/label/rank), CONFLICT() helper расширен опциональным `type` (default RED). 6 существующих рёбер реклассифицированы: 2 🔴 (sk_rls↔ag_rls, hookify↔hk_pre_claude), 4 🟢 (psr_v1↔claude_md, upm↔fd, 21st↔fd, economy↔superpowers). 2 новых  ребра: mcp_pw↔sk_parallel (browser-in-use, квирк #2), ag_pest↔mcp_redis (Redis race в Pest --parallel, квирк 72). NODE_DETAILS conflicts items получили field `type` для всех 12 existing + 4 new items. Iter2 spec §4.1, §4.2, §4.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:59:25 +03:00
Дмитрий f6cd79ccb9 chore(graph): rewrite group D (lefthook + memory, 25 nodes) — plain language
Переписаны nd() блоки для 10 lefthook jobs (lh_gitleaks/lh_mdlint/lh_cspell/lh_stylelint/lh_pint/lh_larastan/lh_squawk/lh_eslint/lh_gitleaks2/lh_lychee) и 15 memory-файлов. Уточнено что «pre-commit stage» = «перед каждым коммитом», «stage_fixed:true» = «авто-сохранить исправленное». Iter2 spec §3 group D. Last text-rewrite task.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:53:35 +03:00
Дмитрий db7f798a64 chore(graph): rewrite group C (agents + MCP, 18 nodes) — plain language
Переписаны nd() блоки для 11 агентов (ag_pest/ag_rls/ag_explore/ag_general/ag_plan/ag_guide/ag_statusline/ag_hookify/ag_pcreator/ag_pvalid/ag_skreview) и 7 MCP (mcp_pw/mcp_gh/mcp_boost/mcp_semgrep/mcp_sentry/mcp_redis/mcp_21st). Иностранные аббревиатуры расшифрованы (SAST/CVE/SQLi/XSS/ПДн). Iter2 spec §3 group C.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:47:10 +03:00
Дмитрий 718a6e6ff3 chore(graph): rewrite group B (skills + hooks, 21 nodes) — plain language
Переписаны nd() блоки для 14 Superpowers-скилов (sk_brainstorm/sk_tdd/sk_debug/sk_wplans/sk_eplans/sk_verify/sk_parallel/sk_worktree/sk_pr/sk_subagent/sk_wskills/sk_spreview/sk_coderev/sk_elements), 2 проектных (sk_rls/sk_qitem), 5 хуков (hk_pre_claude/hk_post_md/hk_post_schema/hk_session/hk_economy). Жаргон-блэклист убран, параграф-ссылки сохранены. Iter2 spec §3 group B.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:42:32 +03:00
Дмитрий 797a17978d fix(graph): T2 polish — psr_v1.limits terminology + superpowers.when full triggers
T2 code-quality review: (1) psr_v1.limits — нормализован framing 3 rules (R14.5/R6.0/R6.1) под единый «обязательное правило» pattern. (2) superpowers.when — восстановлены 11 trigger keywords (brainstorm/TDD/debug/verify/writing-plans/parallel-work/work-tree/finishing-PR/subagent/writing-skills + творческие) — Дмитрий должен видеть конкретные skill-имена.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:36:51 +03:00
Дмитрий 2db5bd8709 chore(graph): rewrite group A (rules + plugins, 9 nodes) — plain language
Переписаны nd() блоки для pravila/claude_md/psr_v1/tooling/superpowers/fd_plugin/upm/claude_md_mgmt/hookify_plugin. Жаргон-блэклист (hard rule, matcher, pipeline, override, peerDep и др. — 11 терминов) убран; параграф-ссылки сохранены как примечания в скобках. Iter2 spec §3 (Style Guide + group A).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:29:21 +03:00
Дмитрий bcdcca01a5 fix(graph): T1 hardening — localStorage try/catch + rAF throttle on redraw
После T1 code-quality review: 2 Important issues из spec §9 mitigation list. (1) try/catch обернул read/write localStorage — в Edge InPrivate / quota-exceeded не падает, fallback на default 300. (2) network.redraw() rAF-throttled через redrawScheduled flag — устраняет potential jank при fast drag на медленном hardware (mousemove может fire'ить >60Hz).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:22:52 +03:00
Дмитрий 97da018724 feat(graph): resize handle 300-900px + localStorage
Drag-handle 6px на левой границе #legend-panel (cursor:col-resize, hover-bg #0d4a5a), JS-обработчики mousedown/mousemove/mouseup, clamp [300, 900]px, сохранение ширины в localStorage ключ liderra-map-legend-width, restoration on DOMContentLoaded. После каждого resize вызывается network.redraw() для пересчёта vis.js canvas. Iter2 spec §2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:17:08 +03:00
Дмитрий abaeebbde6 docs(plan): automation-graph iter2 — 10 atomic tasks (7 parallel-safe + 3 sequential)
Tasks 1-7 (parallel-safe через dispatching-parallel-agents): T1 resize handle CSS+JS+localStorage, T2-T5 text rewrite groups A/B/C/D (9+21+18+25=73 nodes по Style Guide), T6 CONFLICT_TYPES enum + 2 new  edges + reclassify 6, T7 EDGE_DETAILS data (74 entries). Tasks 8-10 (sequential): T8 edge legend render + click handler (depends T7), T9 3-color render + sort + footer (depends T6), T10 visual smoke + push.

Test strategy для single-file HTML без unit-tests: 3-уровневая verification (Level 1 — Node.js syntax check per Edit, Level 2 — lefthook pre-commit gauntlet per commit, Level 3 — manual visual smoke в Edge browser). Pre-push: gitleaks full-history + lychee. Self-review pass: spec coverage 100%, no placeholders, no type drift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:12:33 +03:00
Дмитрий c18cc93c78 chore(cspell): +2 words for automation-graph iter2 plan
qitem + skreview — фрагменты идентификаторов узлов карты (sk_qitem, ag_skreview); cspell токенизирует по `_` и видит их как unknown words. Упомянуты в plan-файле как ссылки на task'и/инструменты. Iter2 plan reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 12:12:21 +03:00
Дмитрий f936944237 docs(spec): automation-graph iter2 — resize + simple-language + 3-color conflicts + edge legend
4 improvements after iter1 ride-out: drag-handle resize 300-900px + localStorage; rewrite 73 nodes to plain language with Style Guide; reclassify 8 conflicts as 🔴 not-closed /  practice-observed / 🟢 closed-by-rule; new 7-field edge legend (source/target/relation-type/trigger/transfers/mandatory/regulation).

Parallel execution strategy: Phase 2 dispatches 6 subagents (P1 resize, P2-P5 text rewrite by category, P6 conflict types + EDGE_DETAILS) returning raw JS blocks; Phase 3 main agent applies 12 atomic Edits in sequence → 11 atomic commits total.

Through superpowers:brainstorming 4-clarifying-question cycle (text scope / conflict classification / resize UX / edge legend fields), all options chosen by Дмитрий explicitly. Self-review pass; no placeholders, no contradictions, 0 open questions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:29:40 +03:00
Дмитрий 8e75951edc chore(cspell): +3 words for automation-graph iter2 spec
зарелизен + отрефакторен (русифицированные tech-термины «released» / «refactored»; используются в iter2 spec §0 Context); cdesc (CSS class из docs/automation-graph.html .conflict-item .cdesc, ссылка в iter2 spec §4.3 renderer changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:29:28 +03:00
Дмитрий b73ddaaedd docs(a11y): authenticated rescan baseline + findings (21/21 passing)
Final state docs after a11y rescan session:

- docs/audit-baseline-pa11y.md: «Authenticated rescan 2026-05-14» section
  added (14 new URLs, all 21 passing). Old «out of scope для первой
  baseline» section marked SUPERSEDED. Per-pattern fix table with file
  references + ignored selector rationale. axe-core cross-validation
  results documented (only DevIndexBadge dev-only remains).

- docs/superpowers/audits/2026-05-14-a11y-rescan-findings.md (new):
  Full audit findings doc — TL;DR, scope expansion table, per-pattern
  root cause + fix sections (A-H), axe-core cross-validation, метрики
  до/после, verdict 🟢 GREEN.

Regression sweep:
- Pa11y: 21/21 URLs passed
- Vitest: 91 files / 736 passed / 3 skipped / 0 failed
- Pest --parallel: 742/739/3sk/0
- Vite build: ~2s
- gitleaks: 0 leaks / 457 commits / 12.72 MB
- lychee: 345 OK / 0 errors / 457 total
- markdownlint: 0 errors (after auto-fix)
- cspell: 0 issues

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:08:08 +03:00
Дмитрий e39a42cfdf fix(a11y): admin search inputs — add label prop for accessible name (Pattern H)
A11y rescan Pattern H — Vuetify <v-text-field> без `label` prop рендерит
empty `<label id="input-v-NN-label">` (referenced via aria-labelledby).
Pa11y/axe видит unlabelled input на /admin/billing (search «Поиск по
названию или ИНН») и /admin/system (search «Поиск по ключу или описанию»).

Initial naive fix добавил `aria-label="..."` — но ARIA priority говорит
aria-labelledby overrides aria-label, поэтому осталось violation.

Final fix: add `label="Поиск"` prop on VTextField. Vuetify рендерит
floating label с правильным accessible text → axe-core resolves через
aria-labelledby chain successfully. Placeholder сохранён (split: «Поиск»
теперь в label, «по названию или ИНН» / «по ключу или описанию» —
placeholder).

Files:
- AdminBillingView.vue:209-217
- AdminSystemView.vue:130-138

Closes Pa11y «label» violations на 2 admin URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:07:48 +03:00
Дмитрий 398f6bcf5a fix(a11y): Vuetify tonal alert/chip + text-warning contrast overrides (Patterns C+D+E)
A11y rescan Patterns C+D+E — Vuetify default theme colours для tonal-variant
.v-alert .v-alert__content (4.18:1) и tonal .v-chip__content (success 4.25:1
/ warning 2.25:1), плюс `.text-warning` utility used в count badges (2.03:1
на ivory) — все ниже WCAG 2.1 AA 4.5:1.

Global CSS overrides in app/resources/css/app.css:

Pattern C — alert tonal content (2 URLs: billing, admin/system):
  .v-alert--variant-tonal .v-alert__content {
      color: #0a0700;   /* near-black, 16:1 on ivory */
  }

Pattern D — chip tonal success/warning content (4 URLs: billing,
admin/{tenants,billing,incidents,system}):
  .v-chip--variant-tonal.bg-success .v-chip__content { color: #1f5e3a }
  .v-chip--variant-tonal.bg-warning .v-chip__content { color: #6a4504 }

Pattern E — .text-warning utility (2 URLs: admin/billing «5», admin/incidents
«1»). Critical specificity fix: Vuetify defines selector as
`.v-theme--liderraForest .text-warning { color: rgb(var(--v-theme-warning))
!important }` (specificity 0,2,0 + !important). Naive `.text-warning
!important` (0,1,0) loses on specificity even with !important. Match Vuetify
selector exactly so override wins on cascade order (loaded after Vuetify CSS):

  .v-theme--liderraForest .text-warning,
  .v-theme--liderraForest.text-warning,
  .text-warning {
      color: #6a4504 !important;
  }

Closes 4+11+2 = 17 color-contrast violations across 5 distinct URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:07:35 +03:00
Дмитрий 6387706be6 fix(a11y): .sep dot separator contrast 2.92:1 → 5.33:1 (Pattern B)
A11y rescan Pattern B — scoped CSS `.sep { color: #92907b; }` повторяется
в 8 компонентах (page-stats / page-meta / hero-meta containers с точкой-
разделителем `·`). На ivory page background #f6f3ec даёт contrast
2.92:1, ниже WCAG 2.1 AA 4.5:1 threshold.

Fix: #92907b → #6b6356 — same warm-grey hue, darker tone, gives
5.33:1 contrast. 8 files:

- views/{DealsView,BillingView,KanbanView,ReportsView}.vue
- components/dashboard/DashboardPageHead.vue
- components/deals/DealDetailHero.vue
- components/admin/tenants/TenantsStatsHeader.vue
- components/admin/tenant-detail/TenantDetailHeader.vue

Closes Pa11y «color-contrast» violations на /dashboard /billing /reports
(8 .sep elements total flagged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:07:11 +03:00
Дмитрий 667befde96 fix(a11y): add aria-label to mobile nav-icon button (closes Pattern A)
A11y rescan Pattern A — Vuetify <v-app-bar-nav-icon class="d-md-none">
без accessible name. Pa11y/axe видит button в DOM даже на desktop где
он hidden via CSS — флагает «button-name» violation на 9 AppLayout views
(/dashboard, /deals, /kanban, /projects, /billing, /settings, /reports,
/reminders, /admin/tenants).

Fix: AppTopbar.vue:90-94 — `aria-label="Открыть меню навигации"`.

Closes 9 of 14 authenticated routes' a11y violations (down 14→5 affected
URLs after this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:06:52 +03:00
Дмитрий 35387e8b17 feat(a11y): extend Pa11y scope to 14 authenticated routes + Vuetify hideElements
pa11y.config.json теперь covers 21 URLs (7 guest + 14 authenticated).

Authenticated URLs использует per-URL actions login flow:
1. navigate to /login
2. fill input[autocomplete="email"] = admin@demo.local (DemoSeeder)
3. fill input[autocomplete="current-password"] = password
4. click button[type="submit"]
5. wait for path /dashboard
6. navigate to target URL + wait path

14 routes added: /dashboard, /deals, /kanban, /projects, /billing, /settings,
/reports, /reminders, /admin/{tenants,billing,incidents,system,pricing-tiers,
supplier-prices}.

hideElements extended:
- select[hidden] — Vuetify VSelect рендерит hidden native <select> для
  form-submission compatibility (не visible UX, screenreader skip).
- input[aria-controls^="menu-v-"] — Vuetify VDataTable items-per-page
  combobox с aria-labelledby chain issue (Vuetify-internal pattern).

timeout 30000 → 60000ms, wait 1500 → 2000ms — accommodate Vue SPA async
hydration после login flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:06:40 +03:00
Дмитрий a650484b11 docs(plan): A11y rescan — live portal authenticated routes
11-task plan для повторного a11y-аудита: extend Pa11y от 7 guest URLs к 21
URLs (включая 14 authenticated через per-URL actions login flow), + axe-core
cross-validation via Playwright MCP, + inline fixes для real prod findings.

Closes Audit #3 Phase 7 «authenticated pages out of scope» clause per
explicit user request «Pa11y был настроен на старые HTML-эскизы, проведи
повторно аудит в этой части, чтобы он проверил реальный портал».

Plan: docs/superpowers/plans/2026-05-14-a11y-rescan-live-portal.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 10:06:19 +03:00
Дмитрий 54ee37c54e feat(graph): physics off by default + buttons restore radial layout + smooth continuous 2026-05-14 09:23:46 +03:00
Дмитрий d75b3b85d3 feat(graph): radial-sector layout — 6 колец × 4 сектора (workflow/UI/infra/data) 2026-05-14 09:22:45 +03:00
Дмитрий 0da8dbf042 feat(graph): when+limits content for memory files (15) — all 73 nodes done 2026-05-14 09:20:54 +03:00
Дмитрий a19bee28be feat(graph): when+limits content for lefthook jobs (10) 2026-05-14 09:19:38 +03:00
Дмитрий 0634426c30 feat(graph): when+limits content for MCP servers (7) 2026-05-14 09:18:45 +03:00
Дмитрий ee958f884a feat(graph): when+limits content for hooks (5) + agents (11) 2026-05-14 09:17:37 +03:00
Дмитрий 2b38e7be32 feat(graph): when+limits content for skills (14 SP + 2 проектных) 2026-05-14 09:16:02 +03:00
Дмитрий 413803e569 feat(graph): when+limits content for rules + plugins (9 nodes) 2026-05-14 09:14:10 +03:00
Дмитрий 1a7cd90c32 feat(graph): nd() helper supports when+limits fields; showLegend renders them 2026-05-14 09:10:57 +03:00
Дмитрий 40b437ccb7 feat(graph): legend panel — add «Когда используется» and «Ограничения» sections 2026-05-14 09:10:15 +03:00
Дмитрий aa258e1ad0 fix(graph): remove edge labels from canvas, move to hover tooltips 2026-05-14 09:09:42 +03:00
Дмитрий 5c2556b73f fix(graph): canvas rendering artifacts — explicit canvas bg + remove hideEdgesOnDrag 2026-05-14 09:09:01 +03:00
Дмитрий e3974482a9 docs(plan): automation-graph refactor — 10 atomic tasks
Implementation plan для spec 2026-05-14-automation-graph-refactor-design.md.
10 tasks, каждый = 1 коммит, в порядке:
1. canvas rendering fix
2. edge labels → tooltips
3. HTML legend sections (когда + ограничения)
4. nd() helper signature + render
5a-5f. when+limits content для 73 узлов (rules+plugins / skills / hooks+agents / MCP / lefthook / memory)
6. radial-sector positioning (ring + sectorAngle на 73 NODES + pos() helper)
7. physics off + button handlers + smooth continuous
8. final smoke + data integrity check

Self-review: spec coverage , no placeholders , type consistency ,
backward-compat nd() handler в Task 4 (for intermediate state).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 09:05:21 +03:00
Дмитрий b747880ddc docs(spec): automation-graph refactor — 4 fixes (фон / подписи / радиальная иерархия / when+limits)
Дизайн рефакторинга docs/automation-graph.html после визуальной проверки
коммита 7ee78a9:
- canvas background на самом canvas + удаление hideEdgesOnDrag (artifacts)
- удаление labels с edges, переход на title-tooltip + legend section
- radial-sector layout: 6 колец × 4 функциональных сектора, physics off
- 2 новые секции легенды: «Когда используется» + «Ограничения»

cspell: +mgmt (валидный идентификатор узла claude_md_mgmt)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 08:55:32 +03:00
Дмитрий ae20033652 docs(claude.md): v1.92 → v1.93 — sync schema header drift 62→63 (Audit #3 P2)
Tail closure of Audit #3 P2 «schema.sql header drift» finding. Schema
source-of-truth was already updated in commit e746b3c (db/schema.sql:4
header «62 базовые таблицы» → «63 (61 regular + 2 partitioned parents:
deals + supplier_lead_costs)»). This commit syncs three CLAUDE.md
references to match.

Touch points:
- Header version 1.92 → 1.93 + description of session
- §0 «Источник истины» row «Схема БД» — 62 → 63 baseline
- §2 «Стек проекта» БД row — 62 → 63 baseline
- §8 self-review triggers row `db/schema.sql` — 62 → 63 baseline
- §9 history — new v1.93 entry summarising 5-commit sprint
  (8ba9c55..c524227), closure tally (1 P1 + 7 P2 + 4 P3), and regression
  check (Pest 742/739/3sk/0, Vitest 91f/736/3sk/0, gitleaks 0/442,
  lychee 325/0).

Via `/claude-md-management:claude-md-improver` per CLAUDE.md §5 п.10
(only sanctioned channel for direct CLAUDE.md edits).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:47:42 +03:00
Дмитрий c5242271d7 chore(p3): close P3 tooling and structural mini-fixes
Closes Audit #3 P3 batch.

Changes:

1. **knip.config.ts cleanup** — remove 4 stale config hints flagged in
   Audit #3 Phase 1B (`ignore: tests/**` redundant since `project` is
   `resources/js/**`; `ignoreDependencies` for vitest/@vue/test-utils/jsdom
   redundant since knip auto-detects test frameworks). Add `histoire.config.ts`
   + `resources/js/histoire.setup.ts` to entry — closes 2 documented FPs
   (histoire.setup.ts + @histoire/plugin-vue unused-flag). Verified:
   `npx knip` exits 0 clean.

2. **Admin table actions column header label** — change `title: ''` →
   `title: 'Действия'` in:
   - TenantsTable.vue (actions column, /admin/tenants)
   - AdminSupplierPricesView.vue (actions column, /admin/supplier-prices)
   Closes axe-core `empty-table-header` violation seen in Audit #3 Phase 7
   on /admin/tenants. Header is now visible in UI (better UX than sr-only
   sleight-of-hand).

3. **npm overrides for lodash** in `package.json` — pin `pa11y-ci > lodash`
   to ^4.17.21. Verified: `npm ls lodash` resolves to lodash@4.17.23 (latest
   4.x; CVE-2021-23337 + GHSA-f23m patched in <4.17.21, our version is above
   that). npm audit may still surface advisory ranges as informational.

4. **Decision doc for pgFormatter (Q.HARD.002)** — explicit FIX-DEFER with
   3-hypothesis comparison (Strawberry Perl install vs sqlfluff replacement
   vs Docker pg_format vs drop SQL formatting). Decision: drop automated
   SQL formatting until Б-1 closure; squawk (linter) covers correctness.
   Addendum: axe-core .v-overlay-container region landmark — no permanent
   axe-core test setup exists, so no whitelist needed at this point.

Verification:
- knip: 0 issues
- vue-tsc: 0 errors
- ESLint: 0 errors
- Vitest: 91 files / 736 passed / 3 skipped (no regressions)
- Vite build: 2.03s

Plan: docs/superpowers/plans/2026-05-14-audit3-deferred-fixes.md Task 4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:38:51 +03:00
Дмитрий c5c0e76950 test(coverage): close F-COV-01/02/03 — ReminderDialog + AdminLayout + api/admin
Closes Audit #2+#3 P2 carryforward triplet (low-coverage files at risk
of silent regression).

Coverage results (Vitest --coverage --coverage.include per-file):

| File | Stmts before | Stmts now | Δ |
|---|---|---|---|
| ReminderDialog.vue | 0% | 95.38% | +95 pp |
| AdminLayout.vue | 9.09% | 95.45% | +86 pp |
| api/admin.ts | 11.53% | 100% | +88 pp |

Branches/Funcs deltas (subagent reports):
- ReminderDialog: Branch 0→97.56%, Funcs 0→85.71%, Lines 0→96.61%
- AdminLayout: Branch 0→90%, Funcs 0→90%, Lines 9.09→94.73%
- api/admin: Branch 0→100%, Funcs 27.27→100%, Lines 11.53→100%

Approach: TDD via @vue/test-utils + Vuetify global plugin + vi.mock for
store/api. Three parallel subagents (general-purpose), each focused on
single target — no production code changes, only test infrastructure.

Coverage areas:
- ReminderDialog (19 specs): rendering, watch(dialogOpen) populate/reset,
  submit create-mode happy + 3 errors, submit edit-mode happy + 1 error,
  cancel, common validation paths
- AdminLayout (16 specs): brand block, 5 nav items, count badges (142/3),
  breadcrumb per route (5 cases + fallback), userInitials computed (4
  cases incl. fallback), userShortName (4 cases), handleLogout call-order,
  active state, aria-label
- api/admin (18 specs): 11 exported functions × happy-path; 2 encodeURI
  edge cases; 4 ensureCsrfCookie call-order verifications via
  invocationCallOrder; 2 error-propagation tests

Verification (full sweep after merge):
- Vitest: 91 files / 736 passed / 3 skipped / 0 failed (+3 files, +53 specs
  from Audit #3 baseline 88/683/3sk)
- Pest --parallel: 742/739/3sk/0 (identical to baseline, 0 regressions)
- Vite build: 2.03s
- vue-tsc: 0 errors
- ESLint: 0 errors

Plan: docs/superpowers/plans/2026-05-14-audit3-deferred-fixes.md Task 3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:37:26 +03:00
Дмитрий e746b3c9a4 chore(cleanup): dead code removal + DemoSeeder env-conditional + schema header drift
Closes Audit #3 P2 batch (knip dead exports/components, DemoSeeder
hygiene, schema header drift).

- Remove app/resources/js/views/admin/AdminPlaceholderView.vue
  (unreferenced placeholder view — confirmed via repo-wide grep, only
  doc references remain)
- npm uninstall concurrently (no script invoked it; --legacy-peer-deps
  for Histoire 1.0-beta.1 peerDep quirk)
- 12 unused exports → internal types (remove `export` keyword):
  - api/admin.ts: AdminTenantsStats, ApiTenantMetrics,
    ApiAdminBillingSummary, ApiAdminIncidentsSummary
  - api/notifications.ts: NotificationEvent
  - api/reports.ts: ApiReportType, ApiReportFormat, ApiReportParameters,
    ReportCounts, ReportQuota
  - composables/mockBilling.ts: TxType
  - composables/useStatusPill.ts: StatusPillSlug
  All 12 are used INSIDE their own file (response shapes), just not
  exported externally — converting to internal types satisfies knip
  without losing type-checking inside the file.
- DatabaseSeeder::run() — DemoSeeder runs only in local+testing envs
  (`migrate:fresh --seed` in dev now produces demo tenant + admin@demo.local
  + 3 projects + ~14 demo deals; prod environments skip)
- db/schema.sql header line 4: «62 базовые таблицы» → «63 базовые
  таблицы (61 regular + 2 partitioned parents: deals + supplier_lead_costs)»
  Closes schema header drift finding from Phase 3.

Verification:
- vue-tsc --noEmit: 0 errors
- ESLint on touched files: 0 errors
- Pest --parallel: 742/739/3sk/0 failed (identical to baseline, no regressions)
- 2243 assertions / 34.46s

Plan: docs/superpowers/plans/2026-05-14-audit3-deferred-fixes.md Task 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:28:44 +03:00
Дмитрий 0c36b7a28d feat(a11y): migrate Pa11y scope from handoff prototypes to live Vue app
Closes Audit #3 sole P1 (F-A11Y-PA11Y-SCOPE-01).

Pa11y was scanning handoff HTML prototypes from liderra_v8_handoff/concepts/
(3 URLs, ~10 contrast violations), NOT the live Vue app. Audit #2 baseline
"0 errors" was inaccurate — real portal was never covered.

Changes:
- pa11y.config.json: now targets http://localhost:8000/<route> for 7 guest
  pages (login, register, forgot, 2fa, recovery, 403, 500)
- pa11y-handoff.config.json: preserves historical handoff baseline as
  opt-in (`npm run a11y:handoff`)
- package.json: new `a11y:handoff` script; `a11y` repointed to live target
- RecoveryCodesView.vue: scoped CSS override fixes Vuetify warning-tonal
  alert content contrast (2.03:1 → ≥4.5:1, color #0a0700 per Pa11y rec)
- .github/workflows/a11y.yml: new CI job with dev-server lifecycle
  (php artisan serve + curl wait-on + Pa11y + screenshot artifact upload)
- docs/audit-baseline-pa11y.md: first live baseline document with per-URL
  status, ignore selectors rationale, re-run instructions

Local verification:
- npm run a11y: 7/7 URLs passed (0 violations)
- vue-tsc: 0 errors
- ESLint: 0 errors
- Vitest: 88 files / 683 passed / 3 skipped / 0 failed (no regressions)

Plan: docs/superpowers/plans/2026-05-14-audit3-deferred-fixes.md Task 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:25:14 +03:00
Дмитрий 8ba9c55724 docs(plan): Audit #3 deferred fixes sprint plan
25 deferred findings (1 P1 + 11 P2 + 14 P3) → 4 task batches:

1. P1 Pa11y scope migration to live Vue app
2. P2 dead code + dev hygiene (knip findings + DemoSeeder + schema header)
3. P2 coverage debt (ReminderDialog + AdminLayout + api/admin via TDD)
4. P3 tooling + structural mini-fixes

Plan: docs/superpowers/plans/2026-05-14-audit3-deferred-fixes.md
Source audit: docs/superpowers/audits/2026-05-14-portal-full-audit-report.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:24:49 +03:00
Дмитрий f9d2452386 docs(audit): finalize portal full audit #3 — report (2026-05-14) 2026-05-14 07:52:27 +03:00
Дмитрий 301334c288 docs(audit): Phase 14 final regression (audit #3) 2026-05-14 07:46:56 +03:00
Дмитрий abb8a5135e docs(audit): Phase 13 categorization + fix decisions (audit #3)
Final audit rollup: 0 P0 / 1 P1 / 11 P2 / 14 P3 (26 total).

Pa11y P1 decision: FIX-DEFER with concrete migration plan
(6 acceptance criteria + 60-120 min estimate). Decision driven by
3-hypothesis analysis: (1) config-only swap surfaces new live-app
violations (color-contrast on DevIndexBadge, region landmarks),
(2) additive both-kept keeps handoff failures blocking CI,
(3) deferred migration with proper sprint task is cleanest path.
Both decision-matrix triggers from brief apply: risk of new
failures without follow-up plan + new CI infra requirement
(live dev server lifecycle).

Carryforward audit: 9 items still open from Audit #2 (all
P2/P3, no regressions). 11 Audit #2 items verified closed in
this audit (bf84568 aria fix, CTO-19 Lucide, Q.DEFER.001-004,
quirks #62/#72/#80, cron, RUNBOOK.md).

FIX-NOW this session: 0 commits (Pa11y deferred per matrix).
FIX-NOW earlier in audit: 1 commit (823da29 cspell inline).
FIX-DEFER documented: 25.
BLOCKED: 0.

Verdict: GREEN — 0 P0, sole P1 is methodology audit-fidelity gap
(Pa11y declared but not exercised against live code); axe-core
via Playwright in Phase 7 provides actual a11y coverage with 0
real prod issues against DevIndexBadge temp feature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 07:38:25 +03:00
Дмитрий 4b4705295c docs(audit): Phase 10-12 pre-prod/TODO/untracked findings (audit #3) 2026-05-14 07:34:35 +03:00
Дмитрий 9d27783729 docs: commit untracked plan files + parse-bundle-analyze.mjs (audit #3) 2026-05-14 07:29:47 +03:00
Дмитрий 51664a0aa4 docs(audit): Phase 9 bundle analyzer delta (audit #3) 2026-05-14 07:27:36 +03:00
Дмитрий ad89473331 docs(audit): Phase 8 coverage targeted (audit #3) 2026-05-14 07:24:12 +03:00
Дмитрий 8fa545e113 docs(audit): Phase 7 a11y targeted Pa11y+axe-core (audit #3) 2026-05-14 07:20:49 +03:00
Дмитрий 8ec7a8c116 docs(audit): Phase 6 cross-doc integrity findings (audit #3) 2026-05-14 07:14:59 +03:00
Дмитрий 1f43beacc3 docs(audit): Phase 5 UI smoke 22-view Playwright sweep (audit #3) 2026-05-14 07:12:48 +03:00
Дмитрий 9e2914a72d docs(audit): Phase 4 security findings (audit #3)
CI workflows: 3 (sast/dependency-check/trivy), unchanged from Audit #2.
gitleaks delta (9e175a1..HEAD): 0 leaks / 18 commits.
gitleaks full history: 0 leaks / 426 commits.
gitleaks no-git app/: 1847 matches all in gitignored vendor/ +
phpstan-cache; P2: GITHUB_TOKEN env var captured in gitignored
nette DI container cache (not in git history, mitigations in place);
P3: generic-api-key FPs in phpstan.phar / cache suggest gitleaks.toml.
cspell-words.txt +3: nette, phar, serialises.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 06:29:31 +03:00
Дмитрий 93a3c667e0 docs(audit): Phase 3 schema integrity findings (audit #3)
Query results A-G: root_tables=63 (61r+2p), partitions=12,
indexes=289, RLS=39, functions=5 (correct names), triggers=13
logical/19 total, orphan_FK=0. One P2 finding: schema.sql v8.20
header "62 базовые таблицы" drift → actual 63 (deals +
supplier_lead_costs both partitioned parents). All invariants
RLS/functions/orphan-FK pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 06:23:25 +03:00
Дмитрий af97885266 docs(audit): Phase 2 test suite findings (audit #3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 06:19:50 +03:00
Дмитрий 4a5ecb085a docs(audit): Phase 1D SQL static analysis + Phase 1 итог (audit #3)
squawk v2.51.0 — 0 issues (bin\squawk.exe db/schema.sql, exit 0).
pgFormatter — N/A (perl not in PATH, known Q.HARD.002 carryforward).
Phase 1 combined итог: P0=0 P1=0 P2=4 P3=2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 06:13:04 +03:00
Дмитрий 823da293de docs(audit): Phase 1C docs static analysis findings + cspell words (audit #3)
markdownlint=0, cspell=0 (+3 words: shapkas/SUT/SUT's), lychee=318 OK/0 errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 06:09:29 +03:00
Дмитрий 362af8c981 docs(audit): Phase 1B frontend static analysis findings (audit #3) 2026-05-14 06:06:54 +03:00
Дмитрий 85d79499e9 docs(audit): Phase 0 addendum + Phase 1A backend static analysis (audit #3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 06:02:45 +03:00
Дмитрий 07a483333c docs(audit): Phase 0 pre-flight skeletons (audit #3) 2026-05-14 05:59:55 +03:00
Дмитрий 08605cf640 fix(tests): Bus::fake partial + session mock — close quirk #72
CsvReconcileJobTest used Bus::fake() (all jobs), silencing dispatch_sync of
RefreshSupplierSessionJob when a parallel afterEach wiped supplier:session.
Now: Bus::fake([RouteSupplierLeadJob::class]) + anonymous mock that re-puts
the session in handle(), making race-window recovery deterministic.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 05:35:06 +03:00
Дмитрий 9a45346205 fix(tests): RefreshDatabase on LookupsTest + ProjectExtensionsTest — close quirk #62
DatabaseTransactions did not prevent cross-session data accumulation in
liderra_testing; count assertions drifted (1465 managers, 519 projects).
RefreshDatabase runs migrate:fresh once per session (RefreshDatabaseState::migrated)
so stale data is wiped at start of each composer test run.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 05:29:34 +03:00
Дмитрий 7ee78a9ad0 feat(docs): interactive automation graph — 73 nodes, 6 conflicts, Solarized dark vis.js
Single-file HTML visualization of Лидерра CRM automation system.
vis.js 9.1.9 force-directed graph: 9 color groups (rules/plugins/skills/hooks/
agents/MCP/lefthook/memory), 6 red dashed conflict edges, click-to-legend panel
with 5 sections (что делает / кому подчиняется / кто / одновременно / конфликты),
search + freeze/unfreeze/reset/clear toolbar. Solarized dark theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:05:59 +03:00
Дмитрий 9b21bbc1fd docs(spec): automation graph design spec — vis.js Solarized dark, 72 nodes, 6 conflicts 2026-05-13 16:43:13 +03:00
Дмитрий 7007379b40 docs(plans): add test-quality-preprod sprint plan + fix lychee/cspell
Sprint plan B.1/B.2/B.3/A.1/A.2/A.3. Fixes: broken ../../../memory/
link → plain text; cspell-words.txt +аутит (Russian IT verb).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:41:59 +03:00
Дмитрий bf84568837 fix(a11y): add aria-label to VTooltip on /admin/tenants impersonate btn
Audit #2 Phase 10.2 P2: axe-core 4.10 reported aria-tooltip-name
violation — <div role="tooltip"> had no accessible name. Adding
aria-label to <v-tooltip> passes it through to the rendered overlay.
Verified: axe-core on /admin/tenants — 0 tooltip violations post-fix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:38:21 +03:00
Дмитрий b241c79773 docs: add RUNBOOK.md — production deployment runbook
Audit #2 Phase 14 P2 fix. Covers: system requirements, DB setup
(ICU collation + roles + migrations + grants), partition bootstrap,
frontend build, Supervisor queue config, cron scheduler, Nginx,
health checks, rolling update sequence, rollback, dev seed,
common issues. cspell-words.txt +mbstring +pcntl (PHP ext names).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:35:19 +03:00
Дмитрий 9530d17981 fix(schedule): register partitions:create-months as daily cron
Audit #2 Phase 14 P2: partition tables were not auto-created.
Without this entry the scheduler never called partitions:create-months,
causing partition exhaustion on the first day of each new month.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:32:56 +03:00
Дмитрий 219f262655 fix(test): ProjectFactory unique name + test:parallel composer alias
fake()->unique()->words(3,true) fixes quirk #77 deterministic collision
on projects(tenant_id,name) UNIQUE in --parallel runs.
test:parallel alias = pest --parallel --recreate-databases (quirk #62/#73).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:32:00 +03:00
Дмитрий e280edd431 style(frontend): apply prettier --write — fix formatting drift
4 files reformatted (import list expansion, line-length wrapping).
Vitest 88/683+3sk green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 13:30:51 +03:00
Дмитрий 58986a2d74 test(vitest): add testTimeout: 10000 — fix quirk #80 router.spec.ts coverage timeout
v8 coverage instrumentation adds ~10x overhead to router-guard async tests,
pushing past the 5000ms default. Audit #2 Phase 13 finding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 12:48:40 +03:00
Дмитрий 9e175a1fd6 docs(audit): Phase 10.2 axe-core + Q.DEFER.001+002 closure — audit #2 follow-up
axe-core 4.10 на 16 auth views: P2=1 (aria-tooltip-name VTooltip /admin/tenants),
P3=4 кат. (region sitewide, DevIndexBadge temp, empty-table-header 2 views,
page-has-heading-one 1 view). P0/P1=0.

Q.DEFER.001 (Phase 5 24-view smoke) + Q.DEFER.002 (axe-core 16 auth) оба CLOSED.
blocked.md + report.md обновлены. Verdict 🟡 YELLOW, 0 открытых Q-items.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:52:59 +03:00
Дмитрий ec0dd00a93 docs(audit): Phase 5 full 24-view smoke — Q.DEFER.001 closure (audit #2 follow-up)
Playwright MCP iteration по 24 URL (auth + main + admin + 404).
Login/logout flow verified. CTO-19 Lucide icons confirmed holding.
25 screenshots в audit-screens/2026-05-13/. 0 реальных дефектов.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:41:11 +03:00
Дмитрий 43f9c257bc docs(audit): finalize portal full audit #2 — Phase 7-9 + report (2026-05-13)
Phase 7 — Categorize: severity rollup 37 findings (P0=0 / P1=5 / P2=14 / P3=18).
  vs 12.05 baseline (P0=1 / P1=47 / P2=339 / P3=6) — massive improvement.

Phase 8 — Fix loop SKIPPED per hybrid: 0 P0 + 5 P1 все FIX-DEFER known quirks
  (квирки 62/72 + router coverage timeout), не FIX-NOW eligible. 0 atomic
  fix-commits в этой session.

Phase 9 — Final regression: 0 regressions vs Phase 2 baseline (742/738/1/3 Pest,
  88/683/3 Vitest, 35/63 Histoire, 2.15s Vite). Все baseline metrics preserved.

Report.md filled: TL;DR + Phase summaries + метрики до/после + verdict 🟡 YELLOW
+ commits + 3 new quirks (78 branch contention, 79 CWD double-cd, 80 vitest
coverage v8 timeout).

Q-items: Q.DEFER.001 (Phase 5 full smoke) + Q.DEFER.002 (Phase 10 axe auth) deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:13:44 +03:00
Дмитрий 845477603a docs(audit): Phase 10+11+12+13+14 findings batch (audit #2)
Phase 10 — Pa11y 4 guest URLs:  all clean.
Phase 11 — TODO sweep: 19 matches (stable vs 12.05).
Phase 12 — Bundle: critical-path ~189 kB gzip, +25 kB drift vs 12.05.
Phase 13 — Coverage: 78.30/75.78/70.12/80.47. P1 router.spec.ts timeouts под coverage.
Phase 14 — Pre-prod 🟡: P2 Sentry prod SDK missing, partitions cron not registered, runbook отсутствует.

cspell-words.txt: +«редиректится».

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 11:11:40 +03:00
Дмитрий 31f804581d docs(audit): Phase 6 cross-doc integrity findings (audit #2)
7 normative docs version match (factual vs memory):
- CLAUDE.md v1.92 , Pravila v1.13 , PSR_v1 v2.1 , Tooling v1.17 
- Реестр v1.83 , schema.sql v8.20 , README_АРХИВ v8.5 

routes/web.php: 26 explicit Route::view + Route::fallback — комплектен. 12.05 finding /projects+/reminders+/admin/* missing — fixed `b9038bc`. /admin top-level index new.

Severity Phase 6: P0=0 / P1=0 / P2=0 / P3=0.  vs 12.05 baseline (5 P2 drift) — параллельная сессия PR #4 sync'нула все версии.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:42:07 +03:00
Дмитрий e81b9c45b4 docs(audit): Phase 5 UI smoke (focused) + Q.DEFER blocked entries (audit #2)
Phase 5 reduced scope (transparent): 17 routes HTTP 200  + CTO-19 Lucide structural verification (vuetify.ts:19 import + prod bundle inclusion). Indirect coverage via Vitest 88/683 + Histoire 35/63 + Vite build (Phase 2).

Not covered этой session: Playwright MCP interactive flows для 24 views.

Q.DEFER entries → blocked.md:
- Q.DEFER.001: Phase 5 full 24-view Playwright smoke deferred.
- Q.DEFER.002: Phase 10 axe-core 16 auth views deferred.

Severity Phase 5: P0=0 / P1=0 / P2=0 / P3=1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:40:42 +03:00
Дмитрий 17d530f669 docs(audit): Phase 4 security findings (audit #2)
- 4.1 CI workflows enum (methodology gap closure per Pravila v1.12 §4.6): 3 active (dependency-check.yml + sast.yml + trivy.yml). Semgrep SAST confirmed deployed: p/php + p/javascript + p/typescript + p/secrets, SARIF upload to GitHub Security tab. Q.INFO.001 12.05 closure verified holding.
- 4.2 Gitleaks full history: 401 commits / 12.11 MB / 0 leaks . vs 12.05 (333/11.14) — +68 commits, still clean.
- 4.3 Composer audit cross-link: 0 advisories.
- 4.4 Production secrets grep: 0 AWS prefix, 0 Stripe prefix в app/.

Severity Phase 4: P0=0 / P1=0 / P2=0 / P3=0 — fully clean.

CI security stack полный: SAST + dependency-check + Trivy = pre-prod readiness baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:37:30 +03:00
Дмитрий 75dc375da3 docs(audit): Phase 3 schema integrity findings (audit #2)
Boost MCP queries к dev liderra:
- Root tables: 61 (vs schema.sql v8.20 header 62; vs CLAUDE.md memory dev-actual 75 stale).
- Partition children: 12 (vs header 12 ; vs memory 102 stale — после migrate:fresh).
- Indexes: 289 (vs header 117 stale; vs memory 289 ).
- RLS policies: 39  exact match.
- User functions: 5  exact by name (audit_block_mutation, audit_chain_hash, calc_lead_score, report_jobs_log_export, set_pd_subject_request_deadline).
- Triggers: 19 (vs header 13 stale; vs memory 19 ).
- DB roles 0 by design (dev).
- Orphan FK: 0 .

Severity Phase 3: P0=0 / P1=0 / P2=2 (schema.sql header drift + CLAUDE.md/memory partition drift after migrate:fresh) / P3=0.

Structural integrity 100%, drift только в documentation accuracy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:35:57 +03:00
Дмитрий 22d8613578 docs(audit): Phase 2 test suite findings (audit #2)
- Pest sequential: 742/736/3/3 (квирк 62 cumulative state — 3 expected fails LookupsTest×2 + ProjectExtensionsTest, numbers ↑ vs 12.05: 1465/12176 — больше накопления).
- Pest --parallel --recreate-databases: 742/738/1/3 — 1 sporadic regression vs 12.05 baseline 739/0/3: CsvReconcileJobTest квирк 72 (Redis supplier:session race в parallel subdir-only).
- Vitest: 88f/683/3  exact match baseline.
- Histoire: 35/63  match.
- Vite build: 2.15s  faster than baseline. P2 bundle drift app-B-3WRbXK.js +21 kB raw.

Severity Phase 2: P0=0 / P1=4 (all FIX-DEFER known quirks) / P2=1 / P3=1.

cspell-words.txt: +«квирков» (валидная gen-plural форма).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:34:01 +03:00
Дмитрий 51440f4e6d docs(audit): Phase 1 static analysis findings (audit #2)
Subagent ×4 parallel dispatch результаты:
- Backend (Pint/Larastan/composer audit):  all 0 errors. P3 composer audit network warn (cached DB).
- Frontend (ESLint/vue-tsc/prettier/knip): ESLint 0, vue-tsc 0. P2 prettier 312 files mismatch (87% — generated .histoire/dist + coverage; ~40 real source). P2 knip lucide-vue-next false-positive (dynamic IconSet pattern).
- Docs (markdownlint/cspell/lychee):  all clean (75 md / 88 cspell / 367 links).
- SQL (squawk/pgFormatter): squawk 0. P3 pgFormatter 6284 lines diff — Q.HARD.002 documented «не трогать».

Severity Phase 1: P0=0 / P1=0 / P2=2 / P3=2. vs 12.05 baseline (P1=44, P2=316) — massive improvement.

Также Phase 0 post-pause update: параллельная сессия завершилась PR #4 merge 66ebb22, нормативка bumped до v1.92/v1.13/v2.1/v1.17, +sentry/redis MCP, +SAST workflow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 10:25:34 +03:00
CoralMinister 66ebb22043 Merge pull request #4 from CoralMinister/feat/claude-automation-norm-sync
docs(meta): sync нормативки — #34 Sentry MCP + #35 Redis MCP (off-phase debug-runtime)Feat/claude automation norm sync
2026-05-13 10:05:26 +03:00
Дмитрий db167c1beb docs(meta): CLAUDE.md v1.91 → v1.92 — §3 +#34/#35 sentry+redis (off-phase debug-runtime)
Применены 9 edits через /claude-md-management:claude-md-improver per §5 п.10:
- Шапка: v1.91 → v1.92 от 13.05.2026 day +1
- §0 row Pravila: v1.12 → v1.13 (§13.2 +Off-phase MCP debug-runtime)
- §0 row PSR_v1: v2.0 → v2.1 (R10.1 Блок 3 +sentry+redis)
- §0 row Tooling: v1.16 → v1.17; «33 формализованных» → «35»
- §1 priority chain row 2b: «33 инструментов» → «35»
- §3 title: «Карта 33» → «Карта 35»
- §3.3 table: +#34 Sentry MCP + #35 Redis MCP rows после #33
- §3.3 footer: «Total: 33 = 29+3+1» → «35 = 29+5+1»
- §9 история: +v1.92 entry

Категория debug-runtime — отдельная от UI-пула (UPM/21st) и от infrastructure
(claude-md-management). Не trigger'ит R6.0/R6.1 и не входит в R14 pipeline.
READ-ONLY usage обязателен.

Связано: Tooling 763aeae (v1.17), PSR_v1 c1f9719 (v2.1), Pravila 318aed4 (v1.13).
PR #3 (cc5f63b) merge precedent. Branch: feat/claude-automation-norm-sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:52:39 +03:00
Дмитрий a0fbe53eea chore(cspell): add «нормативку» (accusative case)
Поддержка для CLAUDE.md v1.92 шапка. «нормативки» (genitive) уже в словаре —
inflection-blind cspell не распознаёт «нормативку» автоматически.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:52:38 +03:00
Дмитрий 318aed4f2c docs(rules): §13.2 +Off-phase MCP debug-runtime (sentry+redis) — Pravila v1.12 → v1.13
Применены 3 edits per Task 9 drafts (commit 00eb8ad):
- Шапка: v1.12 → v1.13 от 13.05.2026 day +1; +«Что изменилось в v1.13» section
- §13.2 cross-ref на PSR_v1: v2.0 (15 правил R0–R14) → v2.1 (+R10.1 Блок 3 sentry+redis)
- §13.2 +новый абзац «Off-phase MCP debug-runtime (отдельная категория)» после
  «Инфраструктурные плагины» paragraph: sentry-mcp (#34, pending Б-1) +
  redis-mcp (#35, deprecated, Memurai verified)

Категория отдельная от UI-пула (§13.2 paired-stack + UPM + 21st) и от
infrastructure (claude-md-management). Не trigger'ит R6.0/R6.1 stack-фильтры
и не входит в R14 pipeline UI-генераторов. READ-ONLY usage обязателен.

Связано: Tooling v1.16 → v1.17 (763aeae), PSR_v1 v2.0 → v2.1 (c1f9719),
CLAUDE.md v1.91 → v1.92 (next via claude-md-management).
PR #3 (cc5f63b) merge precedent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:48:28 +03:00
Дмитрий c1f9719d67 docs(psr): R10.1 Блок 3 +sentry+redis MCP (debug-runtime category) — v2.0 → v2.1
Применены 3 edits per Task 9 drafts (commit 00eb8ad):
- Шапка: v2.0 → v2.1 от 13.05.2026 day +1; L4 narrative +упоминание debug-runtime MCP
- R10.1 Блок 3 (MCP-серверы): +2 строки sentry + redis с категорией debug-runtime
- История версий: +v2.1 entry перед v2.0

NB по drafts correction: drafts указывали "Блок 1" — actual right block для MCP serverов = Блок 3 (MCP-серверы по `~/.claude.json` / `.mcp.json`).

Категория debug-runtime introduced — отдельная от UI-пула (Pravila §13) и infrastructure
(claude-md-management). READ-ONLY usage, не trigger'ит R6.0/R6.1 фильтры, не входит в R14 pipeline.

Связано: Tooling v1.16 → v1.17 (763aeae), CLAUDE.md v1.91 → v1.92, Pravila v1.12 → v1.13.
PR #3 (cc5f63b) merge precedent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:47:04 +03:00
Дмитрий 763aeae0a4 docs(tooling): §0 +#34 Sentry MCP + #35 Redis MCP (off-phase debug-runtime) — v1.16 → v1.17
Применены 5 edits per Task 9 drafts (commit 00eb8ad):
- §0 Сводка row off-phase tools: +3 → +5
- §0 footer: «Итого формализованных позиций» 33 → 35
- §4.8 (новый) — #34 Sentry MCP (@sentry/mcp-server@0.33.0+, official; pending Б-1)
- §4.9 (новый) — #35 Redis MCP (@modelcontextprotocol/server-redis@2025.4.25, deprecated Anthropic source; Memurai PONG verified Task 4)
- §13 история: +v1.16 строка (missing gap) + v1.17 строка
- Footer notes: +v1.16 + v1.17 prepended
- Шапка: v1.16 → v1.17 от 13.05.2026 day +1

Категория debug-runtime — отдельная от UI-пула (UPM/21st) и инфраструктурного (claude-md-management).
Не trigger'ит R6.0/R6.1 фильтры и не входит в R14 pipeline.

Связано: PSR_v1 v2.0 → v2.1, CLAUDE.md v1.91 → v1.92, Pravila v1.12 → v1.13 (separate commits).
PR #3 (cc5f63b) merge precedent.

Verification: markdownlint 0 errors, lychee 5/5 OK 0 broken, gitleaks 10.91 KB no leaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:45:04 +03:00
Дмитрий d7d70ccb4d chore(cspell): add 3 words (wenit, FLUSHDB, LPUSH)
Поддержка для Tooling v1.17 §4.9 Redis MCP entry:
- wenit — npm пакет автор (@wenit/redis-mcp-server, post-MVP migration candidate)
- FLUSHDB, LPUSH — Redis команды (forbidden в READ-ONLY usage)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 09:44:50 +03:00
Дмитрий 2ece232fda Merge branch 'main' of https://github.com/CoralMinister/lidpotok 2026-05-13 09:33:14 +03:00
CoralMinister cc5f63b456 Merge pull request #3 from CoralMinister/feat/claude-automation
Feat/claude automation
2026-05-13 09:26:46 +03:00
Дмитрий c0a5fd1807 feat(agent): extend pest-parallel-debugger с quirk 77 (unique-key collision)
Applied 4 edits per quirk-77 plan Task 3:
- Edit 3.1: добавлен Quirk 77 entry в known-quirks section (between Quirk 73 и NB line)
- Edit 3.2: добавлена Hypothesis 4 quirk 77 в diagnostic pipeline (renumber «other» к H5)
- Edit 3.3: обновлён output format template (+Hypothesis 4 row + extended Conclusion options)
- Edit 3.4: обновлён description frontmatter (+quirk 77 classification (d))

Quirk 77: Pest --parallel deterministic unique-key collision на projects(tenant_id, name)
в ProjectBulkActionsTest::rejects_bulk_when_scope_filter_captures_more_than_500_projects.

Evidence (Task 8 baseline check):
- db/schema.sql:836 UNIQUE (tenant_id, name)
- app/database/factories/ProjectFactory.php:23 fake()->words(3, true)
- app/tests/Pest.php:18 // ->use(RefreshDatabase::class)
- app/tests/Feature/Api/ProjectBulkActionsTest.php:194-206 (501-project bulk)
- 2× --parallel runs failed 738/742; sequential isolation 14/14 
- NOT regression from feat/claude-automation (f454e95 audit-2 zero PHP)

Root cause partial: collision matches birthday paradox (~12.5%), но
deterministic-in-parallel vs sequential suggests worker state sharing
(shared Faker seed via PHP global? Eloquent factory caching?). Full RCA pending.

Mitigation: known parallel-only flake; sequential always passes.
Long-term fix candidates documented в quirk entry.

NB: project-local subagent auto-discovery может требовать session restart.

Verification: markdownlint 0 errors, gitleaks no leaks, +13/-3 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:50:23 +03:00
Дмитрий 0e3f6b2301 docs(plan): quirk #77 candidate plan — Pest --parallel unique-key collision
Plan: docs/superpowers/plans/2026-05-13-quirk-77-pest-parallel-unique-key-collision-plan.md
279 lines, 3 tasks для documenting Task 8 baseline check finding.

Discovery: ProjectBulkActionsTest::rejects_bulk_when_scope_filter_captures_more_than_500_projects
reproducibly fails 738/742 в --parallel --recreate-databases.
Sequential 14/14 . NOT regression from feat/claude-automation
(verified f454e95 audit-2 commit zero PHP touched).

Evidence captured this session:
- db/schema.sql:836 UNIQUE (tenant_id, name)
- app/database/factories/ProjectFactory.php:23 fake()->words(3, true)
- app/tests/Pest.php:18 // ->use(RefreshDatabase::class) (TX rollback only)
- app/tests/Feature/Api/ProjectBulkActionsTest.php:194-206 (501-project bulk)

Tasks:
1. Memory feedback_environment.md +#77 entry (76→77 quirks)
2. MEMORY.md line 5 summary bump
3. .claude/agents/pest-parallel-debugger.md +Hypothesis 4 + output template
   + description frontmatter

Root cause partial: collision pattern matches birthday paradox (~12.5% per-test
prob with ~100-word Lorem ~1M combos), но deterministic-in-parallel vs sequential
suggests worker state sharing (shared Faker seed via PHP global state? Eloquent
factory caching?). Full RCA pending.

Apply-time recommendation: defer until completion plan Task 9 merged,
apply на separate branch feat/quirk-77-update для atomic-commit hygiene.

Verification: lychee 5/5 OK, markdownlint 0 errors, gitleaks 19.07 KB clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:46:07 +03:00
Дмитрий 00eb8ad235 docs(drafts): pre-prep norm-sync edit blocks для Task 9 (5 files, 9 edits)
Drafts file: docs/superpowers/plans/2026-05-13-claude-automation-norm-sync-drafts.md
364 lines, 5 file targets, 9 distinct Edit blocks с OLD/NEW pairs.

Targets:
- Tooling §0 + §4.8 (sentry) + §4.9 (redis) + §13 changelog v1.16→v1.17
- PSR_v1 R10.1 table + история v2.0→v2.1
- CLAUDE.md §3.3 +#34/#35 + §0 cross-refs + v1.91→v1.92 (через claude-md-management plugin per §5 п.10)
- Pravila §13.2 +Off-phase MCP debug-runtime subsection + v1.12→v1.13
- Memory MEMORY.md + reference_archive.md header refs

Critical correction в drafts: original plan Task 9.3 wording «§3.3 +#34/#35» — error.
Tooling §3.3 = «БД-инструменты», off-phase tools живут в §4.5/§4.6/§4.7.
New sentry+redis → §4.8 + §4.9 (new subsections). Corrected throughout drafts.

Plus bonus finding: new Pest --parallel quirk #77 candidate
(ProjectBulkActionsTest unique key collision on parallel worker shared-DB).
NOT regression from feat/claude-automation (verified). Recommendation:
separate follow-up plan to add quirk #77 to memory + extend
pest-parallel-debugger.

Verification: lychee 3/3 OK 0 errors, markdownlint 0 errors after MD032 fix,
gitleaks 27.35 KB scanned no leaks.

Applied: 0 of 9 edits (drafts only, awaiting Task 1 PR merged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:38:13 +03:00
Дмитрий 7db4075107 docs(plan): completion plan для 9 post-implementation tasks
Plan: docs/superpowers/plans/2026-05-13-claude-automation-completion-plan.md
1047 lines, 9 tasks разделены на 3 фазы:
- Phase A (Tasks 1-2): PR creation + Claude Code session reload
- Phase B (Tasks 3-7): hook smoke + Redis check + skill/subagent invocations + Sentry creds
- Phase C (Tasks 8-9): Pest/Vitest regression + sync нормативки (4 sub-files) + merge + worktree cleanup

Architecture decision: Option A (merge feat/claude-automation first, sync нормативки
on separate branch feat/claude-automation-norm-sync). Clean PR audit trail.

Pre-execution baseline captured. Verification: lychee 7/7 OK 0 errors,
markdownlint 0 errors, gitleaks no leaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 08:19:40 +03:00
Дмитрий 4822610df5 fix(agent): escape <cmd>/<output> backticks в pest-parallel-debugger
Markdownlint MD033 (no-inline-html) caught <cmd> and <output> placeholders
on line 63 of constraints section as HTML elements. Wrapped в inline-code
backticks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:54:20 +03:00
Дмитрий a2b5126d19 feat(agent): add pest-parallel-debugger subagent
Project-local subagent в .claude/agents/pest-parallel-debugger.md.
Specialized для верифицированных Pest --parallel квирков 72 + 73
в проекте Лидерра (memory feedback_environment.md lines 385, 389):
- quirk 72 — Redis supplier:session race в subdir-only run
- quirk 73 — cumulative state на long sessions

4-hypothesis diagnostic pipeline (real / quirk 72 / quirk 73 / other).
READ-ONLY (tools: Read, Grep, Bash).

NB: quirks 70-71 в memory — про a11y/Vuetify, не Pest — не входят в agent's scope.
Quirks 74-76 — про npm/Lucide/plans paths, тоже не Pest.

Замена generic systematic-debugging для повторяющихся flake патернов.
NB: project-local subagent auto-discovery может требовать session restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:53:53 +03:00
Дмитрий 995886f73f feat(agent): add rls-reviewer subagent для migration review
Project-local subagent в .claude/agents/rls-reviewer.md.
Specialized для 5-role архитектуры Лидерры (crm_app_user/admin/
supplier_worker BYPASSRLS/readonly/migrator).

Walks 7-item checklist: tenant_id, ENABLE RLS, 2 policies, 5-role GRANTs,
CHANGELOG, squawk. READ-ONLY (tools: Read, Grep, Glob, Bash).

Замена generic security-review для security-critical RLS работ (39 политик).

NB: project-local subagent auto-discovery может требовать session restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:53:00 +03:00
Дмитрий 99a242c9ed feat(hook): remind db/CHANGELOG_schema.md on db/schema.sql edits (PostToolUse)
PostToolUse hook на Edit|Write matcher — emits stdout reminder если file path
matches regex `(^|/)db/schema\.sql$` (Windows backslashes normalized к `/`).

Runtime enforcement существующего правила CLAUDE.md §5 п.8:
"Не править db/schema.sql без записи в db/CHANGELOG_schema.md."

Self-review (§8) ловит это поздно (после ≥3 групп правок); hook — сразу,
в transcript stdout vs stderr (visible alongside markdownlint output).

Параллельный entry в hooks.PostToolUse array — Claude Code processes oба
markdownlint (для .md без CLAUDE.md) + schema reminder (для db/schema.sql)
независимо на каждом Edit|Write.

Edge case: Bash-обход (echo ... >> db/schema.sql) не покрывается —
known limitation, документировано в spec §4.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:52:10 +03:00
Дмитрий c5b0cdfe6f feat(hook): block direct edits of root CLAUDE.md (PreToolUse, Option A warning)
PreToolUse hook на Edit|Write matcher — emits stderr warning если file path
exactly === <project>/CLAUDE.md (path.resolve compare, AND CLAUDE_FILE_PATH +
CLAUDE_PROJECT_DIR both injected by Claude Code at hook firing).

Runtime enforcement существующего правила CLAUDE.md §5 п.10:
"Не править этот CLAUDE.md напрямую — только через плагин claude-md-management."

Option A (warning-only) chosen per Task 1 pre-flight Q5: skill-marker detection
ненадёжно в текущей Claude Code (CLAUDE_SKILL_ACTIVE env var inconclusive в Bash
session — injection-only при hook firing, не verifiable без live test). Warning
visible в transcript stderr; если invoked via /claude-md-management:*, warning
информационный, не блокирует.

Не trigger'ит для:
- app/CLAUDE.md (Boost-managed, не существует на момент implementation)
- node_modules/*/CLAUDE.md (если есть — не root project)

Edge case: Bash-обход (sed -i CLAUDE.md или > CLAUDE.md) не покрывается —
known limitation, документировано в spec §4.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:51:34 +03:00
Дмитрий e9880a1c1b feat(skill): add /rls-check — 7-item RLS checklist для new tables
Project-local skill в .claude/skills/rls-check/SKILL.md.
Инкапсулирует security-critical check: tenant_id, ENABLE RLS, 2+ policies,
5-role GRANTs (db/02_grants.sql), CHANGELOG, squawk, smoke test.

disable-model-invocation: true — для физического вызова при modify db/schema.sql.
Полезно для security-critical правок (39 RLS политик × 5 ролей).

NB: project-local skill auto-discovery может требовать session restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:50:51 +03:00
Дмитрий e642cfeb53 feat(skill): add /q-item-add — добавление Q-item в реестр Открытых_вопросов
Project-local skill в .claude/skills/q-item-add/SKILL.md.
Инкапсулирует 6-шаговый workflow: detect section → find next number →
insert entry → update §0 counters → bump versions → sync CLAUDE.md §0.

disable-model-invocation: true — только пользовательская инвокация
(Pravila §2.2: добавление Q-item требует явного запроса заказчика).

NB: project-local skill auto-discovery может требовать session restart
(Task 1 pre-flight outcome: inconclusive direct test, conservative assumption).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:49:15 +03:00
Дмитрий bd4ec48f05 feat(mcp): add redis-mcp server entry
Memurai (Redis 7-совместимый Windows service, localhost:6379).
Pending формализация в Tooling §3.3 #35 — sync нормативки отдельным планом.

Package: @modelcontextprotocol/server-redis@2025.4.25 — DEPRECATED
по npm статусу («Package no longer supported»), но Anthropic source,
простой протокол, рабочий. Post-MVP migration на community alternative
(e.g., @easy-mcps/redis-mcp-server@1.0.8 или @wenit/redis-mcp-server@1.0.3)
когда подтвердим trust.

READ-ONLY use — отладка очередей, кэша, Pest --parallel quirk 72.
Gitleaks scan (manual via absolute path): no leaks found.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:48:20 +03:00
Дмитрий 6f7e7d72fa feat(mcp): add sentry-mcp server entry
Self-hosted Sentry в Yandex Cloud (CLAUDE.md §2). Pending формализация
в Tooling §3.3 #34 — sync нормативки отдельным планом.

Package: @sentry/mcp-server@0.33.0+ (official sentry-bot,
repo getsentry/sentry-mcp, bin sentry-mcp).
Env vars: SENTRY_URL, SENTRY_AUTH_TOKEN — injected via shell, не commit'ятся.

Gitleaks scan (manual via absolute path due to worktree): 800 bytes,
no leaks found. ${SENTRY_*} placeholders confirmed safe.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:47:41 +03:00
Дмитрий f454e95a2d docs(audit): Phase 0 pre-flight skeletons + findings (audit #2)
Skeleton files findings/blocked/report для portal full audit #2 (2026-05-13).

Phase 0 finding P3: обнаружена параллельная сессия на feat/claude-automation
branch (claude-automation-recommender skill активна параллельно с этим audit'ом
на main). Main verified clean, git checkout main вернул state. CWD persistence
quirk зафиксирован для memory (двойной cd app && ... загнал в app/app/).

cspell-words.txt: +«инвалидирует» (валидное слово для Phase 0 finding prose).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:37:12 +03:00
Дмитрий d0460f6d20 docs(plan): spec + plan для claude-code automation recommendations
Spec: docs/superpowers/specs/2026-05-13-claude-automation-recommendations-design.md
Plan: docs/superpowers/plans/2026-05-13-claude-automation-recommendations-plan.md

8 automations scope:
- 2 MCP: sentry, redis
- 2 skills: /q-item-add, /rls-check
- 2 hooks: PreToolUse block CLAUDE.md, PostToolUse db/schema.sql reminder
- 2 subagents: rls-reviewer, pest-parallel-debugger

Execution: Subagent-Driven (user choice A), feature branch feat/claude-automation.

Out of scope per customer:
- Sync нормативки (PSR_v1/Tooling/CLAUDE.md/Pravila формализация)
- Plugin commit-commands install

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:35:30 +03:00
Дмитрий 1efd25dc8c docs(audit): implementation plan for portal full audit #2 (2026-05-13)
Bite-sized task plan для 14 phases описанных в spec fc07529.
Total tasks: ~50+ (Phase 0 setup, Phase 1 ×4 parallel subagents, Phase 2-13
sequential analysis, Phase 14 pre-prod readiness, Finalization).

Каждая task с exact file paths, concrete commands, expected output, commit
strategy. Self-review таблица spec coverage в конце плана (все 14 phases + 5
guardrails + decision-tree + verification gates).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:29:55 +03:00
Дмитрий fc07529c4c docs(audit): spec for portal full audit #2 (2026-05-13)
Design для нового 14-phase audit pass на main 21262ef post-merge plan5→main.

Scope: full 13-phase audit (replica 12.05 структуры — pre-flight, static analysis ×4 subagents, test suites, schema integrity, security, UI smoke 24 views, cross-doc, categorize, fix loop, regression verify, Pa11y live + axe-core, TODO sweep, bundle analyzer, Vitest coverage) + новая Phase 14 pre-production readiness (Sentry, DB roles, mock-data prod-gate revisit, CI workflows audit, env validation, queue/cron, backup/log rotation, deployment runbook).

Fix-strategy: hybrid — P0+P1 → atomic commits на main по ходу; P2/P3 → только запись в findings.md (без commits).

Guardrails applied (lessons из 12.05 audit + Pravila v1.12):
- Phase 4 SAST: ls .github/workflows/ FIRST (audit methodology gap closure)
- Phase 5/10 UI-refactor visual smoke + axe-core с setTimeout 500ms + hard reload (Q.DEFER.004 lesson)
- Pest --parallel --recreate-databases для long sessions (квирки 62/73)
- Plans/specs relative paths ../../../ для app/ refs (Pravila v1.12 §4.7 п.4)
- npm install с --legacy-peer-deps (квирк 74)

Baseline для regression gate Phase 9: Pest 742/739/0/3, Vitest 88f/683/3sk, Vite ~3.5s/0err, Histoire 35/63.

Next step: invoke superpowers:writing-plans для implementation plan в docs/superpowers/plans/2026-05-13-portal-full-audit-2.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:24:00 +03:00
Дмитрий 982c79d6d2 chore(cspell): add 6 words (доразбор, нормативки, нерегрессии, ver, hookify, pункт)
Слова требуются для unblock pre-commit lefthook на untracked .md в working tree:
- `доразбор` — валидная русская приставочная форма (audit spec scope-decisions).
- `нормативки` — генитив-форма от «нормативка», стандартный проектный термин.
- `нерегрессии` — отрицательная форма от «регрессия» (audit verdict).
- `ver` — стандартная аббревиатура version/release context.
- `hookify` — название плагина из тулчейна (упоминается в memory + skill list).
- `pункт` — mixed-script typo (Latin `p` + Cyrillic ункт) добавлен в audit-cited
  artefacts секцию рядом с импersonator/proverено/моменти. Owner оригинального
  файла видит typo сам — словарь только разблокирует cspell на untracked work-in-progress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:23:45 +03:00
Дмитрий c435e2727b chore(cspell): add 3 words (закоммиченных, AKIA, gpg)
Prepares dictionary для предстоящего audit spec/plan/findings/blocked/report
артефактов в этой и следующих сессиях.

- закоммиченных — валидная форма уже существующего `закоммичены`, нужна для
  описаний git-state в audit-докуменах.
- AKIA — AWS access key prefix, упоминается в production secrets scan
  (Phase 4 audit) как regex anchor.
- gpg — стандартное security-обозначение (GnuPG), используется в decision-tree
  hard-stops («никаких --no-gpg-sign»).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 07:18:06 +03:00
Дмитрий 21262efedf Merge plan5-frontend-projects → main
Объединяет 120 commits работы 12.05–13.05.2026 (day +1):
— Plan 5 frontend Tasks 7-11 (ProjectController 8 endpoints + schema v8.20)
— Quiet Luxury portal redesign (20 commits Direction A)
— Dev Element Indices (temporary feedback feature)
— Portal full audit 2026-05-12 (14 audit commits + 5 post-audit)
— Q.DEFER.002 sub-B / Q.DEFER.003 sub-A+B+C / Q.DEFER.004 sub-A+B closures
— Audit-cleanup tail (5 commits)
— R15 motion-runtime cleanup merge `323957a`
— Registry catch-up v1.77 → v1.82 (commit `9bc0419`)
— CTO-19  closed via Lucide migration (commits `0832997` + `f6e1e64`)
— Session-end documentation hygiene (commit `19d12c9`):
  CLAUDE.md v1.91 / Pravila v1.12 / audit findings.md SAST gap note

Регрессия зелёная (verified pre-merge 13.05.2026 day +1 05:49):
— Pest --parallel --recreate-databases 742/739/0/3
— Vitest 88 files / 683 passed / 3 skipped
— Vite build 3.52s, axe-core 0 iconography violations
— lychee 252 OK, gitleaks 0 (373+ commits)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 05:51:42 +03:00
251 changed files with 50473 additions and 1023 deletions
+82
View File
@@ -0,0 +1,82 @@
---
name: pest-parallel-debugger
description: |
Diagnose Pest 4 --parallel test failures in the Лидерра CRM project.
Classifies failures as (a) real failure, (b) quirk 72 (Redis supplier:session
race в subdir-only), (c) quirk 73 (cumulative state on long sessions),
(d) quirk 77 (unique-key collision в bulk-action tests with Faker-generated names),
or (e) other — escalate. Falsifies hypotheses with actual command runs.
tools: Read, Grep, Bash
---
# Pest --parallel debugger agent — Лидерра
You are diagnosing a Pest 4 --parallel test failure in the Лидерра CRM project. Read-only diagnosis; recommend fixes, do not apply them.
## Known quirks (from memory feedback_environment.md, verified 2026-05-13)
1. **Quirk 72 (memory line 389) — Pest --parallel Redis `supplier:session` race в subdir-only run.**
- Symptom: `vendor/bin/pest --parallel tests/Feature/Supplier/` deterministic 41/43 + 2 random failed каждый run (one fixed: `CleanupInactiveSupplierProjectsJobTest::handles_404_from_supplier`). Single-file isolated 8/8 passes.
- Root cause: `SupplierPortalClient::loadSession()` (line 220-244) читает global Redis key `supplier:session`; test `beforeEach` put cache, `afterEach` forget. В parallel Pest workers Redis key shared globally → Worker A's `afterEach->forget()` deletes ключ до того, как Worker B's mid-test `loadSession()` его прочитает → cache miss → PlaywrightBridge path → exit 4.
- Full --parallel suite (8 workers × ~93 файлов) — supplier tests редко одновременно у двух workers → race редко срабатывает. Full passes 742/739/0/3 ✅.
- Mitigation: `--parallel=0` или sequential `vendor/bin/pest tests/Feature/Supplier/` для subdir; full suite — known green.
2. **Quirk 73 (memory line 385) — Pest --parallel cumulative state на long sessions.**
- Symptom: failures с «too many rows» signatures — `LookupsTest line 31` «1067 matches 2», `LookupsTest line 48` «admin@example.ru vs Абрам К.», `ProjectExtensionsTest line 89` «7677 identical to 1».
- Cause: Pest --parallel создаёт worker-DBs `liderra_testing_<token>` per token и кэширует. Migrations не пересоздаются между runs без `--recreate-databases`. Tests используют `DatabaseTransactions` (не `RefreshDatabase``Pest.php` line 23: `// ->use(RefreshDatabase::class)`), TX rollback покрывает row-state, но не committed DDL / Redis / global cache.
- Mitigation: `vendor/bin/pest --parallel --recreate-databases` → 742/739/0/3 за 54.9s. `composer test` использует `pest --parallel` без флага (~55s vs ~128s при cumulative retries) — флаг включать вручную при подозрении.
3. **Quirk 77 (memory feedback_environment.md, added 13.05.2026 day +1) — Pest --parallel deterministic unique-key collision на `projects(tenant_id, name)` в bulk-action tests.**
- Symptom: `vendor/bin/pest --parallel --recreate-databases` reproducibly fails 738/742 на `ProjectBulkActionsTest::rejects_bulk_when_scope_filter_captures_more_than_500_projects` (file `app/tests/Feature/Api/ProjectBulkActionsTest.php:194-206`). Signature `SQLSTATE[23505] projects_tenant_id_name_key — (tenant_id, name)=(<id>, "<faker-3words>")`. Tenant_id varies per run (~50 apart — per-worker auto-increment).
- Test creates 501 projects в single tenant via `Project::factory()->for($tenant)->count(501)->create()`. ProjectFactory.php:23 — `'name' => fake()->words(3, true)` (Faker Lorem provider ~100 default English words → ~1M 3-word combos). Birthday paradox math для 501 samples из ~1M combos → ~12.5% per-test failure probability — НЕ deterministic в isolation. Reproducible-in-parallel-but-not-sequential pattern suggests worker state sharing (shared Faker seed via PHP global state? Eloquent factory caching?). Full RCA pending.
- Sequential `vendor/bin/pest tests/Feature/Api/ProjectBulkActionsTest.php` passes 14/14 ✅. Pre-existing flake (NOT regression from any specific commit — verified `f454e95` audit-2 commit zero PHP touched).
- Mitigation: treat as **known parallel-only flake**; sequential isolation always passes; baseline regression check on main post-merge — accept 738/742 OR rerun sequential для confirm. Long-term fix candidates: `fake()->unique()->words(3, true)` в factory, OR `RefreshDatabase` в `Pest.php` line 18, OR explicit Faker seed per-test.
**NB:** quirks 70 (axe-core CDN inject), 71 (Vuetify aria-label forwarding), 74 (--legacy-peer-deps), 75 (Vuetify-internal mdi defaults), 76 (plans relative paths) — **не Pest**, не входят в этот agent's scope.
## Diagnostic pipeline
Given a failure output (paste from user OR capture from `./vendor/bin/pest --parallel`):
1. **Capture exact failure.** Какой test file:line failed? Assertion message?
2. **Hypothesis 1 — real failure.** Read failing test + production code. Catches real bug? If yes — fix the code.
3. **Hypothesis 2 — quirk 72 (Redis `supplier:session` race).** Failing test в `tests/Feature/Supplier/*`? Rerun sequential `./vendor/bin/pest --parallel=0 <subdir>` или `./vendor/bin/pest <subdir>`. If passes — race. Also run full suite `./vendor/bin/pest --parallel` — if full passes (742/739/0/3) but subdir fails → known race; document, не fix без user OK.
4. **Hypothesis 3 — quirk 73 (cumulative state).** Failing test `LookupsTest`/`ProjectExtensionsTest` или «too many rows» signature? Rerun `./vendor/bin/pest --parallel --recreate-databases`. If passes → cumulative; baseline restored.
5. **Hypothesis 4 — quirk 77 (unique-key collision в bulk-action tests).** Failing test creates ≥500 records of one model в single tenant с Faker-generated unique field? Pattern: `SQLSTATE[23505]` + `_tenant_id_<col>_key` constraint name + Faker-style value в DETAIL. Rerun sequential `./vendor/bin/pest <test-file>` — if passes 14/14 → quirk 77 confirmed; document as known parallel-only flake, не fix без user OK (root cause не fully RCA'd).
6. **Hypothesis 5 — other.** If none of above → escalate с raw output + tested hypotheses + outcome per hypothesis.
## Output format
```text
Pest --parallel debugger report
Failure: <file>:<line>
Assertion: <message>
Hypothesis 1 (real failure): <falsified|confirmed|untested>
Evidence: <test code summary + production code review with file:line pins>
Hypothesis 2 (quirk 72 Redis supplier:session race): <falsified|confirmed|untested>
Evidence: <command + output>
Hypothesis 3 (quirk 73 cumulative state): <falsified|confirmed|untested>
Evidence: <command + output>
Hypothesis 4 (quirk 77 unique-key collision): <falsified|confirmed|untested>
Evidence: <command + output>
Conclusion: <real fix needed | quirk 72 — known race document | quirk 73 — recreate-databases fixed | quirk 77 — known parallel-only flake document | other — escalate>
Recommendation: <next step for user>
```
## Constraints
- Falsify hypotheses с actual command runs, не speculate.
- Capture raw output, не summaries.
- Никогда "should pass" — только "passed with `<cmd>`" or "failed with `<cmd>` + `<output>`".
- Каждое утверждение про код — с `file:line` pin'ом.
- If unsure — escalate, do not guess.
## Out of scope
- Не fix code — only diagnose + recommend.
- Не run full --parallel for >5 min без user OK (полный прогон ~55-128s OK).
- Vitest (frontend) failures — separate concern.
- a11y / Vuetify quirks — see separate quirks 70-71 in memory; not this agent.
+103
View File
@@ -0,0 +1,103 @@
---
name: rls-reviewer
description: |
Review RLS (Row-Level Security) compliance on migration commits/PRs.
Use when reviewing changes to db/schema.sql or db/migrations/ that add
or modify tables. Specialized for Лидерра's 5-role architecture
(crm_app_user, crm_app_admin, crm_supplier_worker BYPASSRLS,
crm_readonly, crm_migrator). Reports orphan policies, missing tenant_id
columns, inconsistent GRANTs, missing CHANGELOG entries.
For manually checking a single named table before commit - use the /rls-check skill.
tools: Read, Grep, Glob, Bash
---
# RLS reviewer agent — Лидерра
You are reviewing a database migration or schema change for RLS (Row-Level Security) compliance in the Лидерра CRM project. Read-only review — DO NOT edit files.
## Контекст проекта
PostgreSQL 16 с 5 ролями (db/00_create_roles.sql + db/02_grants.sql):
1. `crm_app_user` — regular tenant user; RLS enforced via `current_setting('app.current_tenant_id')`.
2. `crm_app_admin` — tenant admin; RLS enforced, broader policies.
3. `crm_supplier_worker` — SaaS-level worker (BYPASSRLS) для supplier integration jobs.
4. `crm_readonly` — read-only для reports; RLS enforced.
5. `crm_migrator` — DDL role для Laravel migrations; RLS bypassed via session.
Каждая tenant-scoped таблица должна иметь:
- `tenant_id UUID NOT NULL REFERENCES tenants(id)` колонка.
- `ALTER TABLE <name> ENABLE ROW LEVEL SECURITY;`.
- Минимум 2 политики: SELECT (tenant scope `tenant_id = current_setting('app.current_tenant_id')::uuid`), ALL (admin scope).
- GRANT'ы для 5 ролей в `db/02_grants.sql`.
SaaS-level таблицы (e.g., `supplier_csv_reconcile_log`, `system_settings`) exempt от tenant_id; должны иметь explicit `-- SaaS-level` comment.
Каждое schema change требует записи в `db/CHANGELOG_schema.md` (CLAUDE.md §5 п.8).
## Граница со скилом /rls-check
`rls-reviewer` (этот агент) и скил `/rls-check`
(`.claude/skills/rls-check/SKILL.md`) оба проверяют RLS. Правило выбора:
- Есть diff / ветка / PR с изменениями БД, набор таблиц заранее не известен →
**этот агент**.
- Знаешь имя одной конкретной таблицы, проверка вручную перед коммитом →
**скил `/rls-check <table>`**.
Этот агент прогоняет **7 статических пунктов** чеклиста. Живой дымовой тест
(`pest --filter RlsSmokeTest`) намеренно **не входит** в агентский чеклист:
запуск Pest в ревью-субагенте медленный и задевает гонки `--parallel`
(квирки 72/77, см. `.claude/agents/pest-parallel-debugger.md`). Живой дымовой
тест — 8-я строка скила `/rls-check`. 7 пунктов агента === первые 7 строк
вывода скила (общее статическое ядро).
## Workflow
1. Read target migration файл OR `db/schema.sql` diff (use `git diff HEAD~1 -- db/schema.sql` или указанные изменения).
2. Для каждой added/modified таблицы — run 7-item checklist:
- tenant_id column (или SaaS-level comment).
- ENABLE RLS.
- SELECT policy для crm_app_user.
- ALL policy для crm_app_admin (или per-convention).
- 5-role GRANTs в db/02_grants.sql.
- db/CHANGELOG_schema.md entry.
- squawk passes (`./bin/squawk.exe <file>`).
3. Cross-check `db/02_grants.sql` для matching GRANTs.
4. Cross-check `db/CHANGELOG_schema.md` для entry.
5. Run `./bin/squawk.exe db/schema.sql 2>&1 | tail -10` и capture issues.
6. Output structured report:
```text
RLS Review — <table_name>
[✅/❌] tenant_id column present
[✅/❌] ENABLE ROW LEVEL SECURITY
[✅/❌] SELECT policy for crm_app_user
[✅/❌] ALL policy for crm_app_admin
[✅/❌] 5-role GRANTs in db/02_grants.sql
[✅/❌] db/CHANGELOG_schema.md entry
[✅/❌] squawk passes (0 issues)
Issues:
- <file>:<line>:<col> <message>
Pass: <N>/7
```
## Constraints
- READ-ONLY — не edit files, только report.
- Falsify с actual command runs, не speculate.
- SaaS-level exemption — accept если explicit comment present; flag если comment отсутствует.
- Partitioned tables (e.g., `lead_charges` partitioned by month) — verify policy применяется к parent + children.
## Out of scope
- General SQL style (squawk handles).
- Business logic review (other agents).
- Performance review (separate concern).
- Проверка одной названной таблицы вручную перед коммитом + живой дымовой
тест — сценарий скила `/rls-check`, не агента.
## Verification protocol
Каждое утверждение про код — с `file:line` как pin'ом. "Looks correct" / "should pass" — запрещено. Только "passed with command X — output Y" or "failed with command X — output Y".
+38
View File
@@ -37,6 +37,35 @@
]
},
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-recall-hook.mjs\""
}
]
},
{
"hooks": [
{
"type": "command",
"command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-queen-hook.mjs\""
}
]
}
],
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const pd=process.env.CLAUDE_PROJECT_DIR||''; const path=require('path'); if (f && pd && path.resolve(f) === path.resolve(pd, 'CLAUDE.md')) { process.stderr.write('\\n[hook] WARNING: Direct edit of root CLAUDE.md detected. Per CLAUDE.md §5 п.10, prefer /claude-md-management:revise-claude-md or /claude-md-management:claude-md-improver. If invoked via that skill, this warning is informational.\\n'); }\""
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
@@ -46,6 +75,15 @@
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; if(/\\\\.md$/i.test(f) && !/CLAUDE\\\\.md$/i.test(f)) { require('child_process').spawnSync('npx',['-y','markdownlint-cli2','--fix',f],{stdio:'inherit',shell:true}); }\""
}
]
},
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "node -e \"const f=process.env.CLAUDE_FILE_PATH||''; const n=f.replace(/\\\\\\\\/g,'/'); if (/(^|\\\\/)db\\\\/schema\\\\.sql$/i.test(n)) { process.stdout.write('\\n[hook] REMINDER: You modified db/schema.sql. Per CLAUDE.md §5 п.8, add a corresponding entry to db/CHANGELOG_schema.md before committing.\\n'); }\""
}
]
}
]
}
+63
View File
@@ -0,0 +1,63 @@
---
name: q-item-add
description: |
Add a new open question (Q-item) to the registry docs/Открытые_вопросы_v8_3.md.
Use ONLY when customer explicitly requests adding a new business/CTO/legal/design/devops/OPEN
question to the registry. Walks through 6-step workflow: detect section, find next number,
insert entry, update §0 counters, bump header/footer/changelog version, sync §0 row in CLAUDE.md.
disable-model-invocation: true
---
# Q-item-add — добавить новый Q-item в реестр Открытых_вопросов
## Когда использовать
ТОЛЬКО при явном запросе заказчика добавить новый вопрос. Pravila §2.2 — закрытие/добавление вопроса требует явного указания заказчика.
Invoke via `/q-item-add <Биз|CTO|Ю|Диз|DO|OPEN> "<question text>"`.
## Workflow
1. **Detect section.** Открыть `docs/Открытые_вопросы_v8_3.md`, найти секцию по prefix:
- `Биз-*` → section `## 13` (Бизнес).
- `CTO-*` → section `## 3` (CTO/инженерные).
- `Ю-*` → section `## 4` (Юридические).
- `Диз-*` → section `## 5` (Дизайн).
- `DO-*` → section `## 6` (DevOps/инфраструктура).
- `OPEN-*` → section `## 7` (Прочие открытые).
2. **Find next number.** Grep последний номер в секции (e.g., max `Биз-31` → new = `Биз-32`).
```bash
grep -oP '<prefix>-\d+' docs/Открытые_вопросы_v8_3.md | sort -t- -k2 -n | tail -1
```
3. **Insert entry.** Добавить строку формата:
```markdown
**<prefix>-N ⏸** от 2026-MM-DD: <question text>
```
4. **Update §0 «Сводка».** Increment счётчик ⏸ для соответствующего prefix. Шапка `## 0` содержит таблицу типа `Биз 24 ✅ / 7 ⏸` — bump до `8 ⏸`. **Также** «Итого X / Y ✅ / Z ⏸» — bump соответствующие.
5. **Bump versions.** Header (`v1.83 от 13.05.2026 (day +1)` → `v1.84 от 13.05.2026 (day +1)`), footer (last line same), добавить запись в `## 9. История версий`.
6. **Sync CLAUDE.md.** В `CLAUDE.md` §0 row «Открытые вопросы» bump `v1.83+` → `v1.84+`. Помним: CLAUDE.md правится ТОЛЬКО через `/claude-md-management:revise-claude-md` (§5 п.10) — финальный шаг делегируем заказчику или этому skill'у через sub-invocation.
## Validation
После save:
```bash
./bin/lychee.exe --config .lychee.toml docs/Открытые_вопросы_v8_3.md 2>&1 | tail -3
```
Expected: 0 broken links.
Counter arithmetic check: sum of ✅ + ⏸ + 🟦 per prefix = total per prefix.
## Не использовать когда
- Заказчик говорит «закрываем X» — это closure (replace ⏸ → ✅ + дата), не addition. Skip skill, do targeted Edit.
- Item уже существует с тем же текстом — duplicate; уточнить у заказчика или обновить existing.
- Заказчик не давал явного «добавь X в реестр» — Pravila §2.2 запрещает proactive добавление.
+75
View File
@@ -0,0 +1,75 @@
---
name: regression
description: |
Run the project regression sweep and report a canonical status line + GREEN/RED/RED-INCOMPLETE verdict.
Two tiers: `quick` (lint/format/type-check — seconds) and `full` (everything incl.
Pest --parallel, Larastan, Vitest, Vite build, lychee, gitleaks — minutes).
Claude auto-runs only `quick` (e.g. during verification-before-completion);
`full` runs only on explicit `/regression full` or with user confirmation.
---
# Regression — канонический регрессионный свод
## Когда использовать
Перед закрытием задачи/спринта (`full`) или для быстрого фидбэка по ходу работы
(`quick`). Скилл инкапсулирует ~12 команд свода, разбросанных по `package.json`,
`app/package.json`, `app/composer.json` и `lefthook.yml`, в один вызов с
детерминированной канонической строкой и машинным вердиктом.
Invoke via `/regression [quick|full]` (без аргумента → `full`).
## Workflow
1. Определить уровень из аргумента: `quick`, `full`, либо `full` по умолчанию.
2. Запустить через Bash из корня репозитория:
```bash
node .claude/skills/regression/run.mjs <tier>
```
3. Показать пользователю полный вывод скрипта (таблица + каноническая строка +
вердикт + вывод упавших проверок).
4. Интерпретировать вердикт:
- `GREEN` — свод чист, exit-код 0.
- `RED` — перечислены упавшие проверки, exit-код 1; полный вывод каждой —
после вердикта.
- `RED-INCOMPLETE` — проверка не прогналась (нет бинаря), exit-код 1; свод
неполон, зелёным признать нельзя. Если одновременно есть упавшие проверки,
они тоже перечислены в строке вердикта.
## Уровни
- **`quick`** (6 проверок, секунды): Pint, ESLint, Prettier, vue-tsc,
markdownlint, cspell.
- **`full`** (12 проверок, минуты): всё из `quick` + Larastan, Pest `--parallel`,
Vitest, Vite build, lychee, gitleaks.
## Правила инвокации (self-restraint)
- Claude **авто-запускает только `quick`** — в том числе в рамках
`superpowers:verification-before-completion` перед claim «готово» / «passed» /
«closed».
- `full` Claude **сам не запускает** — только по явному `/regression full` от
пользователя ИЛИ запросив подтверждение («запускаю полный свод, ~5–10 мин — ок?»).
- Скилл **не правит `CLAUDE.md`** — он только печатает каноническую строку в
stdout; вставка строки в `CLAUDE.md` — отдельно, через канал
`claude-md-management` (`CLAUDE.md` §5 п.10).
## Caveats
- **Pest `--parallel` flake (квирки 72/73/77).** Если Pest показал 1–3 ошибки,
похожие на Redis-race / cumulative-state / unique-key-collision, — перепрогнать
`full` один раз ИЛИ свериться с агентом `pest-parallel-debugger` до объявления
реального RED.
- **ruflo daemon (квирк 93).** Перед baseline-критичным `full` рассмотреть
`pm2 stop ruflo-daemon` — worker-jitter усиливает Pest-flake.
- gitleaks и lychee: на Windows берутся из `bin\*.exe`, на Linux/Mac CI — из
`PATH`. Отсутствие бинаря → `[⚠] SKIPPED` + вердикт `RED-INCOMPLETE`.
## Не использовать когда
- Нужна одна конкретная проверка — запусти её npm/composer-скрипт напрямую
(быстрее, чем весь свод).
- Pa11y и Semgrep SAST — это CI-tier, в свод намеренно не входят (см. дизайн-спек
`docs/superpowers/specs/2026-05-16-regression-skill-design.md` §5).
+258
View File
@@ -0,0 +1,258 @@
#!/usr/bin/env node
// .claude/skills/regression/run.mjs
// Regression sweep orchestrator for the /regression skill.
// Design: docs/superpowers/specs/2026-05-16-regression-skill-design.md
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import process from 'node:process';
// ── pure: platform binary resolution ───────────────────────────────
export function resolveBinary(name, platform = process.platform) {
return platform === 'win32' ? `bin\\${name}.exe` : name;
}
// ── pure: output header line ───────────────────────────────────────
export function buildHeader(tier) {
const head = `─ /regression ${tier} `;
return head + '─'.repeat(Math.max(3, 48 - head.length));
}
// ── pure: exit-code token ──────────────────────────────────────────
export function parseExit(label, code) {
return `${label} ${code}`;
}
// ── pure: test-count parsers ───────────────────────────────────────
export function parsePest(stdout) {
// pest --parallel emits a single JSON line: {"tool":"pest","result":...,"tests":N,"passed":N,"skipped":N,...}
const jsonMatch = stdout.match(/\{"tool"\s*:\s*"pest"[^}]+\}/);
if (jsonMatch) {
try {
const j = JSON.parse(jsonMatch[0]);
const passed = Number(j.passed ?? 0);
const skipped = Number(j.skipped ?? 0);
const total = Number(j.tests ?? passed + skipped);
const failed = total - passed - skipped;
return `Pest ${total}/${passed}/${skipped}sk/${Math.max(0, failed)}`;
} catch { /* fall through to regex */ }
}
const passed = Number(stdout.match(/(\d+)\s+passed/)?.[1] ?? 0);
const skipped = Number(stdout.match(/(\d+)\s+skipped/)?.[1] ?? 0);
const failed = Number(stdout.match(/(\d+)\s+failed/)?.[1] ?? 0);
return `Pest ${passed + skipped + failed}/${passed}/${skipped}sk/${failed}`;
}
export function parseVitest(stdout) {
const filesLine = stdout.match(/^.*Test Files.+$/m)?.[0] ?? '';
const files = Number(filesLine.match(/(\d+)\s+passed/)?.[1] ?? 0);
const line = stdout.match(/^\s*Tests\s+.+$/m)?.[0] ?? '';
const passed = Number(line.match(/(\d+)\s+passed/)?.[1] ?? 0);
const skipped = Number(line.match(/(\d+)\s+skipped/)?.[1] ?? 0);
const failed = Number(line.match(/(\d+)\s+failed/)?.[1] ?? 0);
return `Vitest ${files}f/${passed}/${skipped}sk/${failed}`;
}
// ── pure: content parsers ──────────────────────────────────────────
export function parseViteBuild(stdout) {
const m = stdout.match(/built in ([\d.]+)\s*s/i);
return `Vite build ${m ? m[1] : '?'}s`;
}
export function parseLarastan(stdout) {
const m = stdout.match(/Found (\d+) error/i);
return `Larastan ${m ? m[1] : 0}`;
}
export function parseGitleaks(stdout, code) {
const commits = stdout.match(/(\d+)\s+commits?\s+scanned/i)?.[1] ?? '?';
const leaks = code === 0
? '0'
: (stdout.match(/(\d+)\s+leaks?\s+found/i)?.[1]
?? stdout.match(/leaks?\s+found:?\s*(\d+)/i)?.[1]
?? '≥1');
return `gitleaks ${leaks}/${commits}`;
}
export function parseLychee(stdout) {
const ok = stdout.match(/(\d+)\s+OK/)?.[1] ?? '?';
const errors = stdout.match(/(\d+)\s+Errors?/i)?.[1] ?? '0';
return `lychee ${ok}/${errors}`;
}
// ── pure: verdict ──────────────────────────────────────────────────
export function computeVerdict(results) {
const skipped = results.filter((r) => r.skipped).map((r) => r.label);
const failed = results
.filter((r) => !r.skipped && r.code !== 0)
.map((r) => r.label);
if (skipped.length) return { verdict: 'RED-INCOMPLETE', exitCode: 1, failed, skipped };
if (failed.length) return { verdict: 'RED', exitCode: 1, failed, skipped };
return { verdict: 'GREEN', exitCode: 0, failed, skipped };
}
// ── pure: output formatting ────────────────────────────────────────
export function buildCanonicalLine(results) {
return results.map((r) => r.token).join(' / ');
}
export function formatRow(r) {
const mark = r.skipped ? '⚠' : r.code === 0 ? '✅' : '❌';
const label = r.label.padEnd(14);
const status = r.skipped
? 'SKIPPED — binary not found'
: `${r.code} ${(r.ms / 1000).toFixed(1)}s`;
return `[${mark}] ${label}${status}`;
}
export function verdictLine(v, total) {
if (v.verdict === 'GREEN') {
return `🟢 GREEN — все ${total} проверок passed`;
}
if (v.verdict === 'RED-INCOMPLETE') {
const tail = v.failed.length ? `; провал: ${v.failed.join(', ')}` : '';
return `🟠 RED-INCOMPLETE — не прогналось: ${v.skipped.join(', ')}${tail}`;
}
return `🔴 RED — ${v.failed.length}/${total} failed: ${v.failed.join(', ')}`;
}
// ── data: checks registry ──────────────────────────────────────────
// Script-based checks carry `cmd`; binary-based checks carry `bin` + `argv`.
// `parse(combinedOutput, exitCode)` → canonical token. `cwd`: '.' = repo root,
// 'app' = the Laravel app. Execution order: quick checks first, then heavy.
export const CHECKS = [
{
id: 'pint', label: 'Pint', tiers: ['quick', 'full'], cwd: 'app',
cmd: 'composer pint:test', parse: (_o, c) => parseExit('Pint', c),
},
{
id: 'eslint', label: 'ESLint', tiers: ['quick', 'full'], cwd: 'app',
cmd: 'npm run lint:vue', parse: (_o, c) => parseExit('ESLint', c),
},
{
id: 'prettier', label: 'Prettier', tiers: ['quick', 'full'], cwd: 'app',
cmd: 'npm run format:check', parse: (_o, c) => parseExit('Prettier', c),
},
{
id: 'vue-tsc', label: 'vue-tsc', tiers: ['quick', 'full'], cwd: 'app',
cmd: 'npm run type-check', parse: (_o, c) => parseExit('vue-tsc', c),
},
{
id: 'markdownlint', label: 'markdownlint', tiers: ['quick', 'full'], cwd: '.',
cmd: 'npm run lint:md', parse: (_o, c) => parseExit('markdownlint', c),
},
{
id: 'cspell', label: 'cspell', tiers: ['quick', 'full'], cwd: '.',
cmd: 'npm run spell', parse: (_o, c) => parseExit('cspell', c),
},
{
id: 'larastan', label: 'Larastan', tiers: ['full'], cwd: 'app',
cmd: 'composer stan', parse: (o) => parseLarastan(o),
},
{
id: 'pest', label: 'Pest', tiers: ['full'], cwd: 'app',
cmd: 'composer test:parallel', parse: (o) => parsePest(o),
},
{
id: 'vitest', label: 'Vitest', tiers: ['full'], cwd: 'app',
cmd: 'npm run test:vue', parse: (o) => parseVitest(o),
},
{
id: 'vite-build', label: 'Vite build', tiers: ['full'], cwd: 'app',
cmd: 'npm run build', parse: (o) => parseViteBuild(o),
},
{
id: 'lychee', label: 'lychee', tiers: ['full'], cwd: '.',
bin: 'lychee',
argv: ['--config', '.lychee.toml', 'docs/**/*.md', 'db/**/*.md', '*.md'],
parse: (o) => parseLychee(o),
},
{
id: 'gitleaks', label: 'gitleaks', tiers: ['full'], cwd: '.',
bin: 'gitleaks',
argv: ['detect', '--source', '.', '--no-banner', '--config', '.gitleaks.toml', '--redact'],
parse: (o, c) => parseGitleaks(o, c),
},
];
// ── I/O: run one check ─────────────────────────────────────────────
function runCheck(check, repoRoot) {
const cwd = check.cwd === '.' ? repoRoot : path.join(repoRoot, check.cwd);
const start = Date.now();
const skippedResult = (reason) => ({
id: check.id, label: check.label, skipped: true, code: null,
ms: Date.now() - start, token: `${check.label} SKIPPED`, stdout: '', stderr: reason,
});
let command;
if (check.bin) {
const bin = resolveBinary(check.bin);
// bin/ executables: existsSync pre-check on Windows (the project ships
// bin\gitleaks.exe / bin\lychee.exe; on POSIX they come from PATH).
if (process.platform === 'win32' && !existsSync(path.join(repoRoot, bin))) {
return skippedResult(`${bin} not found`);
}
command = [bin, ...check.argv].join(' ');
} else {
command = check.cmd;
}
const res = spawnSync(command, {
cwd, shell: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
});
const ms = Date.now() - start;
// ENOENT (POSIX missing binary), POSIX shell exit 127 ("command not found"),
// or the Windows cmd.exe "is not recognized" message → SKIPPED.
const notFound = (res.error && res.error.code === 'ENOENT')
|| res.status === 127
|| /is not recognized as an internal or external command/i.test(res.stderr ?? '');
if (notFound) {
return skippedResult(`command not found: ${command}`);
}
const stdout = res.stdout ?? '';
const stderr = res.stderr ?? '';
const code = res.status ?? 1;
const token = check.parse(`${stdout}\n${stderr}`, code);
return { id: check.id, label: check.label, skipped: false, code, ms, token, stdout, stderr };
}
// ── orchestrator ───────────────────────────────────────────────────
export function main(argv) {
const tier = argv[0] ?? 'full';
if (tier !== 'quick' && tier !== 'full') {
process.stderr.write(
`regression: unknown argument "${tier}". Usage: run.mjs [quick|full]\n`,
);
process.exitCode = 2;
return;
}
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url));
const checks = CHECKS.filter((c) => c.tiers.includes(tier));
process.stdout.write(`${buildHeader(tier)}\n`);
const results = [];
for (const check of checks) {
const r = runCheck(check, repoRoot);
results.push(r);
process.stdout.write(`${formatRow(r)}\n`);
}
process.stdout.write(`${'─'.repeat(48)}\n`);
process.stdout.write(`Canonical: ${buildCanonicalLine(results)}\n`);
const v = computeVerdict(results);
process.stdout.write(`VERDICT: ${verdictLine(v, results.length)}\n`);
// Full output of failed checks, so failures are visible with file:line.
for (const r of results) {
if (!r.skipped && r.code !== 0) {
process.stdout.write(`\n── ${r.label} output ──\n${r.stdout}\n${r.stderr}\n`);
}
}
process.exitCode = v.exitCode;
}
// Run main only when executed directly (not when imported by run.test.mjs).
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
main(process.argv.slice(2));
}
+213
View File
@@ -0,0 +1,213 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
import {
resolveBinary, buildHeader, parseExit,
parsePest, parseVitest,
parseViteBuild, parseLarastan, parseGitleaks, parseLychee,
computeVerdict,
buildCanonicalLine, formatRow, verdictLine,
CHECKS,
} from './run.mjs';
test('resolveBinary: win32 → bin\\<name>.exe', () => {
assert.equal(resolveBinary('gitleaks', 'win32'), 'bin\\gitleaks.exe');
});
test('resolveBinary: non-win32 → bare name on PATH', () => {
assert.equal(resolveBinary('lychee', 'linux'), 'lychee');
assert.equal(resolveBinary('lychee', 'darwin'), 'lychee');
});
test('buildHeader: starts with the tier banner', () => {
assert.ok(buildHeader('quick').startsWith('─ /regression quick '));
assert.ok(buildHeader('full').startsWith('─ /regression full '));
});
test('buildHeader: is padded with dashes', () => {
assert.ok(buildHeader('full').length >= 30);
});
test('parseExit: builds "<label> <code>" token', () => {
assert.equal(parseExit('Pint', 0), 'Pint 0');
assert.equal(parseExit('ESLint', 1), 'ESLint 1');
});
test('parsePest: passed + skipped, no failures → total derived', () => {
const out = ' Tests: 3 skipped, 739 passed (2104 assertions)\n Duration: 71.23s';
assert.equal(parsePest(out), 'Pest 742/739/3sk/0');
});
test('parsePest: with failures', () => {
const out = ' Tests: 2 failed, 1 skipped, 736 passed (2090 assertions)';
assert.equal(parsePest(out), 'Pest 739/736/1sk/2');
});
test('parsePest: passed only → zeros for skipped/failed', () => {
assert.equal(parsePest(' Tests: 19 passed (44 assertions)'), 'Pest 19/19/0sk/0');
});
test('parsePest: JSON format (pest --parallel) passed + skipped', () => {
const out = '{"tool":"pest","result":"passed","tests":793,"passed":790,"assertions":2391,"duration_ms":32200,"skipped":3}';
assert.equal(parsePest(out), 'Pest 793/790/3sk/0');
});
test('parsePest: JSON format with failures', () => {
const out = '{"tool":"pest","result":"failed","tests":793,"passed":788,"assertions":2380,"duration_ms":31000,"skipped":3}';
assert.equal(parsePest(out), 'Pest 793/788/3sk/2');
});
test('parsePest: JSON format no skipped', () => {
const out = '{"tool":"pest","result":"passed","tests":19,"passed":19,"assertions":44,"duration_ms":1711}';
assert.equal(parsePest(out), 'Pest 19/19/0sk/0');
});
test('parseVitest: files + passed + skipped', () => {
const out = ' Test Files 92 passed (92)\n Tests 774 passed | 3 skipped (777)\n Duration 12.6s';
assert.equal(parseVitest(out), 'Vitest 92f/774/3sk/0');
});
test('parseVitest: with failures, does not confuse "Test Files" with "Tests"', () => {
const out = ' Test Files 2 failed | 90 passed (92)\n Tests 5 failed | 769 passed (774)';
assert.equal(parseVitest(out), 'Vitest 90f/769/0sk/5');
});
test('parseViteBuild: extracts build time', () => {
assert.equal(parseViteBuild('✓ 312 modules transformed.\n✓ built in 2.03s'), 'Vite build 2.03s');
});
test('parseViteBuild: no match → "?"', () => {
assert.equal(parseViteBuild('build crashed'), 'Vite build ?s');
});
test('parseLarastan: clean → 0', () => {
assert.equal(parseLarastan(' [OK] No errors'), 'Larastan 0');
});
test('parseLarastan: counts errors', () => {
assert.equal(parseLarastan(' [ERROR] Found 2 errors'), 'Larastan 2');
});
test('parseGitleaks: clean → 0 leaks', () => {
const out = 'INF 442 commits scanned.\nINF no leaks found';
assert.equal(parseGitleaks(out, 0), 'gitleaks 0/442');
});
test('parseGitleaks: leaks found (non-zero exit)', () => {
const out = 'INF 442 commits scanned.\nWRN 3 leaks found';
assert.equal(parseGitleaks(out, 1), 'gitleaks 3/442');
});
test('parseLychee: OK + errors', () => {
const out = '🔍 325 Total (in 9s)\n✅ 325 OK\n🚫 0 Errors';
assert.equal(parseLychee(out), 'lychee 325/0');
});
test('parseLychee: with broken links', () => {
const out = '🔍 327 Total\n✅ 325 OK\n🚫 2 Errors';
assert.equal(parseLychee(out), 'lychee 325/2');
});
test('computeVerdict: all exit 0 → GREEN, exit code 0', () => {
const v = computeVerdict([
{ label: 'Pint', code: 0, skipped: false },
{ label: 'ESLint', code: 0, skipped: false },
]);
assert.equal(v.verdict, 'GREEN');
assert.equal(v.exitCode, 0);
assert.deepEqual(v.failed, []);
});
test('computeVerdict: one non-zero exit → RED, exit code 1', () => {
const v = computeVerdict([
{ label: 'Pint', code: 0, skipped: false },
{ label: 'Larastan', code: 1, skipped: false },
]);
assert.equal(v.verdict, 'RED');
assert.equal(v.exitCode, 1);
assert.deepEqual(v.failed, ['Larastan']);
});
test('computeVerdict: a skipped check → RED-INCOMPLETE', () => {
const v = computeVerdict([
{ label: 'Pint', code: 0, skipped: false },
{ label: 'gitleaks', code: null, skipped: true },
]);
assert.equal(v.verdict, 'RED-INCOMPLETE');
assert.equal(v.exitCode, 1);
assert.deepEqual(v.skipped, ['gitleaks']);
});
test('computeVerdict: skipped takes precedence over a failure', () => {
const v = computeVerdict([
{ label: 'Larastan', code: 1, skipped: false },
{ label: 'lychee', code: null, skipped: true },
]);
assert.equal(v.verdict, 'RED-INCOMPLETE');
assert.deepEqual(v.failed, ['Larastan']);
assert.deepEqual(v.skipped, ['lychee']);
});
test('buildCanonicalLine: joins tokens in result order with " / "', () => {
const results = [
{ token: 'Pint 0' }, { token: 'ESLint 0' }, { token: 'Pest 742/739/3sk/0' },
];
assert.equal(buildCanonicalLine(results), 'Pint 0 / ESLint 0 / Pest 742/739/3sk/0');
});
test('formatRow: passed check → ✅ mark, label, code, time', () => {
const row = formatRow({ label: 'Pint', code: 0, ms: 1800, skipped: false });
assert.ok(row.startsWith('[✅] Pint'));
assert.ok(row.includes('1.8s'));
});
test('formatRow: failed check → ❌ mark', () => {
assert.ok(formatRow({ label: 'Larastan', code: 1, ms: 8400, skipped: false }).startsWith('[❌] Larastan'));
});
test('formatRow: skipped check → ⚠ mark + SKIPPED', () => {
const row = formatRow({ label: 'gitleaks', code: null, ms: 0, skipped: true });
assert.ok(row.startsWith('[⚠] gitleaks'));
assert.ok(row.includes('SKIPPED'));
});
test('verdictLine: GREEN', () => {
const line = verdictLine({ verdict: 'GREEN', failed: [], skipped: [] }, 12);
assert.ok(line.includes('🟢 GREEN'));
assert.ok(line.includes('12'));
});
test('verdictLine: RED lists failed checks', () => {
const line = verdictLine({ verdict: 'RED', failed: ['Larastan'], skipped: [] }, 12);
assert.ok(line.includes('🔴 RED'));
assert.ok(line.includes('Larastan'));
});
test('verdictLine: RED-INCOMPLETE lists skipped checks', () => {
const line = verdictLine({ verdict: 'RED-INCOMPLETE', failed: [], skipped: ['gitleaks'] }, 12);
assert.ok(line.includes('🟠 RED-INCOMPLETE'));
assert.ok(line.includes('gitleaks'));
});
test('CHECKS: quick tier has exactly 6 checks', () => {
assert.equal(CHECKS.filter((c) => c.tiers.includes('quick')).length, 6);
});
test('CHECKS: full tier has exactly 12 checks', () => {
assert.equal(CHECKS.filter((c) => c.tiers.includes('full')).length, 12);
});
test('CHECKS: quick is a strict subset of full', () => {
const full = new Set(CHECKS.filter((c) => c.tiers.includes('full')).map((c) => c.id));
for (const c of CHECKS.filter((c) => c.tiers.includes('quick'))) {
assert.ok(full.has(c.id), `${c.id} in quick must also be in full`);
}
});
test('CHECKS: every check has id, label, cwd, parse, and a command source', () => {
for (const c of CHECKS) {
assert.ok(c.id && c.label && c.cwd, `${c.id}: id/label/cwd`);
assert.equal(typeof c.parse, 'function', `${c.id}: parse is a function`);
assert.ok(c.cmd || (c.bin && Array.isArray(c.argv)), `${c.id}: has cmd or bin+argv`);
}
});
test('CHECKS: ids are unique', () => {
assert.equal(new Set(CHECKS.map((c) => c.id)).size, CHECKS.length);
});
const RUN = fileURLToPath(new URL('./run.mjs', import.meta.url));
test('main: unknown argument → exit code 2 + error on stderr', () => {
try {
execFileSync(process.execPath, [RUN, 'bogus'], { encoding: 'utf8', stdio: 'pipe' });
assert.fail('expected non-zero exit');
} catch (err) {
assert.equal(err.status, 2);
assert.match(String(err.stderr), /unknown argument/i);
}
});
test('main: importing run.mjs does not auto-run the sweep', () => {
// If the import.meta guard were broken, importing run.mjs at the top of this
// file would have spawned a full sweep. Reaching this assertion proves it did not.
assert.ok(true);
});
+121
View File
@@ -0,0 +1,121 @@
---
name: rls-check
description: |
Verify Row-Level Security on a new or modified table in db/schema.sql.
Use when adding a new table, adding/removing tenant_id column, or modifying
RLS policies. Walks through 7-step checklist (tenant_id, ENABLE RLS, 2+ policies,
5-role GRANTs, db/CHANGELOG_schema.md entry, squawk, smoke test).
For reviewing a diff, branch, or PR with DB changes - use the rls-reviewer agent.
disable-model-invocation: true
---
# RLS-check — verify RLS на таблице
## Когда использовать
При добавлении или модификации таблицы в `db/schema.sql`, особенно перед коммитом. Инкапсулирует 7-item checklist; lefthook pre-commit job 7 (squawk) ловит только часть.
Invoke via `/rls-check <table_name>`.
## Граница с агентом rls-reviewer
`rls-check` (этот скил) и `rls-reviewer` (агент, `.claude/agents/rls-reviewer.md`)
оба проверяют RLS, но в разных ситуациях. Правило выбора:
- Знаешь имя одной конкретной таблицы, проверка вручную перед коммитом →
**`/rls-check <table>`** (этот скил).
- Есть diff / ветка / PR с изменениями БД, набор таблиц заранее не известен →
**агент `rls-reviewer`**.
Скил работает в основном контексте по одной названной таблице и прогоняет
**8 строк вывода** — 7 статических пунктов + живой дымовой тест
(`pest --filter RlsSmokeTest`, шаг 7). Агент работает в отдельном контексте
субагента, разбирает diff/миграцию/PR и прогоняет только **7 статических**
строк — дымовой тест намеренно не запускает.
Первые 7 строк вывода у обоих — общее статическое ядро (tenant_id, ENABLE RLS,
SELECT/ALL политики, GRANT'ы 5 ролей, CHANGELOG, squawk). Это не дублирование:
ядро проверок одно, сценарии вызова разные. Дымовой тест — только в скиле:
запуск Pest в ревью-субагенте медленный и задевает гонки `--parallel`
(квирки 72/77, см. `.claude/agents/pest-parallel-debugger.md`).
## Checklist
1. **tenant_id column.** Grep `db/schema.sql` для `CREATE TABLE <name>`. Verify:
- `tenant_id UUID NOT NULL REFERENCES tenants(id)` присутствует, **OR**
- SaaS-level exemption — explicit comment типа `-- SaaS-level: no tenant_id (justification)`.
```bash
grep -A30 "CREATE TABLE.*\b<name>\b" db/schema.sql | grep -E "tenant_id|SaaS-level"
```
2. **ENABLE RLS.** Должна быть строка `ALTER TABLE <name> ENABLE ROW LEVEL SECURITY;`.
```bash
grep -E "ALTER TABLE\s+<name>\s+ENABLE ROW LEVEL SECURITY" db/schema.sql
```
3. **Policies — минимум 2.**
- SELECT для `crm_app_user`/`crm_app_admin` с tenant scope: `USING (tenant_id = current_setting('app.current_tenant_id')::uuid)`.
- ALL для `crm_app_admin` (или per-table convention).
- SaaS-level: BYPASSRLS role pattern (e.g., `crm_supplier_worker`).
```bash
grep -B1 -A5 "ON <name>" db/schema.sql | grep "POLICY"
```
4. **Role GRANTs.** В `db/02_grants.sql` должны быть GRANT'ы для 5 ролей. Проверить по pattern existing tables.
```bash
grep -E "GRANT.*ON\s+<name>" db/02_grants.sql
```
Expected: ≥5 GRANT statements (по одному на роль) или group GRANT.
5. **CHANGELOG entry.** В `db/CHANGELOG_schema.md` должна быть запись с датой + table name + summary (CLAUDE.md §5 п.8).
```bash
grep "<name>" db/CHANGELOG_schema.md
```
6. **squawk lint.**
```bash
./bin/squawk.exe db/schema.sql 2>&1 | tail -10
```
Expected: exit 0, no issues.
7. **Smoke test.** `tests/Feature/RlsSmokeTest.php` (или новый тест для конкретной таблицы) должен assert'ить, что user в tenant A не видит row из tenant B для новой таблицы.
```bash
cd app && ./vendor/bin/pest --filter RlsSmokeTest 2>&1 | tail -10
```
Expected: all assertions pass.
## Output
Print результат per item + total:
```text
RLS-check: <table_name>
[✅] tenant_id column
[✅] ENABLE RLS
[✅] SELECT policy
[✅] ALL policy
[✅] 5-role GRANTs
[✅] CHANGELOG entry
[✅] squawk passes
[✅] smoke test passes
Pass: 8/8
```
Or failure listing: `[❌] tenant_id column missing — db/schema.sql:NNNN`.
## Не использовать когда
- Modifying existing well-RLS'd table без новых columns — overhead.
- Tables explicitly outside RLS (e.g., Laravel `migrations`, `cache` — internal).
- Проверяешь не одну названную таблицу, а diff/ветку/PR с изменениями БД —
это сценарий агента `rls-reviewer`, не скила.
+85
View File
@@ -0,0 +1,85 @@
name: Accessibility (Pa11y live)
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP 8.3
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: pdo, pdo_pgsql, redis, mbstring, intl, bcmath
coverage: none
- name: Setup Node 20
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install root JS deps
run: npm ci --no-audit --no-fund
- name: Install app composer deps
working-directory: app
run: composer install --no-progress --no-interaction --prefer-dist --optimize-autoloader
- name: Install app JS deps
working-directory: app
run: npm ci --no-audit --no-fund
- name: Bootstrap .env + key
working-directory: app
run: |
cp .env.example .env
php artisan key:generate --force
- name: Prepare SQLite for CI (avoid pg-on-CI fixture cost)
working-directory: app
run: |
touch database/database.sqlite
sed -i 's/DB_CONNECTION=.*/DB_CONNECTION=sqlite/' .env
sed -i 's|DB_DATABASE=.*|DB_DATABASE=/home/runner/work/${{ github.event.repository.name }}/${{ github.event.repository.name }}/app/database/database.sqlite|' .env
- name: Build frontend assets
working-directory: app
run: npm run build
- name: Start Laravel dev-server
working-directory: app
run: nohup php artisan serve --host=127.0.0.1 --port=8000 > /tmp/laravel-serve.log 2>&1 &
- name: Wait for dev-server ready
run: |
for i in {1..30}; do
if curl -s -o /dev/null http://127.0.0.1:8000/login; then
echo "Dev-server up after ${i}s"
exit 0
fi
sleep 1
done
echo "Dev-server did not start within 30s"
tail -50 /tmp/laravel-serve.log
exit 1
- name: Run Pa11y (live Vue)
run: npm run a11y
- name: Upload Pa11y screenshots
if: always()
uses: actions/upload-artifact@v4
with:
name: a11y-screenshots
path: bin/a11y-screenshots/
if-no-files-found: warn
retention-days: 14
+42
View File
@@ -145,3 +145,45 @@ app/playwright/node_modules/
# Vitest coverage output (app/coverage/) — генерируется npm run test:coverage
/app/coverage/
# ── Ruflo big-bang integration (2026-05-15) ──────────────────────────────────
# ruflo runtime scaffolding и local-only routing config
.claude-flow/
CLAUDE.local.md
# ruflo runtime state (created on activation 2026-05-15: memory DB + RuVector bridge)
.swarm/
ruvector.db
# CLAUDE.md / .claude/ backups перед npx ruflo init --force (плановые artifacts Task 2.1)
CLAUDE.md.pre-ruflo.bak
.claude.pre-ruflo.bak/
# ruflo install/dry-run logs (transient)
ruflo-init.log
ruflo-init-dryrun.log
ruflo-mcp-stdout.log
ruflo-mcp-stderr.log
# ruflo init --force regen'ит 23 subdirs из upstream IPFS-registry — auto-regenerable, не tracking
.claude/agents/analysis/
.claude/agents/architecture/
.claude/agents/browser/
.claude/agents/consensus/
.claude/agents/core/
.claude/agents/custom/
.claude/agents/data/
.claude/agents/development/
.claude/agents/devops/
.claude/agents/documentation/
.claude/agents/flow-nexus/
.claude/agents/github/
.claude/agents/goal/
.claude/agents/optimization/
.claude/agents/payments/
.claude/agents/sona/
.claude/agents/sparc/
.claude/agents/specialized/
.claude/agents/sublinear/
.claude/agents/swarm/
.claude/agents/templates/
.claude/agents/testing/
.claude/agents/v3/
.claude/commands/
.claude/helpers/
+19
View File
@@ -23,6 +23,25 @@
"command": "npx",
"args": ["-y", "semgrep-mcp"],
"comment": "Фаза 3 #25 — Semgrep MCP (SAST). Семантический поиск/анализ кода через Semgrep rules в Claude Code. Пакет: npmjs.com/package/semgrep-mcp — если 404, запустить 'npm search semgrep mcp' для актуального имени."
},
"sentry": {
"command": "npx",
"args": ["-y", "@sentry/mcp-server"],
"env": {
"SENTRY_URL": "${SENTRY_URL}",
"SENTRY_AUTH_TOKEN": "${SENTRY_AUTH_TOKEN}"
},
"comment": "Off-phase tool — Sentry MCP для self-hosted экземпляра в Yandex Cloud (CLAUDE.md §2). Pending формализация в Tooling §3.3 #34 — sync нормативки отдельным планом. Package: @sentry/mcp-server@0.33.0+ (official sentry-bot, repo getsentry/sentry-mcp, bin sentry-mcp). Env vars: SENTRY_URL (https://sentry.<your-domain>.ru), SENTRY_AUTH_TOKEN (PAT scope: sentry:read). Credentials в .env.local (gitignored), Claude Code считывает env из shell startup. Если env пустые — MCP server fail gracefully."
},
"redis": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-redis", "redis://localhost:6379"],
"comment": "Off-phase tool — Redis MCP для Memurai (Windows service, Redis 7-совместимый, localhost:6379). Pending формализация в Tooling §3.3 #35 — sync нормативки отдельным планом. Package: @modelcontextprotocol/server-redis@2025.4.25 — DEPRECATED по статусу npm («Package no longer supported»), но Anthropic source, простой протокол, рабочий. Post-MVP migration на community alternative (e.g., @easy-mcps/redis-mcp-server@1.0.8 или @wenit/redis-mcp-server@1.0.3) когда подтвердим trust. READ-ONLY use — отладка очередей, кэша, Pest --parallel race (memory quirk 72). НЕ для prod (нет prod). Если в будущем prod Redis с auth — отдельный entry redis-prod с url через env var."
},
"ruflo": {
"command": "npx",
"args": ["-y", "ruflo@latest", "mcp", "start"],
"comment": "Off-phase orchestration MCP — exposes ~210 ruflo tools (Core/Intelligence/Agents/Memory/DevTools). Package: ruflo v3.7.0-alpha.38+ MIT (npm `ruflo`, repo ruvnet/claude-flow legacy after rename Jan-2026; plugin namespace @claude-flow/*). Plugin discovery via IPFS (CID QmeXmAdbWVvT84GfDXPD2Vg1HWhiTW2VdZfRLhkS96KkX2) — Pinata+Cloudflare gateways flaky 2026-05-15, only ipfs.io reliable. stdio mode (no port-conflict). Big-bang integration per spec/plan 2026-05-15-ruflo-integration-design.md (commit a68a0a0+). Pending формализация в Tooling §4.10 — Phase 3 Task 3.4."
}
}
}
+42 -13
View File
File diff suppressed because one or more lines are too long
+334
View File
@@ -0,0 +1,334 @@
# Лидерра CRM — Production Deployment Runbook
**Version:** 1.0 от 2026-05-13
**Stack:** Laravel 13 · Vue 3 + Vuetify 3 · PostgreSQL 16 · Redis 7 · PHP 8.3
**Cloud:** Yandex Cloud, region `ru-central1`
---
## 1. System Requirements
| Component | Version | Notes |
|---|---|---|
| PHP | 8.3+ | Extensions: pdo_pgsql, pgsql, redis, bcmath, mbstring, openssl, tokenizer, xml, ctype, fileinfo, pcntl |
| PostgreSQL | 16 | ICU collation support required (`--with-icu` compile flag) |
| Redis | 7.x | Sessions, queues, cache |
| Node.js | 20+ | Frontend build only |
| Composer | 2.x | |
| Supervisor | 4.x | Queue worker process management |
---
## 2. Environment Configuration
Copy `.env.example` to `.env` and set all required values:
```bash
cp app/.env.example app/.env
```
Critical variables:
```ini
APP_ENV=production
APP_KEY= # php artisan key:generate
APP_URL=https://crm.example.com
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=liderra
DB_USERNAME=crm_migrator # migration role — full DDL rights
DB_PASSWORD=<secret>
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=<secret>
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
CACHE_STORE=redis
MAIL_MAILER=smtp
MAIL_HOST=smtp.unisender.com
MAIL_PORT=587
MAIL_USERNAME=<unisender-go-api-key>
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@liderra.ru
MAIL_FROM_NAME="Лидерра"
```
---
## 3. Database Setup
### 3.1 Create database with ICU collation
```sql
-- Run as PostgreSQL superuser
CREATE DATABASE liderra
ENCODING 'UTF8'
LOCALE_PROVIDER 'icu'
ICU_LOCALE 'ru-RU'
TEMPLATE template0;
```
### 3.2 Create application roles
```bash
# Run as PostgreSQL superuser
psql -U postgres liderra < db/00_create_roles.sql
```
The script creates 5 roles: `crm_app_user`, `crm_app_admin`, `crm_readonly`, `crm_migrator`, `crm_supplier_worker` (BYPASSRLS).
### 3.3 Run migrations
```bash
cd app
php artisan migrate --force
```
This loads `db/schema.sql` (v8.19+) via the single bootstrap migration `load_initial_schema.php`.
### 3.4 Apply grants
```bash
psql -U postgres liderra < db/02_grants.sql
```
### 3.5 Create initial partition tables
Partitioned tables (`lead_audit_log`, `supplier_session_log`, etc.) require month-based child partitions to exist before any inserts:
```bash
cd app
php artisan partitions:create-months
```
Run this once after migration. The scheduler maintains partitions automatically thereafter (see §7).
### 3.6 Apply pg_audit extension (pre-prod)
```sql
-- Run as PostgreSQL superuser
CREATE EXTENSION IF NOT EXISTS pgaudit;
```
---
## 4. Application Bootstrap
```bash
cd app
# Install PHP dependencies
composer install --no-dev --optimize-autoloader
# Generate app key (first deploy only)
php artisan key:generate --force
# Clear and cache config/routes/views
php artisan config:cache
php artisan route:cache
php artisan view:cache
# Run storage symlink
php artisan storage:link
```
---
## 5. Frontend Build
```bash
cd app
npm ci
npm run build
```
Output goes to `app/public/build/`. Confirm `app/public/build/manifest.json` exists.
---
## 6. Queue Worker (Supervisor)
Create `/etc/supervisor/conf.d/liderra-worker.conf`:
```ini
[program:liderra-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /path/to/app/artisan queue:work redis --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/liderra-worker.log
stopwaitsecs=3600
```
```bash
supervisorctl reread
supervisorctl update
supervisorctl start liderra-worker:*
```
---
## 7. Scheduler (Cron)
Add to the deployment user's crontab (`crontab -e`):
```cron
* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1
```
The scheduler runs these jobs automatically:
| Command/Job | Schedule | Purpose |
|---|---|---|
| `projects:reset-delivered-today` | daily 00:00 MSK | Reset daily lead counter |
| `projects:reset-monthly` | 1st of month 00:00 MSK | Reset monthly counter for tier lookup |
| `partitions:create-months` | daily | Create PostgreSQL partition tables for upcoming months |
| `RefreshSupplierSessionJob` | hourly + 20:15 MSK | Supplier API session tokens |
| `SyncSupplierProjectsJob` | 20:30 MSK daily | Sync supplier project list |
| `CleanupInactiveSupplierProjectsJob` | 02:00 MSK daily | Archive stale supplier projects |
| `supplier:retry-failed` | hourly | Retry failed supplier lead deliveries |
| `CsvReconcileJob` | hourly | CSV reconciliation (reserve lead intake channel) |
---
## 8. Web Server (Nginx)
```nginx
server {
listen 443 ssl;
server_name crm.example.com;
root /path/to/app/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~* \.(js|css|png|jpg|svg|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
---
## 9. Health Checks
```bash
# PHP and Laravel bootstrap
cd app && php artisan about
# Database connection
php artisan db:show
# Scheduler registered entries
php artisan schedule:list
# Queue worker status
supervisorctl status liderra-worker:*
# Redis connection
redis-cli -h 127.0.0.1 ping
# Application HTTP
curl -I https://crm.example.com/login
```
Expected responses:
- `php artisan about` — no errors, APP_ENV=production
- `php artisan schedule:list` — 9 entries including `partitions:create-months`
- Redis: `PONG`
- HTTP `/login`: 200
---
## 10. Deployment Sequence (Rolling Update)
```bash
# 1. Pull latest code
git pull origin main
# 2. Install/update dependencies
cd app && composer install --no-dev --optimize-autoloader
npm ci && npm run build
# 3. Run new migrations (if any)
php artisan migrate --force
# 4. Recache configuration
php artisan config:cache
php artisan route:cache
php artisan view:cache
# 5. Restart queue workers (pick up new code)
supervisorctl restart liderra-worker:*
```
---
## 11. Rollback
```bash
# Revert to previous release tag
git checkout <previous-tag>
cd app
composer install --no-dev --optimize-autoloader
npm ci && npm run build
php artisan migrate:rollback # only if the migration is reversible
php artisan config:cache
supervisorctl restart liderra-worker:*
```
> **Warning:** Schema migrations are not always reversible. Always take a PostgreSQL dump before deploying schema changes.
```bash
pg_dump -U crm_migrator liderra > backup_$(date +%Y%m%d_%H%M%S).sql
```
---
## 12. Development Seed
For staging/dev environments only:
```bash
cd app
php artisan db:seed --class=DemoSeeder --force
```
Creates: `admin@demo.local` / `password`, 3 projects, 14 demo deals.
**Never run DemoSeeder on production.**
---
## 13. Common Issues
| Symptom | Likely Cause | Fix |
|---|---|---|
| `SQLSTATE[08006]` on boot | Wrong `DB_HOST` (use `127.0.0.1`, not `localhost` on Windows) | Set `DB_HOST=127.0.0.1` |
| Partition insert error on new month | `partitions:create-months` not run | `php artisan partitions:create-months` |
| Queue jobs not processing | Supervisor not running or wrong user | `supervisorctl status`; check `stdout_logfile` |
| CSS/JS 404 after deploy | Frontend not rebuilt or `storage:link` missing | `npm run build` + `php artisan storage:link` |
| `jwt expired` from supplier API | Supplier session not refreshed | `php artisan tinker``dispatch(new RefreshSupplierSessionJob)` |
| Scheduler not running | Cron not set up | Verify crontab entry; `php artisan schedule:run --verbose` |
+1 -1
View File
@@ -50,7 +50,7 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
QUEUE_CONNECTION=redis
CACHE_STORE=database
# CACHE_PREFIX=
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ApiKey;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* API-ключи тенанта (audit D2/D3/J5). Endpoints под auth:sanctum + tenant.
*
* Полный ключ показывается ОДИН раз в ответе regenerate(). В БД хранится
* только bcrypt key_hash + key_prefix (первые 10 символов для UI). У тенанта
* поддерживается один активный ключ: regenerate деактивирует прежние.
*/
class ApiKeyController extends Controller
{
private const KEY_PREFIX = 'lpkapi_';
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
// Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
$keys = ApiKey::query()
->where('tenant_id', $tenantId)
->where('is_active', true)
->where('expires_at', '>', now())
->orderByDesc('created_at')
->get(['id', 'name', 'key_prefix', 'last_used_at', 'expires_at', 'created_at']);
return response()->json(['data' => $keys]);
}
public function regenerate(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$userId = (int) $request->user()->id;
// Один активный ключ на тенанта — прежние деактивируются.
ApiKey::query()
->where('tenant_id', $tenantId)
->where('is_active', true)
->update(['is_active' => false]);
$plainKey = self::KEY_PREFIX.Str::random(48);
$key = ApiKey::query()->create([
'tenant_id' => $tenantId,
'user_id' => $userId,
'name' => 'API-ключ',
'key_hash' => Hash::make($plainKey),
'key_prefix' => substr($plainKey, 0, 10),
'scopes' => ['read'],
'expires_at' => now()->addYear(),
'is_active' => true,
'created_at' => now(),
]);
return response()->json([
'id' => $key->id,
'name' => $key->name,
'key' => $plainKey,
'key_prefix' => $key->key_prefix,
], Response::HTTP_CREATED);
}
}
@@ -228,6 +228,31 @@ class AuthController extends Controller
]);
}
/**
* PATCH /api/auth/me обновление профиля текущего пользователя
* (имя, фамилия, телефон, тайм-зона). Email менять нельзя (через support).
*
* Audit J6/D1 (ProfileTab). Зеркалит updateNotificationPreferences:
* та же группа auth:sanctum, тот же inline-validate, тот же userResource.
*/
public function updateProfile(Request $request): JsonResponse
{
$validated = $request->validate([
'first_name' => ['required', 'string', 'max:255'],
'last_name' => ['required', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
'timezone' => ['required', 'timezone'],
]);
/** @var User $user */
$user = $request->user();
$user->update($validated);
return response()->json([
'user' => $this->userResource($user->fresh()),
]);
}
/**
* Ключ throttle для login: email|ip защищает email от брутфорса даже
* за NAT'ом, и IP от перебора емейлов с одного источника.
@@ -333,6 +358,8 @@ class AuthController extends Controller
'email' => $user->email,
'first_name' => $user->first_name,
'last_name' => $user->last_name,
'phone' => $user->phone,
'timezone' => $user->timezone,
'tenant_id' => $user->tenant_id,
'totp_enabled' => $user->totp_enabled,
'last_login_at' => $user->last_login_at,
@@ -0,0 +1,186 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\BillingTopupService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Биллинг тенанта кошелёк, транзакции, счета, пополнение (audit E1/E3).
*
* Все эндпоинты под middleware [auth:sanctum, tenant] (RLS-контекст).
* Отдельно от TenantChargesController (lead_charges ledger) и
* AdminBillingController (SaaS-уровневые агрегаты).
*
* E1: POST /api/billing/topup MVP-stub пополнения (без платёжного шлюза).
* E3: GET wallet/transactions/invoices данные для BillingView Overview.
*/
class BillingController extends Controller
{
public function __construct(
private readonly BillingTopupService $topupService,
) {}
/**
* POST /api/billing/topup пополнить рублёвый баланс.
*
* MVP-stub: кредитует баланс немедленно (без ЮKassa реальная оплата
* post-Б-1). Записывает append-only строку balance_transactions(topup).
*/
public function topup(Request $request): JsonResponse
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'min:100', 'max:1000000', 'decimal:0,2'],
]);
/** @var User $user */
$user = $request->user();
// Нормализуем в DECIMAL-строку scale 2 для bcmath (НЕ float).
$amountRub = bcadd((string) $validated['amount_rub'], '0', 2);
$tx = $this->topupService->topup((int) $user->tenant_id, $amountRub, (int) $user->id);
return response()->json([
'transaction' => [
'id' => $tx->id,
'type' => $tx->type,
'amount_rub' => $tx->amount_rub,
'balance_rub_after' => $tx->balance_rub_after,
'created_at' => $tx->created_at,
],
'balance_rub' => $tx->balance_rub_after,
], 201);
}
/**
* GET /api/billing/wallet балансы тенанта + текущий тариф + runway.
*/
public function wallet(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
/** @var Tenant $tenant */
$tenant = Tenant::query()->with('tariff')->findOrFail((int) $user->tenant_id);
return response()->json([
'balance_rub' => $tenant->balance_rub,
'balance_leads' => $tenant->balance_leads,
'runway_days' => $this->runwayDays($tenant),
'tariff' => $tenant->tariff === null ? null : [
'code' => $tenant->tariff->code,
'name' => $tenant->tariff->name,
'price_monthly' => $tenant->tariff->price_monthly,
'billing_model' => $tenant->tariff->billing_model,
'features' => $tenant->tariff->features ?? [],
],
]);
}
/**
* GET /api/billing/transactions?type=topup|lead_charge|refund&page=N
* пагинированная история balance_transactions тенанта (20/страница).
*/
public function transactions(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
$tenantId = (int) $user->tenant_id;
// Явный tenant_id фильтр — defense-in-depth поверх RLS (тесты идут
// под superuser BYPASSRLS; паттерн TenantChargesController).
$query = BalanceTransaction::query()
->where('tenant_id', $tenantId)
->orderBy('created_at', 'desc')
->orderBy('id', 'desc');
$type = $request->query('type');
if (is_string($type) && in_array($type, ['topup', 'lead_charge', 'refund'], true)) {
$query->where('type', $type);
}
$page = $query->paginate(20);
return response()->json([
'data' => array_map(static fn (BalanceTransaction $tx): array => [
'id' => $tx->id,
'code' => 'TX-'.$tx->id,
'type' => $tx->type,
'description' => $tx->description,
'amount_rub' => $tx->amount_rub,
'amount_leads' => $tx->amount_leads,
'balance_rub_after' => $tx->balance_rub_after,
'created_at' => $tx->created_at,
], $page->items()),
'meta' => [
'current_page' => $page->currentPage(),
'last_page' => $page->lastPage(),
'total' => $page->total(),
'per_page' => $page->perPage(),
],
]);
}
/**
* GET /api/billing/invoices счета тенанта (saas_invoices).
*
* Real-but-empty на MVP: saas_invoices.legal_entity_id NOT NULL требует
* зарегистрированного юр-лица (блокируется Б-1). Read-only выборка через
* DB::table без Eloquent-модели (паттерн AdminBillingController).
*/
public function invoices(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->user();
$tenantId = (int) $user->tenant_id;
$rows = DB::table('saas_invoices')
->where('tenant_id', $tenantId)
->orderBy('issued_at', 'desc')
->get(['id', 'invoice_number', 'amount_total', 'status', 'issued_at', 'pdf_path']);
return response()->json([
'data' => $rows->map(static fn (\stdClass $r): array => [
'id' => $r->id,
'invoice_number' => $r->invoice_number,
'amount_total' => $r->amount_total,
'status' => $r->status,
'issued_at' => $r->issued_at,
'has_pdf' => $r->pdf_path !== null,
])->all(),
]);
}
/**
* Прогноз «на сколько дней хватит баланса» оценочный UX-показатель.
*
* = balance_rub / (рублёвые списания за 30 дней / 30). NULL, если списаний
* не было. Float здесь допустим: грубая оценка для шапки, НЕ мутация
* баланса (мутации баланса строго bcmath, см. BillingTopupService).
* Отрицательный баланс 0 (тенант уже в минусе, runway не может быть < 0).
*/
private function runwayDays(Tenant $tenant): ?int
{
$spent = abs((float) DB::table('balance_transactions')
->where('tenant_id', $tenant->id)
->where('type', BalanceTransaction::TYPE_LEAD_CHARGE)
->where('created_at', '>=', now()->subDays(30))
->sum('amount_rub'));
if ($spent <= 0.0) {
return null;
}
$perDay = $spent / 30.0;
return max(0, (int) floor((float) $tenant->balance_rub / $perDay));
}
}
@@ -123,15 +123,21 @@ class ImpersonationController extends Controller
]);
// TODO: отправить email на $tenant->contact_email с $plainCode.
// На MVP возвращаем code в response для тестов / dev (на prod НЕ должно
// возвращаться никогда — токен только в email клиента).
return response()->json([
$payload = [
'token_id' => $token->id,
'expires_at' => $token->expires_at->toIso8601String(),
'sent_to_email' => $token->sent_to_email,
// dev-only field — на prod исчезает после интеграции с MailService.
'_dev_plain_code' => $plainCode,
]);
];
// Audit-fix A2: plain-код возвращается в API-ответе ТОЛЬКО на dev/testing
// (для тестов и локальной разработки). На prod код уходит исключительно
// в email клиента — env-guard исключает захват impersonation-сессии
// через чтение ответа init.
if (app()->environment('local', 'testing')) {
$payload['_dev_plain_code'] = $plainCode;
}
return response()->json($payload);
}
/** POST /api/admin/impersonation/verify */
@@ -10,6 +10,7 @@ use App\Models\SupplierLead;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\RateLimiter;
use Symfony\Component\HttpFoundation\IpUtils;
/**
@@ -40,6 +41,9 @@ use Symfony\Component\HttpFoundation\IpUtils;
*/
class SupplierWebhookController extends Controller
{
/** Audit-fix C2: per-IP rate-limit (DoS-guard), запросов в минуту. */
private const RATE_LIMIT_PER_MINUTE = 600;
public function receive(Request $request, string $secret): JsonResponse
{
if (! $this->verifySecret($secret)) {
@@ -50,6 +54,20 @@ class SupplierWebhookController extends Controller
return response()->json(['message' => 'Not found.'], 404);
}
// Audit-fix C2: per-IP rate-limit. Endpoint secret-gated, но защищаем
// от flood даже с валидным secret (DoS-guard). Лимит с запасом для
// легитимного stream'а лидов от crm.bp-gr.ru.
$rateKey = 'supplier-webhook:'.($request->ip() ?? 'unknown');
if (RateLimiter::tooManyAttempts($rateKey, self::RATE_LIMIT_PER_MINUTE)) {
$retryAfter = RateLimiter::availableIn($rateKey);
return response()->json([
'message' => 'Превышен лимит запросов.',
'retry_after' => $retryAfter,
], 429)->header('Retry-After', (string) $retryAfter);
}
RateLimiter::hit($rateKey, 60);
// Plan 2.6 fix #iii: timestamp partition guard. Партиции deals месячные
// (deals_2026_MM); time за пределами текущего месяца → INSERT CRASH
// "no partition of relation deals found for row" в RouteSupplierLeadJob.
@@ -117,17 +117,19 @@ class WebhookReceiveController extends Controller
}
/**
* HMAC-обязательность. Если ключ отсутствует в БД default false
* (backward-compat для существующих интеграций).
* HMAC-обязательность. Audit-fix B3: если ключ отсутствует в БД default
* TRUE (HMAC обязателен по умолчанию). Отключить можно только явной
* установкой webhook_hmac_required=false. Неизвестное значение fail-secure
* (HMAC требуется).
*/
private function isHmacRequired(): bool
{
$setting = SystemSetting::find('webhook_hmac_required');
if ($setting === null) {
return false;
return true;
}
return in_array($setting->value, ['true', '1'], true);
return ! in_array($setting->value, ['false', '0'], true);
}
/**
@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\OutboundWebhookSubscription;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
/**
* Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
* Endpoints под auth:sanctum + tenant.
*
* Одна подписка-ряд на тенанта. Секрет генерируется при создании и
* показывается ОДИН раз (в БД bcrypt secret_hash + secret_prefix).
*
* test(): MVP делает unsigned connectivity-проверку (реальный POST на
* target_url, отчёт по HTTP-статусу). HMAC-подписанная доставка событий
* отдельный пост-MVP эпик (outbound-pipeline пока не построен).
*/
class WebhookSettingsController extends Controller
{
private const SECRET_PREFIX = 'whsec_';
/** @var list<string> События по умолчанию для новой подписки. */
private const DEFAULT_EVENTS = ['deal.created', 'deal.status_changed'];
public function show(Request $request): JsonResponse
{
$sub = $this->currentSubscription($request);
if ($sub === null) {
return response()->json(['data' => null]);
}
return response()->json(['data' => [
'target_url' => $sub->target_url,
'secret_prefix' => $sub->secret_prefix,
'events' => $sub->events,
'is_active' => $sub->is_active,
]]);
}
public function update(Request $request): JsonResponse
{
$validated = $request->validate([
'target_url' => ['required', 'string', 'url', 'max:2048', 'starts_with:https://'],
]);
$sub = $this->currentSubscription($request);
$plainSecret = null;
if ($sub === null) {
$plainSecret = self::SECRET_PREFIX.Str::random(40);
$sub = OutboundWebhookSubscription::query()->create([
'tenant_id' => (int) $request->user()->tenant_id,
'user_id' => (int) $request->user()->id,
'name' => 'Webhook',
'target_url' => $validated['target_url'],
'secret_hash' => Hash::make($plainSecret),
'secret_prefix' => substr($plainSecret, 0, 10),
'events' => self::DEFAULT_EVENTS,
'is_active' => true,
]);
} else {
$sub->update(['target_url' => $validated['target_url']]);
}
$payload = [
'target_url' => $sub->target_url,
'secret_prefix' => $sub->secret_prefix,
'events' => $sub->events,
'is_active' => $sub->is_active,
];
if ($plainSecret !== null) {
$payload['secret'] = $plainSecret;
}
return response()->json(['data' => $payload]);
}
public function test(Request $request): JsonResponse
{
$sub = $this->currentSubscription($request);
if ($sub === null) {
return response()->json([
'message' => 'Сначала сохраните URL webhook.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$testPayload = [
'event' => 'webhook.test',
'sent_at' => now()->toIso8601String(),
'message' => 'Тестовая доставка webhook от Лидерра.',
];
// MVP: unsigned connectivity-проверка. SSRF-харднинг (блок приватных
// IP) — пост-MVP security-review; URL уже ограничен https:// валидацией.
try {
$response = Http::timeout(10)
->withHeaders(['X-Webhook-Event' => 'webhook.test'])
->post($sub->target_url, $testPayload);
return response()->json([
'ok' => $response->successful(),
'status' => $response->status(),
'message' => $response->successful()
? "Тестовый запрос доставлен (HTTP {$response->status()})."
: "Endpoint ответил HTTP {$response->status()}.",
]);
} catch (\Throwable $e) {
return response()->json([
'ok' => false,
'status' => null,
'message' => 'Не удалось доставить тестовый запрос: '.$e->getMessage(),
]);
}
}
private function currentSubscription(Request $request): ?OutboundWebhookSubscription
{
$tenantId = (int) $request->user()->tenant_id;
// Defense-in-depth: явный where даже при RLS — в тестах PG superuser BYPASSRLS.
return OutboundWebhookSubscription::query()
->where('tenant_id', $tenantId)
->orderByDesc('id')
->first();
}
}
+4 -1
View File
@@ -66,7 +66,10 @@ class SetTenantContext
}
}
if ($request->hasHeader('X-Tenant-Id')) {
// Audit-fix A3: X-Tenant-Id принимается ТОЛЬКО на dev/testing. На prod
// заголовок игнорируется — иначе на любом роуте с `tenant`, но без
// auth-middleware возможен спуфинг тенанта произвольным значением.
if (app()->environment('local', 'testing') && $request->hasHeader('X-Tenant-Id')) {
$headerValue = $request->header('X-Tenant-Id');
if (is_string($headerValue) && ctype_digit($headerValue)) {
return (int) $headerValue;
+66
View File
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\ApiKeyFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* API-ключ тенанта (таблица api_keys). Tenant-aware, RLS на уровне БД.
*
* key_hash bcrypt-хэш; оригинал ключа показывается ОДИН раз при генерации
* (ApiKeyController::regenerate). key_prefix (10 символов) для отображения
* в UI. Таблица имеет только created_at (без updated_at).
*
* @mixin IdeHelperApiKey
*/
class ApiKey extends Model
{
/** @use HasFactory<ApiKeyFactory> */
use HasFactory;
public $timestamps = false;
protected $fillable = [
'tenant_id',
'user_id',
'name',
'key_hash',
'key_prefix',
'scopes',
'last_used_at',
'last_used_ip',
'expires_at',
'is_active',
'created_at',
];
protected $hidden = ['key_hash'];
protected function casts(): array
{
return [
'scopes' => 'array',
'is_active' => 'boolean',
'last_used_at' => 'datetime',
'expires_at' => 'datetime',
'created_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+5
View File
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Models;
use Database\Factories\BalanceTransactionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -19,6 +21,9 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
*/
class BalanceTransaction extends Model
{
/** @use HasFactory<BalanceTransactionFactory> */
use HasFactory;
public const TYPE_TRIAL_BONUS = 'trial_bonus';
public const TYPE_TOPUP = 'topup';
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\OutboundWebhookSubscriptionFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Исходящая webhook-подписка тенанта (таблица outbound_webhook_subscriptions).
*
* Tenant-aware, RLS на уровне БД.
*
* secret_hash bcrypt-хэш; оригинал секрета показывается ОДИН раз при
* создании. events JSONB-массив, CHECK требует ≥1 элемента.
*
* NB: outbound-доставка событий (подписанные webhook'и) пост-MVP; пока
* подписка хранит URL + секрет, а WebhookSettingsController::test делает
* unsigned connectivity-проверку.
*
* @mixin IdeHelperOutboundWebhookSubscription
*/
class OutboundWebhookSubscription extends Model
{
/** @use HasFactory<OutboundWebhookSubscriptionFactory> */
use HasFactory;
protected $fillable = [
'tenant_id',
'user_id',
'name',
'target_url',
'secret_hash',
'secret_prefix',
'events',
'custom_headers',
'is_active',
'paused_at',
];
protected $hidden = ['secret_hash'];
protected function casts(): array
{
return [
'events' => 'array',
'custom_headers' => 'array',
'is_active' => 'boolean',
'consecutive_failures' => 'integer',
'paused_at' => 'datetime',
'last_delivery_at' => 'datetime',
'last_failure_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
/** @return BelongsTo<User, $this> */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
+54
View File
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* Тарифный план SaaS-портала (каталог tariff_plans).
*
* Сидится из db/schema.sql (4 стартовых плана: start/basic/pro/enterprise).
* Read-mostly: редактируется только админкой SaaS. Tenant ссылается через
* tenants.current_tariff_id (см. Tenant::tariff()).
*
* Источник: db/schema.sql §20.2.1, table `tariff_plans`.
*
* @mixin IdeHelperTariffPlan
*/
class TariffPlan extends Model
{
protected $fillable = [
'code',
'name',
'description',
'billing_model',
'price_per_lead',
'price_monthly',
'included_leads',
'limits',
'features',
'trial_bonus_leads',
'is_active',
'is_public',
'sort_order',
];
protected function casts(): array
{
return [
'price_per_lead' => 'decimal:2',
'price_monthly' => 'decimal:2',
'included_leads' => 'integer',
'limits' => 'array',
'features' => 'array',
'trial_bonus_leads' => 'integer',
'is_active' => 'boolean',
'is_public' => 'boolean',
'sort_order' => 'integer',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
}
+7
View File
@@ -7,6 +7,7 @@ namespace App\Models;
use Database\Factories\TenantFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -80,4 +81,10 @@ class Tenant extends Model
{
return $this->hasMany(Project::class);
}
/** @return BelongsTo<TariffPlan, $this> */
public function tariff(): BelongsTo
{
return $this->belongsTo(TariffPlan::class, 'current_tariff_id');
}
}
@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Services\Billing;
use App\Models\BalanceTransaction;
use App\Models\Tenant;
/**
* Сервис пополнения рублёвого баланса тенанта (audit E1).
*
* MVP-stub: кредитует tenants.balance_rub немедленно и пишет строку
* balance_transactions(type='topup'). Реальная оплата через платёжный
* шлюз post-Б-1 (требует реквизитов ООО), здесь НЕ интегрирована.
*
* Контракт: вызывается ВНУТРИ транзакции (middleware `tenant` оборачивает
* HTTP-запрос в DB-транзакцию). lockForUpdate на строке tenant защищает от
* lost-update при конкурентных topup/charge.
*
* balance_transactions защищена hash-chain триггером (BEFORE INSERT
* audit_chain_hash) log_hash заполняется автоматически. UPDATE/DELETE
* на таблице запрещены триггером audit_block_mutation, поэтому каждое
* пополнение отдельная append-only строка; существующие не меняются.
*/
final class BillingTopupService
{
/**
* Пополнить рублёвый баланс тенанта.
*
* @param int $tenantId ID тенанта.
* @param string $amountRub Сумма пополнения, DECIMAL-строка («100.00»).
* @param int|null $userId Кто инициировал (NULL системное).
* @return BalanceTransaction Созданная append-only строка ledger'а.
*/
public function topup(int $tenantId, string $amountRub, ?int $userId): BalanceTransaction
{
/** @var Tenant $tenant */
$tenant = Tenant::query()->lockForUpdate()->findOrFail($tenantId);
// bcadd — DECIMAL-точность, НЕ PHP float (паттерн LedgerService).
$newBalanceRub = bcadd((string) $tenant->balance_rub, $amountRub, 2);
// Eloquent decimal:2 cast сохраняет bcmath-строку без потери точности
// при save() (в отличие от decrement(), который требует float|int —
// именно поэтому LedgerService использует raw DB::table()->update();
// здесь же присваивание уже посчитанной строки через модель безопасно).
$tenant->balance_rub = $newBalanceRub;
$tenant->save();
return BalanceTransaction::create([
'tenant_id' => $tenant->id,
'type' => BalanceTransaction::TYPE_TOPUP,
'amount_rub' => $amountRub,
'amount_leads' => 0,
'balance_rub_after' => $newBalanceRub,
'balance_leads_after' => (int) $tenant->balance_leads,
'description' => 'Пополнение баланса',
'user_id' => $userId,
'created_at' => now(),
]);
}
}
+1
View File
@@ -61,6 +61,7 @@
],
"pint": "@php vendor/bin/pint",
"pint:test": "@php vendor/bin/pint --test",
"test:parallel": "@php vendor/bin/pest --parallel --recreate-databases",
"stan": "@php vendor/bin/phpstan analyse --memory-limit=512M",
"mutation": "@php vendor/bin/infection --threads=2 --min-msi=50",
"audit-offline": "@composer audit --locked",
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ApiKey;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<ApiKey>
*/
class ApiKeyFactory extends Factory
{
protected $model = ApiKey::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'user_id' => User::factory(),
'name' => 'API-ключ',
'key_hash' => Hash::make(Str::random(48)),
'key_prefix' => 'lpkapi_'.Str::lower(Str::random(3)),
'scopes' => ['read'],
'last_used_at' => null,
'expires_at' => now()->addYear(),
'is_active' => true,
'created_at' => now(),
];
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\BalanceTransaction;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<BalanceTransaction>
*/
class BalanceTransactionFactory extends Factory
{
protected $model = BalanceTransaction::class;
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'type' => BalanceTransaction::TYPE_TOPUP,
'amount_rub' => '100.00',
'amount_leads' => 0,
'balance_rub_after' => '100.00',
'balance_leads_after' => 0,
'description' => 'Тестовая транзакция',
'created_at' => now(),
];
}
}
@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\OutboundWebhookSubscription;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<OutboundWebhookSubscription>
*/
class OutboundWebhookSubscriptionFactory extends Factory
{
protected $model = OutboundWebhookSubscription::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'user_id' => User::factory(),
'name' => 'Webhook',
'target_url' => 'https://'.fake()->domainName().'/webhook',
'secret_hash' => Hash::make('whsec_'.Str::random(40)),
'secret_prefix' => 'whsec_'.Str::lower(Str::random(4)),
'events' => ['deal.created', 'deal.status_changed'],
'is_active' => true,
];
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ class ProjectFactory extends Factory
{
return [
'tenant_id' => Tenant::factory(),
'name' => fake()->words(3, true),
'name' => fake()->unique()->words(3, true),
'type' => 'webhook',
'is_active' => true,
'daily_limit_target' => 10,
+7 -4
View File
@@ -12,13 +12,16 @@ class DatabaseSeeder extends Seeder
/**
* Seed the application's database.
*
* Note: the Laravel scaffold default User::factory() seed was removed
* наша схема использует first_name/last_name (а не "name"), и заранее
* не было сценария, где этот seed реально вызывался. PricingTierSeeder
* (Plan 4) единственный текущий seed для dev/testing.
* PricingTierSeeder runs in all environments (prod нуждается в 7-tier
* config bootstrap'е). DemoSeeder только local+testing: создаёт demo
* tenant + admin@demo.local + 3 проекта + ~14 demo сделок для UI smoke.
*/
public function run(): void
{
$this->call(PricingTierSeeder::class);
if (app()->environment('local', 'testing')) {
$this->call(DemoSeeder::class);
}
}
}
+7 -3
View File
@@ -1,10 +1,14 @@
import type { KnipConfig } from 'knip';
const config: KnipConfig = {
entry: ['resources/js/app.ts', 'resources/js/router/index.ts'],
entry: [
'resources/js/app.ts',
'resources/js/router/index.ts',
'histoire.config.ts',
'resources/js/histoire.setup.ts',
],
project: ['resources/js/**/*.{ts,vue}'],
ignore: ['**/*.story.vue', 'tests/**'],
ignoreDependencies: ['@vue/test-utils', 'jsdom', 'vitest'],
ignore: ['**/*.story.vue'],
};
export default config;
-138
View File
@@ -15,7 +15,6 @@
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.10",
"axios": "^1.16.0",
"concurrently": "^9.0.1",
"cross-env": "^10.1.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
@@ -4319,36 +4318,6 @@
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/change-case": {
"version": "5.4.4",
"resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.4.tgz",
@@ -4394,21 +4363,6 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -4470,31 +4424,6 @@
"node": ">=14"
}
},
"node_modules/concurrently": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
"integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.8.3",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/config-chain": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
@@ -7975,16 +7904,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
@@ -9214,16 +9133,6 @@
"node": ">=20"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
@@ -10103,24 +10012,6 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
@@ -10222,35 +10113,6 @@
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
-1
View File
@@ -24,7 +24,6 @@
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.10",
"axios": "^1.16.0",
"concurrently": "^9.0.1",
"cross-env": "^10.1.0",
"eslint": "^10.3.0",
"eslint-config-prettier": "^10.1.8",
+156 -42
View File
@@ -78,12 +78,6 @@ parameters:
count: 1
path: app/Http/Middleware/SetTenantContext.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 1
path: app/Http/Resources/ProjectResource.php
-
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
@@ -102,18 +96,18 @@ parameters:
count: 1
path: app/Services/NotificationService.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 1
path: app/Services/Project/ProjectService.php
-
message: '#^Match expression does not handle remaining value\: string$#'
identifier: match.unhandled
count: 1
path: app/Services/Project/ProjectService.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
count: 1
path: database/factories/BalanceTransactionFactory.php
-
message: '#^Return type \(array\<string, mixed\>\) of method Database\\Factories\\ProjectFactory\:\:definition\(\) should be compatible with return type \(array\<model property of App\\Models\\Project, mixed\>\) of method Illuminate\\Database\\Eloquent\\Factories\\Factory\<App\\Models\\Project\>\:\:definition\(\)$#'
identifier: method.childReturnType
@@ -252,6 +246,36 @@ parameters:
count: 14
path: tests/Feature/Api/ProjectBulkActionsTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 7
path: tests/Feature/ApiKeyControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 5
path: tests/Feature/ApiKeyControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/ApiKeyControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/ApiKeyControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/ApiKeyControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
@@ -463,16 +487,58 @@ parameters:
path: tests/Feature/Auth/TwoFactorTest.php
-
message: '#^Access to an undefined property App\\Models\\LeadCharge\:\:\$charge_source\.$#'
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Billing/LedgerServiceTest.php
count: 1
path: tests/Feature/Auth/UpdateProfileTest.php
-
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#'
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Billing/LedgerServiceTest.php
count: 4
path: tests/Feature/Auth/UpdateProfileTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Auth/UpdateProfileTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Auth/UpdateProfileTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
identifier: method.notFound
count: 6
path: tests/Feature/Auth/UpdateProfileTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 12
path: tests/Feature/Billing/BillingOverviewControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/Billing/BillingOverviewControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/BillingOverviewControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 18
path: tests/Feature/Billing/BillingOverviewControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$ledger\.$#'
@@ -528,6 +594,36 @@ parameters:
count: 1
path: tests/Feature/Billing/TenantChargesControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 5
path: tests/Feature/Billing/TopupControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Billing/TopupControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/TopupControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:assertDatabaseHas\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Billing/TopupControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 8
path: tests/Feature/Billing/TopupControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
@@ -540,12 +636,6 @@ parameters:
count: 2
path: tests/Feature/Console/ResetDeliveredTodayCommandTest.php
-
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#'
identifier: method.notFound
@@ -882,12 +972,6 @@ parameters:
count: 6
path: tests/Feature/Plan5/Jobs/SyncSupplierProjectJobTest.php
-
message: '#^Access to an undefined property App\\Models\\Project\:\:\$archived_at\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Plan5/Projects/ProjectsActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
@@ -1116,18 +1200,6 @@ parameters:
count: 7
path: tests/Feature/Supplier/RetryFailedSupplierJobsCommandTest.php
-
message: '#^Access to an undefined property App\\Models\\LeadCharge\:\:\$charge_source\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
-
message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:seed\(\)\.$#'
identifier: method.notFound
@@ -1158,6 +1230,48 @@ parameters:
count: 14
path: tests/Feature/WebhookReceiveTest.php
-
message: '#^Access to an undefined property Pest\\Mixins\\Expectation\<mixed\>\:\:\$not\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/WebhookSettingsControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 7
path: tests/Feature/WebhookSettingsControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 5
path: tests/Feature/WebhookSettingsControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/WebhookSettingsControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/WebhookSettingsControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/WebhookSettingsControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:putJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/WebhookSettingsControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$resolver\.$#'
identifier: property.notFound
+40
View File
@@ -34,3 +34,43 @@ body {
.v-field-label {
--v-medium-emphasis-opacity: 0.7;
}
/*
* A11y rescan 2026-05-14: Vuetify tonal-variant default text color produces
* 2.0-4.4:1 contrast on ivory page background (#f6f3ec) below WCAG 2.1 AA
* 4.5:1 threshold. Pa11y rescan flagged across /billing /admin/billing
* /admin/incidents /admin/system. Fix: darken text color inside .v-alert and
* .v-chip tonal variants; also darken .text-warning utility used in count
* badges (text-h6 text-warning «5» on ivory was 2.03:1).
*/
.v-alert--variant-tonal .v-alert__content,
.v-alert--variant-tonal .v-alert__content strong,
.v-alert--variant-tonal .v-alert__content code {
color: #0a0700;
}
.v-chip--variant-tonal.bg-success .v-chip__content,
.v-chip--variant-tonal.text-success .v-chip__content {
/* deep forest green, ≥4.5:1 on tonal pale-success bg */
color: #1f5e3a;
}
.v-chip--variant-tonal.bg-warning .v-chip__content,
.v-chip--variant-tonal.text-warning .v-chip__content {
/* dark amber, ≥4.5:1 on tonal pale-warning bg + on ivory page bg */
color: #6a4504;
}
/*
* .text-warning is used both inside chips (covered above) and standalone
* (text-h6 count badges on ivory background). Vuetify defines the utility as
* `.v-theme--liderraForest .text-warning { color: rgb(var(--v-theme-warning)) !important }`
* which has specificity 0,2,0 + !important plain `.text-warning !important`
* (0,1,0) loses on specificity even with !important. Match Vuetify's selector
* exactly so our override wins on cascade-order (loaded after Vuetify CSS).
*/
.v-theme--liderraForest .text-warning,
.v-theme--liderraForest.text-warning,
.text-warning {
color: #6a4504 !important;
}
+4 -4
View File
@@ -113,7 +113,7 @@ export interface AdminTenant {
created_at: string | null;
}
export interface AdminTenantsStats {
interface AdminTenantsStats {
total: number;
active: number;
trial: number;
@@ -182,7 +182,7 @@ export interface ApiTenantActivityEvent {
created_at: string;
}
export interface ApiTenantMetrics {
interface ApiTenantMetrics {
leads_today: number;
leads_this_week: number;
leads_this_month: number;
@@ -224,7 +224,7 @@ export interface ApiAdminBillingTenant {
chargeback_unrecovered_rub: string;
}
export interface ApiAdminBillingSummary {
interface ApiAdminBillingSummary {
total_mrr_rub: string;
monthly_revenue_rub: string;
overdue_count: number;
@@ -262,7 +262,7 @@ export interface ApiAdminIncident {
rkn_deadline_at: string | null;
}
export interface ApiAdminIncidentsSummary {
interface ApiAdminIncidentsSummary {
open: number;
investigating: number;
rkn_pending: number;
+32
View File
@@ -0,0 +1,32 @@
import { apiClient, ensureCsrfCookie } from './client';
/**
* API-ключи тенанта (audit D2/D3). Backend: ApiKeyController.
* Полный ключ доступен только в ответе regenerateApiKey().
*/
export interface ApiKeyInfo {
id: number;
name: string;
key_prefix: string;
last_used_at: string | null;
expires_at: string | null;
created_at: string | null;
}
export interface RegeneratedApiKey {
id: number;
name: string;
key: string;
key_prefix: string;
}
export async function listApiKeys(): Promise<ApiKeyInfo[]> {
const { data } = await apiClient.get<{ data: ApiKeyInfo[] }>('/api/api-keys');
return data.data;
}
export async function regenerateApiKey(): Promise<RegeneratedApiKey> {
await ensureCsrfCookie();
const { data } = await apiClient.post<RegeneratedApiKey>('/api/api-keys/regenerate');
return data;
}
+15
View File
@@ -25,6 +25,8 @@ export interface AuthUser {
email: string;
first_name: string | null;
last_name: string | null;
phone?: string | null;
timezone?: string | null;
tenant_id: number;
totp_enabled: boolean;
last_login_at: string | null;
@@ -151,3 +153,16 @@ export async function updateNotificationPreferences(payload: UpdateNotificationP
const { data } = await apiClient.patch<{ user: AuthUser }>('/api/auth/me/notification-preferences', payload);
return data.user;
}
export interface UpdateProfilePayload {
first_name: string;
last_name: string;
phone: string | null;
timezone: string;
}
export async function updateProfile(payload: UpdateProfilePayload): Promise<AuthUser> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ user: AuthUser }>('/api/auth/me', payload);
return data.user;
}
+90
View File
@@ -0,0 +1,90 @@
import { apiClient, ensureCsrfCookie } from './client';
/**
* API-модуль биллинга (Sprint 2 Plan C).
*
* Эндпоинты под [auth:sanctum, tenant]: GET wallet/transactions/invoices
* (E3), POST topup (E1 добавляется в Task 5). GET'ы не требуют CSRF-cookie.
*/
/** Тариф в составе ответа GET /api/billing/wallet. */
export interface WalletTariff {
code: string;
name: string;
price_monthly: string | null;
billing_model: string;
features: string[];
}
/** Ответ GET /api/billing/wallet — кошелёк тенанта. */
export interface Wallet {
balance_rub: string;
balance_leads: number;
runway_days: number | null;
tariff: WalletTariff | null;
}
/** GET /api/billing/wallet — балансы + текущий тариф + runway. */
export async function getWallet(): Promise<Wallet> {
const { data } = await apiClient.get<Wallet>('/api/billing/wallet');
return data;
}
/** Строка истории транзакций (GET /api/billing/transactions). */
export interface BillingTransaction {
id: number;
code: string;
type: string;
description: string | null;
amount_rub: string;
amount_leads: number;
balance_rub_after: string | null;
created_at: string;
}
/** Пагинированный ответ GET /api/billing/transactions. */
export interface TransactionsPage {
data: BillingTransaction[];
meta: { current_page: number; last_page: number; total: number; per_page: number };
}
/** Счёт тенанта (GET /api/billing/invoices). */
export interface BillingInvoice {
id: number;
invoice_number: string;
amount_total: string;
status: string;
issued_at: string;
has_pdf: boolean;
}
/** GET /api/billing/transactions — пагинированная история транзакций. */
export async function getTransactions(params: { page?: number; type?: string }): Promise<TransactionsPage> {
const { data } = await apiClient.get<TransactionsPage>('/api/billing/transactions', { params });
return data;
}
/** GET /api/billing/invoices — счета тенанта (real-but-empty до Б-1). */
export async function getInvoices(): Promise<{ data: BillingInvoice[] }> {
const { data } = await apiClient.get<{ data: BillingInvoice[] }>('/api/billing/invoices');
return data;
}
/** Результат POST /api/billing/topup. */
export interface TopupResult {
transaction: {
id: number;
type: string;
amount_rub: string;
balance_rub_after: string | null;
created_at: string;
};
balance_rub: string;
}
/** POST /api/billing/topup — пополнить рублёвый баланс (MVP-stub). */
export async function topup(amountRub: number): Promise<TopupResult> {
await ensureCsrfCookie();
const { data } = await apiClient.post<TopupResult>('/api/billing/topup', { amount_rub: amountRub });
return data;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { apiClient, ensureCsrfCookie } from './client';
* Mutating-вызовы (mark-read/mark-all-read/destroy) делают ensureCsrfCookie().
*/
export type NotificationEvent =
type NotificationEvent =
| 'new_lead'
| 'reminder'
| 'low_balance'
+5 -5
View File
@@ -12,11 +12,11 @@ import { apiClient, ensureCsrfCookie } from './client';
export type ApiReportStatus = 'pending' | 'processing' | 'done' | 'failed';
export type ApiReportType = 'deals_export' | 'managers_summary' | 'sources_summary' | 'billing_summary';
type ApiReportType = 'deals_export' | 'managers_summary' | 'sources_summary' | 'billing_summary';
export type ApiReportFormat = 'csv' | 'xlsx' | 'json' | 'pdf';
type ApiReportFormat = 'csv' | 'xlsx' | 'json' | 'pdf';
export interface ApiReportParameters {
interface ApiReportParameters {
format: ApiReportFormat;
date_from: string;
date_to: string;
@@ -43,14 +43,14 @@ export interface ApiReportJob {
retry_max: number;
}
export interface ReportCounts {
interface ReportCounts {
pending: number;
processing: number;
done: number;
failed: number;
}
export interface ReportQuota {
interface ReportQuota {
active: number;
max_active: number;
}
+40
View File
@@ -0,0 +1,40 @@
import { apiClient, ensureCsrfCookie } from './client';
/**
* Настройки исходящего webhook'а тенанта (audit D4/D5). Backend:
* WebhookSettingsController. Полный secret доступен только в ответе
* saveWebhookSettings() при первом создании подписки.
*/
export interface WebhookSettings {
target_url: string;
secret_prefix: string;
events: string[];
is_active: boolean;
}
export interface SavedWebhookSettings extends WebhookSettings {
secret?: string;
}
export interface WebhookTestResult {
ok: boolean;
status: number | null;
message: string;
}
export async function getWebhookSettings(): Promise<WebhookSettings | null> {
const { data } = await apiClient.get<{ data: WebhookSettings | null }>('/api/tenants/me/webhook-settings');
return data.data;
}
export async function saveWebhookSettings(payload: { target_url: string }): Promise<SavedWebhookSettings> {
await ensureCsrfCookie();
const { data } = await apiClient.put<{ data: SavedWebhookSettings }>('/api/tenants/me/webhook-settings', payload);
return data.data;
}
export async function testWebhook(): Promise<WebhookTestResult> {
await ensureCsrfCookie();
const { data } = await apiClient.post<WebhookTestResult>('/api/webhooks/test');
return data;
}
+1 -1
View File
@@ -2,7 +2,7 @@
/**
* Корневой shell приложения. Мапит meta.layout текущего route'а на layout-компонент.
*
* meta.layout = 'auth' AuthLayout (двухпанельный для login/register/2fa/forgot/recovery).
* meta.layout = 'auth' AuthLayout (двухпанельный для login/register/2fa/forgot/recovery-use).
* meta.layout = 'error' RouterView напрямую (ErrorView сам предоставляет v-app + теало-нуар bg).
* meta.layout не задан или 'app' AppLayout (sidebar + topbar для авторизованных страниц).
*
@@ -0,0 +1,84 @@
<script setup lang="ts">
/**
* Глобальный индикатор активных impersonation-сессий (audit B5 / Ю-1).
*
* Размещён в AdminLayout над <RouterView> виден на всех /admin/* страницах.
* На MVP saas-admin auth нет и реального переключения сессии нет, поэтому
* показываем счётчик ВСЕХ активных сессий (impersonationActive() =
* used_at != null AND session_ended_at == null). Polling 30 c сессия может
* стартовать/завершиться, пока админ остаётся в админке (AdminLayout
* persistent, перемонтируется только <RouterView>).
*
* Если активных сессий 0 компонент не рендерит ничего.
*/
import { computed, onMounted, ref } from 'vue';
import { impersonationActive, type ImpersonationActiveSession } from '../../api/admin';
import { usePolling } from '../../composables/usePolling';
const sessions = ref<ImpersonationActiveSession[]>([]);
async function load(): Promise<void> {
try {
sessions.value = await impersonationActive();
} catch {
// Баннер не критичен ошибку детально покажет AdminImpersonationView.
// Сохраняем прежнее значение sessions, не падаем.
}
}
const count = computed(() => sessions.value.length);
const label = computed(() => {
if (count.value === 1) {
const s = sessions.value[0];
return `Активна impersonation-сессия: ${s.tenant_name ?? `тенант #${s.tenant_id}`}`;
}
return `Активны impersonation-сессии: ${count.value}`;
});
onMounted(load);
usePolling(load, { intervalMs: 30_000 });
defineExpose({ sessions, load });
</script>
<template>
<div v-if="count > 0" class="impersonation-banner" role="status" data-testid="impersonation-banner">
<v-icon size="16" class="impersonation-banner__icon">mdi-account-switch</v-icon>
<span class="impersonation-banner__label">{{ label }}</span>
<RouterLink
to="/admin/impersonation"
class="impersonation-banner__link"
data-testid="impersonation-banner-link"
>
Открыть
</RouterLink>
</div>
</template>
<style scoped>
.impersonation-banner {
display: flex;
align-items: center;
gap: 8px;
background: #fff4e0;
border-bottom: 1px solid #f0d8a8;
color: #8a5a00;
font-size: 13px;
padding: 8px 24px;
}
.impersonation-banner__icon {
color: #b87400;
}
.impersonation-banner__label {
flex: 1;
}
.impersonation-banner__link {
color: #0f6e56;
font-weight: 600;
text-decoration: none;
}
.impersonation-banner__link:hover {
text-decoration: underline;
}
</style>
@@ -117,7 +117,8 @@ const emit = defineEmits<{
align-items: center;
}
.page-meta .sep {
color: #92907b;
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
}
.head-actions {
display: flex;
@@ -81,7 +81,8 @@ function formatRub(v: number): string {
align-items: center;
}
.page-stats .sep {
color: #92907b;
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
@@ -40,7 +40,7 @@ function statusColor(s: TenantStatus): string {
{ title: 'Желаем×факт сегодня', key: 'today', align: 'end', sortable: false },
{ title: 'MRR', key: 'mrrRub', align: 'end', sortable: false },
{ title: 'Активность', key: 'activitySince', sortable: false },
{ title: '', key: 'actions', align: 'end', sortable: false, width: 56 },
{ title: 'Действия', key: 'actions', align: 'end', sortable: false, width: 56 },
]"
items-per-page="-1"
hide-default-footer
@@ -78,7 +78,11 @@ function statusColor(s: TenantStatus): string {
<span class="num text-medium-emphasis">{{ item.activitySince }}</span>
</template>
<template #[`item.actions`]="{ item }: { item: AdminTenant }">
<v-tooltip text="Войти как клиент (impersonation)" location="top">
<v-tooltip
text="Войти как клиент (impersonation)"
location="top"
aria-label="Войти как клиент (impersonation)"
>
<template #activator="{ props: tipProps }">
<v-btn
v-bind="tipProps"
@@ -1,15 +1,27 @@
<script setup lang="ts">
/**
* BalanceCard 3 wallet-cards в одной строке: Кошелёк (primary, dark) +
* Баланс лидов + Тариф. Sprint 4 Phase B/2 split BillingView (audit O-refactor-04 хвост).
* BalanceCard 3 wallet-cards в одной строке: Кошелёк (dark) +
* Баланс лидов + Тариф. Данные из GET /api/billing/wallet (E3).
* tariff* допускают null (тенант без назначенного тарифа trial).
*/
defineProps<{
import { computed } from 'vue';
const props = defineProps<{
walletRub: number;
leadsBalance: number;
tariffName: string;
tariffPrice: number;
tariffName: string | null;
tariffPrice: string | null;
tariffFeatures: string[];
}>();
defineEmits<{ topup: [] }>();
const walletText = computed(() => new Intl.NumberFormat('ru-RU').format(props.walletRub));
const tariffPriceText = computed(() => {
if (props.tariffPrice === null) return 'по запросу';
return new Intl.NumberFormat('ru-RU').format(Number(props.tariffPrice)) + ' ₽/мес';
});
</script>
<template>
@@ -21,12 +33,19 @@ defineProps<{
<v-chip size="x-small" color="primary" variant="elevated">LIVE</v-chip>
</div>
<div class="wallet-amount mt-2">
<span class="num">{{ new Intl.NumberFormat('ru-RU').format(walletRub) }}</span>
<span class="num">{{ walletText }}</span>
<span class="ru">&nbsp;</span>
</div>
<div class="wallet-foot mt-3">мин. пополнение <strong>100 </strong> · округление вниз лиды</div>
<div class="wallet-actions mt-3">
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" size="small">Пополнить</v-btn>
<v-btn
color="primary"
variant="flat"
prepend-icon="mdi-plus"
size="small"
@click="$emit('topup')"
>Пополнить</v-btn
>
<v-btn variant="outlined" prepend-icon="mdi-autorenew" size="small"> Автопополнение </v-btn>
</div>
</v-card>
@@ -41,22 +60,24 @@ defineProps<{
<span class="num">{{ leadsBalance }}</span>
<span class="ru-text">&nbsp;лидов</span>
</div>
<div class="wallet-foot mt-3">средняя цена <strong>50 /лид</strong> · потрачено за месяц 412</div>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card variant="outlined" class="wallet-card pa-4 d-flex flex-column">
<span class="wallet-label">Тариф</span>
<div class="tariff-name mt-1">
{{ tariffName }}
<span class="tariff-price">· {{ tariffPrice }} /мес</span>
</div>
<ul class="tariff-feats mt-3">
<li v-for="f in tariffFeatures" :key="f">
<v-icon size="14" color="success" class="mr-1">mdi-check</v-icon>{{ f }}
</li>
</ul>
<template v-if="tariffName">
<div class="tariff-name mt-1">
{{ tariffName }}
<span class="tariff-price">· {{ tariffPriceText }}</span>
</div>
<ul v-if="tariffFeatures.length" class="tariff-feats mt-3">
<li v-for="f in tariffFeatures" :key="f">
<v-icon size="14" color="success" class="mr-1">mdi-check</v-icon>{{ f }}
</li>
</ul>
</template>
<div v-else class="tariff-empty mt-2">Тариф не выбран</div>
<v-btn variant="outlined" size="small" class="mt-auto">Сменить тариф </v-btn>
</v-card>
</v-col>
@@ -137,6 +158,10 @@ defineProps<{
font-weight: 500;
margin-left: 4px;
}
.tariff-empty {
color: #66635c;
font-size: 14px;
}
.tariff-feats {
list-style: none;
padding: 0;
@@ -1,29 +1,84 @@
<script setup lang="ts">
/**
* InvoicesTable список счетов и УПД (PDF / 1С 8.3 XML).
* Sprint 4 Phase B/2 split BillingView.
* InvoicesTable список счетов тенанта. Данные GET /api/billing/invoices
* (E3). Real-but-empty до Б-1: на MVP saas_invoices пуста (нужно
* зарегистрированное юр-лицо), компонент показывает empty-state.
*/
import { MOCK_INVOICES } from '../../composables/mockBilling';
import { formatIcon, formatLabel, formatPlain } from '../../composables/billingFormatters';
import { ref, onMounted } from 'vue';
import { getInvoices, type BillingInvoice } from '../../api/billing';
import { formatPlain } from '../../composables/billingFormatters';
const invoices = ref<BillingInvoice[]>([]);
const loading = ref(true);
const loadError = ref<string | null>(null);
const STATUS_LABELS: Record<string, string> = {
draft: 'Черновик',
issued: 'Выставлен',
paid: 'Оплачен',
overdue: 'Просрочен',
cancelled: 'Отменён',
};
function statusLabel(status: string): string {
return STATUS_LABELS[status] ?? status;
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('ru-RU', { timeZone: 'Europe/Moscow' });
}
async function load(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
invoices.value = (await getInvoices()).data;
} catch {
loadError.value = 'Не удалось загрузить счета.';
} finally {
loading.value = false;
}
}
onMounted(load);
defineExpose({ load, invoices });
</script>
<template>
<v-card variant="outlined" class="mt-4 panel">
<div class="panel-h pa-4">
<h2 class="text-h6 panel-title ma-0">Счета и УПД</h2>
<v-btn variant="outlined" size="small" prepend-icon="mdi-download">Реестр XLSX</v-btn>
<h2 class="text-h6 panel-title ma-0">Счета</h2>
</div>
<v-divider />
<ul class="invoices-list pa-2 ma-0">
<li v-for="inv in MOCK_INVOICES" :key="inv.id" class="inv-row">
<span class="inv-when num">{{ inv.when }}</span>
<div v-if="loading" class="py-8 d-flex justify-center">
<v-progress-circular indeterminate color="primary" size="28" />
</div>
<v-alert v-else-if="loadError" type="error" variant="tonal" density="compact" class="ma-4" role="alert">
{{ loadError }}
</v-alert>
<div v-else-if="invoices.length === 0" class="empty pa-8 text-center text-medium-emphasis">
Счета появятся после первой оплаты.
</div>
<ul v-else class="invoices-list pa-2 ma-0">
<li v-for="inv in invoices" :key="inv.id" class="inv-row">
<span class="inv-when num">{{ formatDate(inv.issued_at) }}</span>
<span class="inv-name">
{{ inv.title }}
<span class="sub">{{ inv.sub }}</span>
{{ inv.invoice_number }}
<span class="sub">{{ statusLabel(inv.status) }}</span>
</span>
<span class="inv-amount num">{{ formatPlain(inv.amountRub) }}</span>
<v-btn variant="text" size="small" :prepend-icon="formatIcon(inv.format)">
{{ formatLabel(inv.format) }}
<span class="inv-amount num">{{ formatPlain(Number(inv.amount_total)) }}</span>
<v-btn
variant="text"
size="small"
prepend-icon="mdi-file-pdf-box"
:disabled="!inv.has_pdf"
>
PDF
</v-btn>
</li>
</ul>
@@ -52,6 +107,10 @@ import { formatIcon, formatLabel, formatPlain } from '../../composables/billingF
letter-spacing: -0.01em;
}
.empty {
font-size: 14px;
}
.invoices-list {
list-style: none;
padding: 0;
@@ -0,0 +1,125 @@
<script setup lang="ts">
/**
* TopupDialog диалог пополнения рублёвого баланса (audit E1).
*
* MVP-stub: POST /api/billing/topup кредитует баланс немедленно (без
* платёжного шлюза реальная оплата post-Б-1). При успехе эмитит
* `success` с новым балансом и закрывается.
*/
import { ref, computed, watch } from 'vue';
import { topup } from '../../api/billing';
import { extractErrorMessage, extractValidationErrors } from '../../api/client';
const model = defineModel<boolean>({ required: true });
const emit = defineEmits<{ success: [balanceRub: string] }>();
const PRESETS = [1000, 5000, 10000, 25000];
const amount = ref<number | null>(null);
const submitting = ref(false);
const errorMsg = ref<string | null>(null);
const amountError = computed<string | null>(() => {
if (amount.value === null || !Number.isFinite(amount.value)) return null;
if (amount.value < 100) return 'Минимум 100 ₽';
if (amount.value > 1000000) return 'Максимум 1 000 000 ₽';
return null;
});
const canSubmit = computed(
() => Number.isFinite(amount.value) && amountError.value === null && !submitting.value,
);
// Сброс состояния при каждом открытии диалога (паттерн ReminderDialog/
// NewDealDialog) нет префилла прошлой суммы и нет всплытия устаревшей ошибки.
watch(model, (open) => {
if (open) {
amount.value = null;
errorMsg.value = null;
}
});
function setPreset(value: number): void {
amount.value = value;
}
async function submit(): Promise<void> {
if (!canSubmit.value || amount.value === null) return;
submitting.value = true;
errorMsg.value = null;
try {
const res = await topup(amount.value);
emit('success', res.balance_rub);
model.value = false;
amount.value = null;
} catch (e) {
const validation = extractValidationErrors(e);
errorMsg.value = validation?.amount_rub?.[0] ?? extractErrorMessage(e);
} finally {
submitting.value = false;
}
}
function close(): void {
if (submitting.value) return;
model.value = false;
errorMsg.value = null;
}
defineExpose({ amount, submit, canSubmit, errorMsg });
</script>
<template>
<v-dialog v-model="model" max-width="460">
<v-card>
<v-card-title class="text-h6">Пополнить баланс</v-card-title>
<v-card-text>
<v-text-field
v-model.number="amount"
type="number"
label="Сумма пополнения"
suffix="₽"
density="comfortable"
:error-messages="amountError ?? undefined"
autofocus
/>
<div class="presets mb-2">
<v-chip
v-for="p in PRESETS"
:key="p"
size="small"
variant="outlined"
@click="setPreset(p)"
>
{{ new Intl.NumberFormat('ru-RU').format(p) }}
</v-chip>
</div>
<v-alert type="info" variant="tonal" density="compact" class="mt-2">
Платёжный шлюз подключается после регистрации юр. лица на текущем этапе баланс
пополняется сразу.
</v-alert>
<v-alert v-if="errorMsg" type="error" variant="tonal" density="compact" class="mt-3" role="alert">
{{ errorMsg }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="submitting" @click="close">Отмена</v-btn>
<v-btn color="primary" variant="flat" :loading="submitting" :disabled="!canSubmit" @click="submit">
Пополнить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>
@@ -1,63 +1,155 @@
<script setup lang="ts">
/**
* TransactionsTable VDataTable истории транзакций с табами фильтрации
* (Все / Пополнения / Списания / Возвраты). Sprint 4 Phase B/2 split BillingView.
* TransactionsTable server-driven история транзакций с табами
* (Все / Пополнения / Списания / Возвраты). Данные GET
* /api/billing/transactions (E3). Паттерн self-fetching из ChargesTab.
*/
import { computed, ref } from 'vue';
import { BILLING_TABS, MOCK_TRANSACTIONS, type BillingTransaction } from '../../composables/mockBilling';
import { formatCost, statusChipColor, statusLabel, txAmountClass } from '../../composables/billingFormatters';
import { ref, onMounted } from 'vue';
import { getTransactions, type BillingTransaction } from '../../api/billing';
import { formatCost, txAmountClass } from '../../composables/billingFormatters';
const activeTab = ref<(typeof BILLING_TABS)[number]['id']>('all');
interface Tab {
id: string;
label: string;
type: string | null;
}
const filteredTransactions = computed<BillingTransaction[]>(() => {
const tab = BILLING_TABS.find((t) => t.id === activeTab.value);
const types = tab?.types;
if (!types) return MOCK_TRANSACTIONS;
return MOCK_TRANSACTIONS.filter((tx) => types.includes(tx.type));
});
const TABS: Tab[] = [
{ id: 'all', label: 'Все', type: null },
{ id: 'topup', label: 'Пополнения', type: 'topup' },
{ id: 'lead_charge', label: 'Списания', type: 'lead_charge' },
{ id: 'refund', label: 'Возвраты', type: 'refund' },
];
const activeTab = ref<string>('all');
const rows = ref<BillingTransaction[]>([]);
const total = ref(0);
const loading = ref(false);
const loadError = ref<string | null>(null);
const page = ref(1);
const headers = [
{ title: 'Дата', key: 'created_at', sortable: false },
{ title: 'Операция', key: 'description', sortable: false },
{ title: 'ID', key: 'code', sortable: false, width: 120 },
{ title: 'Сумма', key: 'amount_rub', align: 'end' as const, sortable: false, width: 140 },
];
function formatWhen(iso: string): string {
return new Date(iso).toLocaleString('ru-RU', {
timeZone: 'Europe/Moscow',
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
/** Числовое значение движения: рубли приоритетно, иначе лиды. */
function txAmountValue(tx: BillingTransaction): number {
const rub = Number(tx.amount_rub);
return rub !== 0 ? rub : tx.amount_leads;
}
/** Текст суммы: «+ 5 000 ₽» / «− 1 лид.» / «0 ₽». */
function txAmountText(tx: BillingTransaction): string {
const rub = Number(tx.amount_rub);
if (rub !== 0) return formatCost(rub);
if (tx.amount_leads !== 0) {
const sign = tx.amount_leads > 0 ? '+ ' : ' ';
return sign + Math.abs(tx.amount_leads) + ' лид.';
}
return '0 ₽';
}
async function load(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
const tab = TABS.find((t) => t.id === activeTab.value);
const params: { page: number; type?: string } = { page: page.value };
if (tab?.type) params.type = tab.type;
const res = await getTransactions(params);
rows.value = res.data;
total.value = res.meta.total;
} catch {
loadError.value = 'Не удалось загрузить транзакции.';
rows.value = [];
total.value = 0;
} finally {
loading.value = false;
}
}
async function changeTab(id: string): Promise<void> {
activeTab.value = id;
page.value = 1;
await load();
}
async function loadOptions(opts: { page: number }): Promise<void> {
page.value = opts.page;
await load();
}
async function refresh(): Promise<void> {
page.value = 1;
await load();
}
onMounted(load);
defineExpose({ load, refresh, changeTab, activeTab, total, rows });
</script>
<template>
<v-card variant="outlined" class="mt-4 panel">
<div class="panel-h pa-4">
<h2 class="text-h6 panel-title ma-0">История транзакций</h2>
<v-btn-toggle v-model="activeTab" mandatory color="primary" density="comfortable" variant="text">
<v-btn v-for="tab in BILLING_TABS" :key="tab.id" :value="tab.id" size="small">
<v-btn-toggle
:model-value="activeTab"
mandatory
color="primary"
density="comfortable"
variant="text"
>
<v-btn
v-for="tab in TABS"
:key="tab.id"
:value="tab.id"
size="small"
@click="changeTab(tab.id)"
>
{{ tab.label }}
</v-btn>
</v-btn-toggle>
</div>
<v-data-table
:items="filteredTransactions"
:headers="[
{ title: 'Дата', key: 'when', sortable: false },
{ title: 'Операция', key: 'description', sortable: false },
{ title: 'ID', key: 'code', sortable: false },
{ title: 'Статус', key: 'status', sortable: false },
{ title: 'Сумма', key: 'amount', align: 'end', sortable: false },
]"
items-per-page="-1"
hide-default-footer
<v-alert v-if="loadError" type="error" variant="tonal" density="compact" class="mx-4 mb-4" role="alert">
{{ loadError }}
</v-alert>
<v-data-table-server
:headers="headers"
:items="rows"
:items-length="total"
:loading="loading"
:items-per-page="20"
density="comfortable"
@update:options="loadOptions"
>
<template #[`item.when`]="{ item }">
<span class="tx-when num">{{ item.when }}</span>
<template #[`item.created_at`]="{ item }">
<span class="tx-when num">{{ formatWhen(item.created_at) }}</span>
</template>
<template #[`item.code`]="{ item }">
<span class="tx-id">#{{ item.code }}</span>
</template>
<template #[`item.status`]="{ item }">
<v-chip size="small" variant="tonal" :color="statusChipColor(item.status)">
{{ statusLabel(item.status) }}
</v-chip>
</template>
<template #[`item.amount`]="{ item }">
<span class="num" :class="txAmountClass(item)">
{{ item.status === 'rejected' ? '— 0 ₽' : formatCost(item.amount) }}
<template #[`item.amount_rub`]="{ item }">
<span class="num" :class="txAmountClass(txAmountValue(item))">
{{ txAmountText(item) }}
</span>
</template>
</v-data-table>
</v-data-table-server>
</v-card>
</template>
@@ -29,7 +29,11 @@ defineProps<{
<span class="ru">&nbsp;</span>
</div>
<div class="runway mt-3">
<div class="runway-bar" role="img" :aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`">
<div
class="runway-bar"
role="img"
:aria-label="`Хватит на ${balance.runwayDays} дня из ${balance.runwayMax}`"
>
<span
v-for="i in balance.runwayMax"
:key="i"
@@ -55,7 +55,8 @@ const range = defineModel<'today' | '7d' | '30d' | 'custom'>({ required: true })
align-items: center;
}
.page-meta .sep {
color: #92907b;
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
@@ -87,7 +87,8 @@ function formatRelative(minutes: number): string {
text-decoration: underline;
}
.hero-meta .sep {
color: #92907b;
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
}
.status-row {
@@ -87,7 +87,11 @@ async function handleLogout(): Promise<void> {
<template>
<v-app-bar :elevation="0" color="surface" class="app-topbar" :height="56">
<v-app-bar-nav-icon class="d-md-none" @click="emit('toggle-drawer')" />
<v-app-bar-nav-icon
class="d-md-none"
aria-label="Открыть меню навигации"
@click="emit('toggle-drawer')"
/>
<div class="crumb">
<strong>{{ pageTitle }}</strong>
@@ -55,6 +55,19 @@
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_limit', ...p })"
/>
<v-snackbar
v-model="skipToastOpen"
:timeout="6000"
color="warning"
location="bottom right"
data-testid="bulk-skip-toast"
>
{{ skipToastText }}
<template #actions>
<v-btn variant="text" @click="skipToastOpen = false">Закрыть</v-btn>
</template>
</v-snackbar>
</v-card>
</template>
@@ -72,6 +85,10 @@ const regionsOpen = ref(false);
const daysOpen = ref(false);
const limitOpen = ref(false);
// Sprint 1 C5: window.alert v-snackbar (non-blocking, accessible, не breaks браузерный automation).
const skipToastOpen = ref(false);
const skipToastText = ref('');
const messages: Record<string, string> = {
pause: 'Приостановить выбранные проекты?',
resume: 'Возобновить выбранные проекты?',
@@ -87,13 +104,12 @@ async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
const result = await store.bulkUpdate(payload);
if (result.skipped.length > 0) {
window.alert(
`Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`,
);
skipToastText.value = `Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`;
skipToastOpen.value = true;
}
}
defineExpose({ regionsOpen, daysOpen, limitOpen });
defineExpose({ regionsOpen, daysOpen, limitOpen, skipToastOpen, skipToastText, runBulk });
</script>
<style scoped>
@@ -0,0 +1,245 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import axios from 'axios';
import type { Project } from '../../stores/projectsStore';
import { useProjectsStore } from '../../stores/projectsStore';
import { REGIONS } from '../../constants/regions';
const props = defineProps<{ project: Project | null }>();
const emit = defineEmits<{ close: []; saved: [] }>();
interface FormState {
name: string;
daily_limit_target: number;
region_mask: number;
region_mode: 'include' | 'exclude';
delivery_days_mask: number;
sms_senders: string[];
sms_keyword: string;
}
const form = reactive<FormState>({
name: '',
daily_limit_target: 50,
region_mask: 0,
region_mode: 'include',
delivery_days_mask: 127,
sms_senders: [],
sms_keyword: '',
});
const selectedRegions = ref<number[]>([]);
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
function maskToCodes(mask: number): number[] {
const codes: number[] = [];
for (let i = 1; i <= 31; i++) if (mask & (1 << i)) codes.push(i);
return codes;
}
function reseedFromProject(p: Project | null): void {
if (!p) return;
form.name = p.name;
form.daily_limit_target = p.daily_limit_target;
form.region_mask = p.region_mask ?? 0;
form.region_mode = (p.region_mode ?? 'include') as 'include' | 'exclude';
form.delivery_days_mask = p.delivery_days_mask ?? 127;
form.sms_senders = p.sms_senders ?? [];
form.sms_keyword = p.sms_keyword ?? '';
selectedRegions.value = maskToCodes(form.region_mask);
}
reseedFromProject(props.project);
watch(() => props.project?.id, () => {
reseedFromProject(props.project);
});
watch(selectedRegions, (codes) => {
if (codes.length === 0) {
form.region_mask = 0;
form.region_mode = 'include';
} else {
form.region_mask = codes.reduce((acc, c) => (c >= 1 && c <= 31 ? acc | (1 << c) : acc), 0);
form.region_mode = 'exclude';
}
});
const saving = ref(false);
const errors = reactive<Record<string, string[]>>({});
const store = useProjectsStore();
async function onPause(): Promise<void> {
if (!props.project) return;
await store.toggleActive(props.project);
}
async function onDelete(): Promise<void> {
if (!props.project) return;
const ok = window.confirm('Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).');
if (!ok) return;
await store.archive(props.project.id);
emit('close');
}
async function onSave(): Promise<void> {
if (!props.project) return;
saving.value = true;
Object.keys(errors).forEach((k) => delete errors[k]);
try {
const payload: Record<string, unknown> = {
name: form.name,
daily_limit_target: form.daily_limit_target,
region_mask: form.region_mask,
region_mode: form.region_mode,
delivery_days_mask: form.delivery_days_mask,
};
if (props.project.signal_type === 'sms') {
payload.sms_senders = form.sms_senders;
payload.sms_keyword = form.sms_keyword;
}
await axios.patch(`/api/projects/${props.project.id}`, payload);
emit('saved');
} catch (e: unknown) {
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
}
} finally {
saving.value = false;
}
}
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape' && props.project) emit('close');
}
onMounted(() => document.addEventListener('keydown', onKey));
onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
const activeDays = computed<boolean[]>(() => {
const mask = form.delivery_days_mask;
return Array.from({ length: 7 }, (_, i) => (mask & (1 << i)) !== 0);
});
function toggleDay(i: number): void {
form.delivery_days_mask ^= (1 << i);
}
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
</script>
<template>
<aside class="project-details-drawer" :class="{ open: project !== null }">
<div v-if="project" class="pdd-content">
<header class="pdd-head">
<div class="pdd-title">{{ project.name }}</div>
<button class="pdd-close" data-testid="pdd-close" @click="$emit('close')"></button>
</header>
<div class="pdd-body">
<label class="pdd-field">
<span class="pdd-label">Название</span>
<input v-model="form.name" data-testid="pdd-name" class="pdd-input" />
<div v-if="errors.name" class="pdd-error" data-testid="pdd-error-name">{{ errors.name[0] }}</div>
</label>
<label class="pdd-field">
<span class="pdd-label">Лимит лидов в день</span>
<input
v-model.number="form.daily_limit_target"
type="number"
min="1"
max="10000"
data-testid="pdd-limit"
class="pdd-input"
/>
<div v-if="errors.daily_limit_target" class="pdd-error">{{ errors.daily_limit_target[0] }}</div>
</label>
<div class="pdd-field">
<span class="pdd-label">Регионы (пусто = вся РФ)</span>
<v-autocomplete
v-model="selectedRegions"
:items="selectableRegions"
item-title="name"
item-value="code"
multiple
chips
clearable
density="comfortable"
hide-details
data-testid="pdd-regions"
/>
</div>
<div class="pdd-field">
<span class="pdd-label">Дни недели приёма</span>
<div class="pdd-days">
<button
v-for="(label, i) in dayLabels"
:key="i"
type="button"
:data-testid="`pdd-day-${i}`"
:class="['pdd-day', { active: activeDays[i] }]"
@click="toggleDay(i)"
>
{{ label }}
</button>
</div>
</div>
</div>
<footer class="pdd-foot">
<div class="pdd-foot-left">
<button class="pdd-btn pdd-btn-warning" data-testid="pdd-pause" @click="onPause">
{{ project.is_active ? '⏸ Приостановить' : '▶ Возобновить' }}
</button>
<button class="pdd-btn pdd-btn-error" data-testid="pdd-delete" @click="onDelete">🗄 Удалить</button>
</div>
<div class="pdd-foot-right">
<button class="pdd-btn pdd-btn-text" data-testid="pdd-cancel" @click="$emit('close')">Отмена</button>
<button
class="pdd-btn pdd-btn-primary"
data-testid="pdd-save"
:disabled="saving"
@click="onSave"
>Сохранить</button>
</div>
</footer>
</div>
</aside>
</template>
<style scoped>
.project-details-drawer {
position: fixed; top: 0; right: 0; bottom: 0;
width: 480px;
background: var(--liderra-surface, #ffffff);
border-left: 1px solid var(--liderra-line, #e6e2d6);
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.06);
transform: translateX(100%);
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
display: flex; flex-direction: column;
z-index: 5;
}
.project-details-drawer.open { transform: translateX(0); }
.pdd-content { display: flex; flex-direction: column; height: 100%; }
.pdd-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--liderra-line, #e6e2d6); }
.pdd-title { font-weight: 600; font-size: 16px; }
.pdd-close { background: none; border: 0; cursor: pointer; font-size: 18px; padding: 4px; }
.pdd-body { padding: 16px 20px; display: flex; flex-direction: column; gap: 14px; flex: 1; overflow-y: auto; }
.pdd-field { display: flex; flex-direction: column; gap: 4px; }
.pdd-label { font-size: 12px; color: #6b6f72; }
.pdd-input { padding: 8px 10px; border: 1px solid var(--liderra-line, #e6e2d6); border-radius: 6px; font: inherit; }
.pdd-days { display: flex; gap: 4px; }
.pdd-day { padding: 6px 10px; border: 1px solid var(--liderra-line, #e6e2d6); background: #ffffff; border-radius: 4px; cursor: pointer; font: inherit; }
.pdd-day.active { background: #0f6e56; color: #ffffff; border-color: #0f6e56; }
.pdd-foot { display: flex; justify-content: space-between; padding: 12px 20px; border-top: 1px solid var(--liderra-line, #e6e2d6); }
.pdd-foot-left, .pdd-foot-right { display: flex; gap: 8px; }
.pdd-btn { padding: 6px 14px; border: 0; border-radius: 6px; cursor: pointer; font: inherit; }
.pdd-btn-text { background: transparent; color: #081319; }
.pdd-btn-primary { background: #0f6e56; color: #ffffff; }
.pdd-btn-warning { background: #f59e0b; color: #ffffff; }
.pdd-btn-error { background: #dc2626; color: #ffffff; }
.pdd-error { color: #dc2626; font-size: 12px; margin-top: 4px; }
</style>
@@ -1,9 +1,11 @@
/**
* Форматтеры для биллинга. Экспортируются для использования в нескольких
* sub-components BillingView (BalanceCard, TransactionsTable, InvoicesTable).
* Sprint 4 Phase B/2 split BillingView.
* Форматтеры биллинга BillingView + TransactionsTable + InvoicesTable.
*
* Sprint 2 Plan C: status/format-функции (statusChipColor/statusLabel/
* formatLabel/formatIcon) удалены real-API транзакции не имеют статуса
* (append-only ledger), счета отдельный формат. txAmountClass
* перетипизирован под знак суммы.
*/
import type { BillingTransaction, InvoiceFormat, TxStatus } from './mockBilling';
/** «5000» → «5 000 ₽» (без знака). */
export function formatPlain(cost: number): string {
@@ -16,36 +18,25 @@ export function formatCost(cost: number): string {
return sign + new Intl.NumberFormat('ru-RU').format(Math.abs(cost)) + ' ₽';
}
/** CSS-класс для суммы транзакции по статусу/знаку. */
export function txAmountClass(tx: BillingTransaction): string {
if (tx.status === 'rejected') return 'tx-amount-neutral';
if (tx.amount > 0) return 'tx-amount-up';
if (tx.amount < 0) return 'tx-amount-down';
/** CSS-класс суммы транзакции по знаку. */
export function txAmountClass(amount: number): string {
if (amount > 0) return 'tx-amount-up';
if (amount < 0) return 'tx-amount-down';
return 'tx-amount-neutral';
}
/** Vuetify-цвет чипа статуса транзакции. */
export function statusChipColor(status: TxStatus): string {
if (status === 'pending') return 'warning';
if (status === 'completed') return 'success';
return 'error';
}
/** Человекочитаемые лейблы для feature-слагов tariff_plans.features. */
export const FEATURE_LABELS: Record<string, string> = {
webhook: 'Webhook',
kanban: 'Канбан',
basic_analytics: 'Базовая аналитика',
advanced_analytics: 'Расширенная аналитика',
api: 'API',
'2fa': 'Двухфакторная аутентификация',
custom_domain: 'Свой домен',
};
/** Локализованный лейбл статуса транзакции. */
export function statusLabel(status: TxStatus): string {
if (status === 'pending') return 'В обработке';
if (status === 'completed') return 'Проведён';
return 'Отклонено';
}
/** Лейбл формата файла счёта/УПД (PDF / 1С 8.3 XML). */
export function formatLabel(format: InvoiceFormat): string {
if (format === 'pdf') return 'PDF';
return '1С 8.3 XML';
}
/** Иконка формата файла счёта/УПД. */
export function formatIcon(format: InvoiceFormat): string {
if (format === 'pdf') return 'mdi-file-pdf-box';
return 'mdi-xml';
/** Лейбл feature-слага; неизвестный слаг возвращается как есть. */
export function featureLabel(slug: string): string {
return FEATURE_LABELS[slug] ?? slug;
}
+7 -158
View File
@@ -1,167 +1,16 @@
/**
* Mock-данные для BillingView. Заменятся на API-fetch:
* GET /api/billing/wallet баланс + леды + tariff.
* GET /api/billing/transactions?type={all|topup|charge|refund} `balance_transactions`.
* GET /api/billing/invoices `invoices` table (счета + УПД).
* Мок платежа «в обработке» для pending-баннера BillingView.
*
* Mock-структуры соответствуют схеме v8.7:
* - balance_transactions (§4.4): type {topup, lead_charge, refund, tariff_charge, manager_addon}.
* - invoices (§4.5): type {invoice, upd}, format {pdf, xml_1c83}.
* Кошелёк / транзакции / счета подключены к real API (api/billing.ts) в
* Sprint 2 Plan C (E3). Pending-баннер отдельный эпик E4 (Sprint 5);
* до его реализации остаётся mock.
*/
export type TxType = 'topup' | 'lead_charge' | 'refund' | 'tariff_charge';
export type TxStatus = 'pending' | 'completed' | 'rejected';
export interface BillingTransaction {
id: number;
code: string; // 'TX-89421'
when: string; // '07.05 · 14:21'
type: TxType;
description: string;
status: TxStatus;
amount: number; // signed (+ topup/refund, charge)
}
export const MOCK_TRANSACTIONS: BillingTransaction[] = [
{
id: 89421,
code: 'TX-89421',
when: '07.05 · 14:21',
type: 'topup',
description: 'Пополнение через ЮKassa',
status: 'pending',
amount: 5000,
},
{
id: 89384,
code: 'TX-89384',
when: '07.05 · 11:14',
type: 'lead_charge',
description: 'Списание · 3 лида проект «Окна Москва»',
status: 'completed',
amount: -6600,
},
{
id: 89370,
code: 'TX-89370',
when: '07.05 · 09:48',
type: 'refund',
description: 'Возврат лида #1018 · дубликат',
status: 'completed',
amount: 2200,
},
{
id: 89312,
code: 'TX-89312',
when: '06.05 · 22:06',
type: 'topup',
description: 'Пополнение через ЮKassa',
status: 'completed',
amount: 10000,
},
{
id: 89286,
code: 'TX-89286',
when: '06.05 · 18:32',
type: 'lead_charge',
description: 'Списание · 5 лидов проект «Натяжные потолки»',
status: 'completed',
amount: -9250,
},
{
id: 89108,
code: 'TX-89108',
when: '05.05 · 12:00',
type: 'tariff_charge',
description: 'Списание абонентской платы тарифа «Команда»',
status: 'completed',
amount: -990,
},
{
id: 88937,
code: 'TX-88937',
when: '04.05 · 16:42',
type: 'topup',
description: 'Попытка пополнения через банковский перевод',
status: 'rejected',
amount: 0,
},
{
id: 88714,
code: 'TX-88714',
when: '03.05 · 09:18',
type: 'refund',
description: 'Возврат лида #998 · спам',
status: 'completed',
amount: 1850,
},
];
export interface BillingTab {
id: 'all' | 'topup' | 'lead_charge' | 'refund';
label: string;
types: TxType[] | null;
}
export const BILLING_TABS: BillingTab[] = [
{ id: 'all', label: 'Все', types: null },
{ id: 'topup', label: 'Пополнения', types: ['topup'] },
{ id: 'lead_charge', label: 'Списания', types: ['lead_charge', 'tariff_charge'] },
{ id: 'refund', label: 'Возвраты', types: ['refund'] },
];
export type InvoiceFormat = 'pdf' | 'xml_1c83';
export interface Invoice {
id: number;
when: string; // '07.05.2026'
title: string; // 'Счёт № 2026-0512'
sub: string; // 'Тариф «Команда» · май 2026'
amountRub: number;
format: InvoiceFormat;
}
export const MOCK_INVOICES: Invoice[] = [
{
id: 1,
when: '07.05.2026',
title: 'Счёт № 2026-0512',
sub: 'Тариф «Команда» · май 2026',
amountRub: 990,
format: 'pdf',
},
{
id: 2,
when: '06.05.2026',
title: 'УПД № УПД-2026-0492',
sub: 'Списания за апрель · 18 лидов',
amountRub: 29850,
format: 'xml_1c83',
},
{
id: 3,
when: '05.05.2026',
title: 'УПД № УПД-2026-0488',
sub: 'Списания за март · 24 лида',
amountRub: 38100,
format: 'xml_1c83',
},
{
id: 4,
when: '01.04.2026',
title: 'Счёт № 2026-0498',
sub: 'Тариф «Команда» · апрель 2026',
amountRub: 990,
format: 'pdf',
},
];
export interface PendingPayment {
code: string;
amount: number;
method: string; // 'ЮKassa'
startedAt: string; // '14:21'
autoCancelAt: string; // '14:51'
method: string;
startedAt: string;
autoCancelAt: string;
timeoutMinutes: number;
}
@@ -28,7 +28,7 @@ export const STATUS_PILL_SLUGS = [
'archived',
] as const;
export type StatusPillSlug = (typeof STATUS_PILL_SLUGS)[number];
type StatusPillSlug = (typeof STATUS_PILL_SLUGS)[number];
const STYLES: Record<StatusPillSlug, PillStyle> = {
new: { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' },
-1
View File
@@ -12,7 +12,6 @@ export const setupVue3 = defineSetupVue3(({ app }) => {
{ path: '/register', component: { template: '<div />' } },
{ path: '/forgot', component: { template: '<div />' } },
{ path: '/2fa', component: { template: '<div />' } },
{ path: '/recovery', component: { template: '<div />' } },
{ path: '/recovery-use', component: { template: '<div />' } },
{ path: '/dashboard', component: { template: '<div />' } },
{ path: '/deals', component: { template: '<div />' } },
+5 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Layout админки SaaS отдельный sidebar с пометкой ADMIN, 4 nav-пункта,
* Layout админки SaaS отдельный sidebar с пометкой ADMIN, 7 nav-пунктов,
* без user-chip как в обычной AppLayout.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_admin.html.
@@ -8,7 +8,6 @@
*
* Не входит в этот коммит:
* - Auth-guard на /admin/* должен проверять `super_admin` role + 2FA.
* - Impersonation banner (когда admin вошёл «как клиент» Ю-1: 15 мин / 5 попыток).
* - Audit-log записей для всех action'ов admin (по schema v8.7 §10
* `saas_admin_audit_log`).
*/
@@ -16,6 +15,7 @@ import { useAuthStore } from '../stores/auth';
import { computed } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
import DevIndexBadge from '../components/DevIndexBadge.vue';
import ImpersonationBanner from '../components/admin/ImpersonationBanner.vue';
interface NavItem {
title: string;
@@ -27,6 +27,8 @@ interface NavItem {
const navItems: NavItem[] = [
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants', count: 142 },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents', count: 3 },
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
@@ -129,6 +131,7 @@ const currentPageTitle = computed(() => {
</v-app-bar>
<v-main class="admin-main">
<ImpersonationBanner />
<RouterView />
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Двухпанельный layout для экранов аутентификации (login/register/2fa/forgot/recovery).
* Двухпанельный layout для экранов аутентификации (login/register/2fa/forgot/recovery-use).
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html (CSS .layout grid 1fr 1fr,
* .brand-pane слева тёмный теало-нуар + radial-gradient'ы, .form-pane справа warm ivory).
@@ -44,8 +44,8 @@ const route = useRoute();
</p>
<div class="bp-foot">
<span>v8 · Forest</span>
<a href="/legal/offer">Оферта</a>
<a href="/legal/privacy">Политика</a>
<RouterLink to="/legal/offer">Оферта</RouterLink>
<RouterLink to="/legal/privacy">Политика</RouterLink>
</div>
</div>
</v-col>
+88 -12
View File
@@ -4,18 +4,94 @@ import { h, type Component } from 'vue';
import { createVuetify } from 'vuetify';
import type { ThemeDefinition, IconSet, IconProps } from 'vuetify';
import {
Activity, AlertCircle, AlertTriangle, Archive, ArrowDown, ArrowLeft, ArrowRightLeft,
ArrowUp, Bell, BellOff, Calendar, CalendarDays, Camera, Check, CheckCircle, ChevronDown,
ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, ChevronsUpDown, ChevronUp,
Circle, CircleDot, CircleStop, Clock, Code,
Columns3, Copy, CreditCard, Download, Eye, EyeOff, FilterX, FileText, FlaskConical,
Folder, Folders, Globe, HelpCircle, Info, Key, KeyRound, LayoutDashboard, List, LogOut, Mail,
Megaphone, Menu, MessageSquare, MessageSquareText, Minus, MoreVertical, Paperclip, Pause,
Pencil, Phone, Play, Plus,
PlusCircle, Puzzle, ReceiptText, RefreshCw, RotateCcw, RotateCw, RussianRuble, Save, Search,
Settings, Shield, ShieldCheck, ShieldOff, Square, SquareCheck, SquareMinus, Star, StarHalf,
Tag, Trash2, User, UserCheck, UserCog, UserPlus,
Users, Wallet, Webhook, X, XCircle,
Activity,
AlertCircle,
AlertTriangle,
Archive,
ArrowDown,
ArrowLeft,
ArrowRightLeft,
ArrowUp,
Bell,
BellOff,
Calendar,
CalendarDays,
Camera,
Check,
CheckCircle,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronsUpDown,
ChevronUp,
Circle,
CircleDot,
CircleStop,
Clock,
Code,
Columns3,
Copy,
CreditCard,
Download,
Eye,
EyeOff,
FilterX,
FileText,
FlaskConical,
Folder,
Folders,
Globe,
HelpCircle,
Info,
Key,
KeyRound,
LayoutDashboard,
List,
LogOut,
Mail,
Megaphone,
Menu,
MessageSquare,
MessageSquareText,
Minus,
MoreVertical,
Paperclip,
Pause,
Pencil,
Phone,
Play,
Plus,
PlusCircle,
Puzzle,
ReceiptText,
RefreshCw,
RotateCcw,
RotateCw,
RussianRuble,
Save,
Search,
Settings,
Shield,
ShieldCheck,
ShieldOff,
Square,
SquareCheck,
SquareMinus,
Star,
StarHalf,
Tag,
Trash2,
User,
UserCheck,
UserCog,
UserPlus,
Users,
Wallet,
Webhook,
X,
XCircle,
} from 'lucide-vue-next';
/**
+6 -6
View File
@@ -58,18 +58,18 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/auth/ForgotPasswordView.vue'),
meta: { layout: 'auth', title: 'Сброс пароля', guestOnly: true, devIndex: 4, devLabel: 'Forgot password' },
},
{
path: '/recovery',
name: 'recovery',
component: () => import('../views/auth/RecoveryCodesView.vue'),
meta: { layout: 'auth', title: 'Резервные коды', devIndex: 6, devLabel: 'Recovery codes' },
},
{
path: '/recovery-use',
name: 'recovery-use',
component: () => import('../views/auth/UseRecoveryCodeView.vue'),
meta: { layout: 'auth', title: 'Вход по резервному коду', devIndex: 7, devLabel: 'Use recovery' },
},
{
path: '/legal/:doc(offer|privacy)',
name: 'legal',
component: () => import('../views/legal/LegalDocView.vue'),
meta: { layout: 'auth', title: 'Правовые документы' },
},
{
path: '/reset/:token',
name: 'reset-password',
+105 -52
View File
@@ -1,47 +1,69 @@
<script setup lang="ts">
/**
* Биллинг и тарифы финансовый экран. Кошелёк , баланс лидов,
* текущий тариф, история транзакций и счета/УПД.
* текущий тариф, история транзакций и счета.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_billing.html.
* MVP: page-head + pending banner + 3 wallet-cards (BalanceCard) +
* transactions table с табами (TransactionsTable) + invoices list (InvoicesTable).
* Mock-данные из composables/mockBilling.ts.
* Sprint 2 Plan C (E3): Overview-таб подвязан на real API
* (GET /api/billing/wallet BalanceCard + шапка; TransactionsTable и
* InvoicesTable тянут данные сами). Списания ChargesTab (Plan 4).
*
* Sprint 4 Phase B/2 split на shell + 3 sub-components (audit O-refactor-04 хвост).
*
* Plan 4 Task 11 добавлен top-level v-tabs split:
* - "Обзор" существующий контент (mock-balance, transactions, invoices).
* - "Списания" ChargesTab, real backend ledger (GET /api/billing/charges).
*
* Не входит в MVP:
* - TopupDialog (диалог настройки автопополнения через ЮKassa).
* - Tariff change wizard (диалог смены тарифа с расчётом разницы).
* - Tariff comparison table (4 тарифа: Solo/Команда/Бизнес/Корпоративный).
* - Refund-request dialog (заявка на возврат).
*
* Backend (отдельный коммит):
* - GET /api/billing/wallet балансы.
* - GET /api/billing/transactions?type=...&page=... пагинация.
* - POST /api/billing/topup ЮKassa-checkout.
* - GET /api/billing/invoices/{id}/file PDF/XML download.
* Pending-баннер остаётся mock (MOCK_PENDING) это отдельный эпик E4
* (Sprint 5). TopupDialog «Пополнить баланс» Task 5 (E1).
*/
import { ref } from 'vue';
import { ref, computed, onMounted } from 'vue';
import BalanceCard from '../components/billing/BalanceCard.vue';
import TransactionsTable from '../components/billing/TransactionsTable.vue';
import InvoicesTable from '../components/billing/InvoicesTable.vue';
import TopupDialog from '../components/billing/TopupDialog.vue';
import ChargesTab from './billing/ChargesTab.vue';
import { MOCK_PENDING } from '../composables/mockBilling';
import { formatPlain } from '../composables/billingFormatters';
const walletRub = 14250;
const leadsBalance = 285;
const runwayDays = 4;
const tariffName = 'Команда';
const tariffPrice = 990;
const tariffFeatures = ['до 10 проектов', '4 менеджера + расширение', 'Канбан, Webhook, API'];
import { formatPlain, featureLabel } from '../composables/billingFormatters';
import { getWallet, type Wallet } from '../api/billing';
import { extractErrorMessage } from '../api/client';
const activeView = ref<'overview' | 'charges'>('overview');
const wallet = ref<Wallet | null>(null);
const loading = ref(true);
const loadError = ref<string | null>(null);
const topupOpen = ref(false);
const topupSnackbar = ref(false);
const txTableRef = ref<InstanceType<typeof TransactionsTable> | null>(null);
const walletRub = computed(() => Number(wallet.value?.balance_rub ?? 0));
const leadsBalance = computed(() => wallet.value?.balance_leads ?? 0);
const runwayDays = computed(() => wallet.value?.runway_days ?? null);
const tariffName = computed(() => wallet.value?.tariff?.name ?? null);
const tariffPrice = computed(() => wallet.value?.tariff?.price_monthly ?? null);
const tariffFeatures = computed<string[]>(() => (wallet.value?.tariff?.features ?? []).map(featureLabel));
async function loadWallet(): Promise<void> {
loading.value = true;
loadError.value = null;
try {
wallet.value = await getWallet();
} catch (e) {
// Сброс устаревших данных: при неудачном повторе не оставляем
// прошлый успешный wallet в памяти (защита от ложного рендера).
wallet.value = null;
loadError.value = extractErrorMessage(e, 'Не удалось загрузить данные биллинга.');
} finally {
loading.value = false;
}
}
async function onTopupSuccess(): Promise<void> {
// success-событие несёт новый баланс, но мы намеренно перезапрашиваем
// кошелёк (loadWallet) единый источник истины надёжнее точечного патча.
topupOpen.value = false;
topupSnackbar.value = true;
await loadWallet();
txTableRef.value?.refresh();
}
onMounted(loadWallet);
defineExpose({ loadWallet, wallet, topupOpen });
</script>
<template>
@@ -49,7 +71,7 @@ const activeView = ref<'overview' | 'charges'>('overview');
<header class="page-head">
<div>
<h1 class="text-h4 mb-2 page-title">Биллинг и тарифы</h1>
<div class="page-stats text-body-2 text-medium-emphasis">
<div v-if="wallet" class="page-stats text-body-2 text-medium-emphasis">
<span
><span class="num text-primary">{{ formatPlain(walletRub) }}</span> кошелёк</span
>
@@ -57,13 +79,17 @@ const activeView = ref<'overview' | 'charges'>('overview');
<span
><span class="num">{{ leadsBalance }}</span> лидов запас</span
>
<span class="sep">·</span>
<span
>хватит на <span class="num">{{ runwayDays }} дня</span></span
>
<template v-if="runwayDays !== null">
<span class="sep">·</span>
<span
>хватит на <span class="num">{{ runwayDays }}</span> дн.</span
>
</template>
</div>
</div>
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus">Пополнить баланс</v-btn>
<v-btn color="primary" variant="flat" prepend-icon="mdi-plus" @click="topupOpen = true"
>Пополнить баланс</v-btn
>
</header>
<v-tabs v-model="activeView" color="primary" class="mt-4">
@@ -73,30 +99,56 @@ const activeView = ref<'overview' | 'charges'>('overview');
<v-tabs-window v-model="activeView">
<v-tabs-window-item value="overview">
<v-alert v-if="MOCK_PENDING" type="info" variant="tonal" density="compact" class="mt-4" role="status">
<strong>1 платёж в обработке</strong> {{ formatPlain(MOCK_PENDING.amount) }} от
{{ MOCK_PENDING.method }}, начат {{ MOCK_PENDING.startedAt }}. Авто-восстановление в
{{ MOCK_PENDING.autoCancelAt }} ({{ MOCK_PENDING.timeoutMinutes }} мин). Кнопки «Отменить» нет это
техническое решение.
<div v-if="loading" class="py-12 d-flex justify-center">
<v-progress-circular indeterminate color="primary" />
</div>
<v-alert v-else-if="loadError" type="error" variant="tonal" class="mt-4" role="alert">
{{ loadError }}
<template #append>
<v-btn size="small" variant="text" @click="loadWallet">Повторить</v-btn>
</template>
</v-alert>
<BalanceCard
:wallet-rub="walletRub"
:leads-balance="leadsBalance"
:tariff-name="tariffName"
:tariff-price="tariffPrice"
:tariff-features="tariffFeatures"
/>
<template v-else-if="wallet">
<v-alert
v-if="MOCK_PENDING"
type="info"
variant="tonal"
density="compact"
class="mt-4"
role="status"
>
<strong>1 платёж в обработке</strong> {{ formatPlain(MOCK_PENDING.amount) }} от
{{ MOCK_PENDING.method }}, начат {{ MOCK_PENDING.startedAt }}. Авто-восстановление в
{{ MOCK_PENDING.autoCancelAt }} ({{ MOCK_PENDING.timeoutMinutes }} мин).
</v-alert>
<TransactionsTable />
<BalanceCard
:wallet-rub="walletRub"
:leads-balance="leadsBalance"
:tariff-name="tariffName"
:tariff-price="tariffPrice"
:tariff-features="tariffFeatures"
@topup="topupOpen = true"
/>
<InvoicesTable />
<TransactionsTable ref="txTableRef" />
<InvoicesTable />
</template>
</v-tabs-window-item>
<v-tabs-window-item value="charges">
<ChargesTab />
</v-tabs-window-item>
</v-tabs-window>
<TopupDialog v-model="topupOpen" @success="onTopupSuccess" />
<v-snackbar v-model="topupSnackbar" color="success" :timeout="4000">
Баланс пополнен.
</v-snackbar>
</v-container>
</template>
@@ -123,7 +175,8 @@ const activeView = ref<'overview' | 'charges'>('overview');
align-items: center;
}
.page-stats .sep {
color: #92907b;
/* WCAG2AA 4.5:1: #6b6356 → 5.33:1 on ivory. */
color: #6b6356;
}
.num {
+163 -22
View File
@@ -13,7 +13,7 @@
*
* Источник статусов composables/leadStatuses.ts (snapshot из db/schema.sql:2130).
*/
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue';
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue';
import { DEALS_TABS, MOCK_DEALS, type MockDeal } from '../composables/mockDeals';
import { mapApiDeal } from '../composables/dealsApiMapper';
import { usePolling } from '../composables/usePolling';
@@ -37,11 +37,61 @@ import { buildCsvString, triggerBlobDownload, triggerCsvDownload } from '../comp
// Task 15: density-toggle composable (persists в localStorage, влияет на row height).
const { rowHeight } = useDensity();
// Task 15: stub-обработчики redesign-filter-chip'ов. На I1 popover'ы Проект/Менеджер
// не реализованы; chiprow служит quiet-luxury визуальной заменой для status-summary'ов.
// Не ломает существующие VSelect'ы в DealsFilters те остаются как полноценный filter UI.
// Sprint 1 C2: popovers для Проект/Менеджер chip'ов. Draft-state накапливает
// выбор внутри v-menu; копируется из filterProjects/filterManagers при открытии
// (watch на menu open=true); переносится обратно в filterProjects/Managers на
// «Применить» button. Status chip read-only (P2 backlog Sprint 5).
const projectMenuOpen = ref(false);
const managerMenuOpen = ref(false);
const projectMenuDraft = ref<string[]>([]);
const managerMenuDraft = ref<string[]>([]);
// При открытии меню копируем текущий filter в draft (snapshot-on-open).
// При закрытии без apply draft остаётся, но не влияет на filterProjects
// (apply нужен явно).
watch(projectMenuOpen, (isOpen) => {
if (isOpen) projectMenuDraft.value = [...filterProjects.value];
});
watch(managerMenuOpen, (isOpen) => {
if (isOpen) managerMenuDraft.value = [...filterManagers.value];
});
function onRedesignFilterClick(name: string): void {
console.log(`[redesign filterbar] ${name} clicked — popover TBD`);
// Status chip read-only summary (P2 backlog Sprint 5).
// Project/Manager managed by v-menu activator (no manual click handler needed).
if (name === 'Статус') {
// no-op placeholder для будущей реализации
}
}
function applyProjectFilter(): void {
filterProjects.value = [...projectMenuDraft.value];
projectMenuOpen.value = false;
}
function applyManagerFilter(): void {
filterManagers.value = [...managerMenuDraft.value];
managerMenuOpen.value = false;
}
function clearProjectDraft(): void {
projectMenuDraft.value = [];
}
function clearManagerDraft(): void {
managerMenuDraft.value = [];
}
function toggleProjectDraft(proj: string): void {
projectMenuDraft.value = projectMenuDraft.value.includes(proj)
? projectMenuDraft.value.filter((p) => p !== proj)
: [...projectMenuDraft.value, proj];
}
function toggleManagerDraft(name: string): void {
managerMenuDraft.value = managerMenuDraft.value.includes(name)
? managerMenuDraft.value.filter((m) => m !== name)
: [...managerMenuDraft.value, name];
}
const auth = useAuthStore();
@@ -350,6 +400,16 @@ defineExpose({
trashMode,
toggleTrashMode,
applyBulkRestoreFromTrash,
projectMenuOpen,
managerMenuOpen,
projectMenuDraft,
managerMenuDraft,
applyProjectFilter,
applyManagerFilter,
clearProjectDraft,
clearManagerDraft,
toggleProjectDraft,
toggleManagerDraft,
});
const leadStatuses = computed(() => leadStatusesStore.statuses);
@@ -463,10 +523,9 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
@clear-filters="clearFilters"
/>
<!-- Task 15: redesign-filterbar (quiet luxury chiprow + density toggle).
Минимальный набор: 3 FilterChip-ярлыка (Статус/Проект/Менеджер) + DensityToggle справа.
Клики на I1 stub'ы (popover'ы TBD); полноценные multi-select'ы остаются в DealsFilters выше.
Status-legend ниже визуализирует пул цветов StatusPill'ов воронки. -->
<!-- Sprint 1 C2: redesign-filterbar с popover'ами для Проект/Менеджер.
Status chip остаётся read-only (P2 backlog Sprint 5).
Полноценные multi-select'ы в DealsFilters выше сохранены. -->
<div v-if="!trashMode" class="ld-filterbar mt-3">
<div class="ld-filterbar__chips">
<FilterChip
@@ -475,18 +534,99 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
:active="false"
@click="onRedesignFilterClick('Статус')"
/>
<FilterChip
label="Проект"
:count="filterProjects.length"
:active="filterProjects.length > 0"
@click="onRedesignFilterClick('Проект')"
/>
<FilterChip
label="Менеджер"
:count="filterManagers.length"
:active="filterManagers.length > 0"
@click="onRedesignFilterClick('Менеджер')"
/>
<v-menu v-model="projectMenuOpen" :close-on-content-click="false" location="bottom start">
<template #activator="{ props: activatorProps }">
<span v-bind="activatorProps">
<FilterChip
label="Проект"
:count="filterProjects.length"
:active="filterProjects.length > 0"
/>
</span>
</template>
<v-card min-width="260" max-width="320" data-testid="project-menu-card">
<v-card-text class="pa-2">
<v-list density="compact" class="pa-0">
<v-list-item v-if="availableProjects.length === 0" class="text-medium-emphasis">
<v-list-item-title>Нет проектов в текущем списке</v-list-item-title>
</v-list-item>
<v-list-item
v-for="proj in availableProjects"
:key="proj"
class="py-1"
@click="toggleProjectDraft(proj)"
>
<template #prepend>
<v-checkbox-btn :model-value="projectMenuDraft.includes(proj)" />
</template>
<v-list-item-title>{{ proj }}</v-list-item-title>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="px-3 pb-2">
<v-btn variant="text" size="small" data-testid="project-menu-clear" @click="clearProjectDraft">
Очистить
</v-btn>
<v-spacer />
<v-btn
color="primary"
variant="flat"
size="small"
data-testid="project-menu-apply"
@click="applyProjectFilter"
>
Применить
</v-btn>
</v-card-actions>
</v-card>
</v-menu>
<v-menu v-model="managerMenuOpen" :close-on-content-click="false" location="bottom start">
<template #activator="{ props: activatorProps }">
<span v-bind="activatorProps">
<FilterChip
label="Менеджер"
:count="filterManagers.length"
:active="filterManagers.length > 0"
/>
</span>
</template>
<v-card min-width="260" max-width="320" data-testid="manager-menu-card">
<v-card-text class="pa-2">
<v-list density="compact" class="pa-0">
<v-list-item v-if="availableManagers.length === 0" class="text-medium-emphasis">
<v-list-item-title>Нет менеджеров в текущем списке</v-list-item-title>
</v-list-item>
<v-list-item
v-for="mgr in availableManagers"
:key="mgr.name"
class="py-1"
@click="toggleManagerDraft(mgr.name)"
>
<template #prepend>
<v-checkbox-btn :model-value="managerMenuDraft.includes(mgr.name)" />
</template>
<v-list-item-title>{{ mgr.name }}</v-list-item-title>
<v-list-item-subtitle class="text-caption">{{ mgr.initials }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="px-3 pb-2">
<v-btn variant="text" size="small" data-testid="manager-menu-clear" @click="clearManagerDraft">
Очистить
</v-btn>
<v-spacer />
<v-btn
color="primary"
variant="flat"
size="small"
data-testid="manager-menu-apply"
@click="applyManagerFilter"
>
Применить
</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</div>
<DensityToggle class="ld-filterbar__density" />
</div>
@@ -607,7 +747,8 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
align-items: center;
}
.page-stats .sep {
color: #92907b;
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
}
.num {
+69 -11
View File
@@ -5,13 +5,16 @@
* Источник дизайна: liderra_v8_handoff/concepts/v8_kanban.html.
* DnD реализован через vuedraggable@4 (обёртка SortableJS) карточки можно
* перетаскивать между колонками. При drop:
* - событие 'added' в целевой колонке меняем `statusSlug` сделки.
* - событие 'added' в целевой колонке optimistic update statusSlug +
* POST /api/deals/transition (через dealsApi). На failure revert:
* карточка возвращается в исходную колонку + toast «Не удалось переместить».
* Без auth.user.tenant_id local-only mode (API не зовётся).
* - событие 'removed' в исходной колонке ничего не делаем (обработано в added).
* - событие 'moved' внутри одной колонки только смена порядка (statusSlug
* не меняется; на API будущем PATCH /api/deals/{id} {sort_order}).
*
* Не входит в этот коммит:
* - PATCH /api/deals/{id} {status_slug} при drop backend.
* - PATCH /api/deals/{id} {sort_order} при moved (intra-column reorder) backend.
* - Filters (Проект/Менеджер) общий filter-bar с DealsView.
* - DealDetailDrawer на click по карточке (event @open-deal).
*/
@@ -50,14 +53,44 @@ const dealsByStatus = reactive<Record<string, MockDeal[]>>(
}, {}),
);
function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) {
if (event.added) {
// Карточка переехала в эту колонку синхронизируем statusSlug.
// На production будет POST /api/deals/{id}/transition с проверкой allowed-переходов.
event.added.element.statusSlug = targetSlug;
async function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) {
if (!event.added) {
// 'removed' и 'moved' vuedraggable мутирует array; reactive triggers re-render.
return;
}
const dealItem = event.added.element;
const previousSlug = dealItem.statusSlug;
// Optimistic: меняем статус в local state сразу (UX отвечает мгновенно).
dealItem.statusSlug = targetSlug;
// Без auth local-only mode (dev/demo без tenant context). API не зовём.
if (!auth.user?.tenant_id) return;
try {
await dealsApi.transitionDeals({
tenant_id: auth.user.tenant_id,
ids: [dealItem.id],
status: targetSlug,
});
// success статус уже применён, тостить не нужно.
} catch {
// Revert на исходный статус + переместить карточку обратно в исходную колонку.
dealItem.statusSlug = previousSlug;
// Найдём целевую колонку и удалим из неё карточку, потом вернём в исходную.
const targetCol = dealsByStatus[targetSlug];
if (targetCol) {
const idx = targetCol.findIndex((d) => d.id === dealItem.id);
if (idx >= 0) targetCol.splice(idx, 1);
}
const sourceCol = dealsByStatus[previousSlug];
if (sourceCol && !sourceCol.find((d) => d.id === dealItem.id)) {
sourceCol.push(dealItem);
}
transitionToastText.value = `Не удалось переместить сделку #${dealItem.id} — восстановлен исходный статус.`;
transitionToastOpen.value = true;
}
// 'removed' и 'moved' обрабатываются автоматически через v-model
// (vuedraggable мутирует array; reactive triggers re-render).
}
const drawerOpen = ref(false);
@@ -80,6 +113,10 @@ const fetchError = ref(false);
const newDealOpen = ref(false);
// Sprint 1 C4: revert-on-fail toast при DnD-fail.
const transitionToastOpen = ref(false);
const transitionToastText = ref('');
function onDealCreated(deal: MockDeal) {
if (!dealsByStatus[deal.statusSlug]) dealsByStatus[deal.statusSlug] = [];
dealsByStatus[deal.statusSlug].unshift(deal);
@@ -113,7 +150,17 @@ onMounted(() => {
usePolling(loadDeals);
defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError, loadDeals });
defineExpose({
dealsByStatus,
totalDeals,
newDealOpen,
onDealCreated,
fetchError,
loadDeals,
onColumnChange,
transitionToastOpen,
transitionToastText,
});
</script>
<template>
@@ -176,6 +223,16 @@ defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError
<DealDetailDrawer v-model:open="drawerOpen" :deal="selectedDeal" :tenant-id="auth.user?.tenant_id" />
<NewDealDialog v-model="newDealOpen" :tenant-id="auth.user?.tenant_id" @created="onDealCreated" />
<v-snackbar
v-model="transitionToastOpen"
:timeout="4000"
color="warning"
location="bottom right"
data-testid="kanban-transition-toast"
>
{{ transitionToastText }}
</v-snackbar>
</v-container>
</template>
@@ -207,7 +264,8 @@ defineExpose({ dealsByStatus, totalDeals, newDealOpen, onDealCreated, fetchError
align-items: center;
}
.page-stats .sep {
color: #92907b;
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
}
.num {
+29 -3
View File
@@ -1,5 +1,5 @@
<template>
<div class="projects-view">
<div class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
<div class="d-flex justify-space-between align-center mb-4">
<h1 class="text-h4">Проекты</h1>
<v-btn color="primary" prepend-icon="mdi-plus" @click="openCreate">+ Создать проект</v-btn>
@@ -75,16 +75,23 @@
/>
</div>
<BulkActionsBar v-if="store.selectedIds.size > 0" />
<BulkActionsBar v-if="store.selectedIds.size >= 2" />
<ProjectDetailsDrawer
:project="singleSelectedProject"
@close="onDrawerClose"
@saved="onDrawerSaved"
/>
<NewProjectDialog v-model="createOpen" mode="create" @saved="store.fetch()" />
<EditProjectDialog v-model="editOpen" :project="editing" @saved="store.fetch()" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { ref, onMounted, onUnmounted, watch, computed } from 'vue';
import { useProjectsStore, type Project } from '../stores/projectsStore';
import ProjectCard from '../components/projects/ProjectCard.vue';
import ProjectDetailsDrawer from '../components/projects/ProjectDetailsDrawer.vue';
import BulkActionsBar from '../components/projects/BulkActionsBar.vue';
import NewProjectDialog from './projects/NewProjectDialog.vue';
import EditProjectDialog from './projects/EditProjectDialog.vue';
@@ -94,6 +101,19 @@ const createOpen = ref(false);
const editOpen = ref(false);
const editing = ref<Project | null>(null);
const singleSelectedProject = computed<Project | null>(() => {
if (store.selectedIds.size !== 1) return null;
const [id] = store.selectedIds;
return store.items.find((p: Project) => p.id === id) ?? null;
});
function onDrawerClose(): void {
store.clearSelection();
}
function onDrawerSaved(): void {
void store.fetch();
}
const typeFilters = [
{ title: 'Сайт', value: 'site' },
{ title: 'Звонок', value: 'call' },
@@ -223,4 +243,10 @@ onUnmounted(() => store.stopPolling());
height: 2px;
background: #fff;
}
.projects-view {
transition: padding-right 240ms cubic-bezier(0.16, 1, 0.3, 1);
}
.projects-view.has-drawer {
padding-right: 480px;
}
</style>
+2 -1
View File
@@ -245,7 +245,8 @@ const canSubmit = computed(() => quotaActive.value < quotaMax.value && !submitti
align-items: center;
}
.page-stats .sep {
color: #92907b;
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
color: #6b6356;
}
.num {
@@ -207,7 +207,8 @@ function tariffLabel(t: string): string {
<h2 class="text-h6 ma-0">Тенанты</h2>
<v-text-field
v-model="search"
placeholder="Поиск по названию или ИНН"
label="Поиск"
placeholder="по названию или ИНН"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
@@ -1,37 +0,0 @@
<script setup lang="ts">
/**
* Универсальный placeholder для ещё-не-реализованных admin-разделов
* (Биллинг / Инциденты / Система). Конфигурация через `route.meta.title`
* и `route.meta.description`.
*/
import { computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const title = computed(() => (route.meta.title as string | undefined) ?? 'Раздел');
const description = computed(
() => (route.meta.description as string | undefined) ?? 'Раздел в разработке. Реализуется в следующих коммитах.',
);
</script>
<template>
<v-container fluid class="admin-placeholder pa-6">
<header class="page-head mb-4">
<h1 class="text-h4 page-title">{{ title }}</h1>
</header>
<v-alert type="info" variant="tonal" density="compact">
<strong>В разработке.</strong> {{ description }}
</v-alert>
</v-container>
</template>
<style scoped>
.admin-placeholder {
max-width: 1200px;
}
.page-title {
font-variation-settings: 'opsz' 28;
letter-spacing: -0.018em;
}
</style>
@@ -2,6 +2,19 @@
<div class="admin-pricing-tiers-view">
<h1 class="text-h4 mb-6">Тарифная сетка</h1>
<v-alert
v-if="errorMessage"
type="error"
variant="tonal"
class="mb-4"
density="compact"
data-testid="pricing-error-alert"
closable
@click:close="errorMessage = null"
>
{{ errorMessage }}
</v-alert>
<v-card class="mb-6" elevation="1">
<v-card-title>
Текущая активная сетка
@@ -88,21 +101,31 @@
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="successToastOpen"
:timeout="4000"
color="success"
location="bottom right"
data-testid="pricing-success-toast"
>
{{ successMessage }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import { extractErrorMessage } from '../../api/client';
/**
* SaaS-admin Тарифная сетка (Plan 4 Task 9).
* SaaS-admin Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
*
* Backend: AdminPricingTiersController (GET/POST/DELETE).
* Палитра Forest + JetBrains Mono для tnum-цифр.
*
* defineExpose ниже для Vitest unit-тестов (`load`/`submit`/`confirmDelete`/
* `editorOpen`/`active`/`scheduled`/`editor`). На прод-сборку это не влияет.
* defineExpose ниже для Vitest unit-тестов.
*/
interface Tier {
@@ -122,6 +145,11 @@ const scheduled = ref<Record<string, Tier[]>>({});
const editorOpen = ref(false);
const saving = ref(false);
// Sprint 1 G1: error/success state для UI feedback.
const errorMessage = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const successToastOpen = ref(false);
const defaultEditor: EditorRow[] = [
{ tier_no: 1, leads_in_tier: 100, price_rub: '500.00' },
{ tier_no: 2, leads_in_tier: 200, price_rub: '450.00' },
@@ -149,17 +177,28 @@ const nextMonthStart = computed(() => {
const hasScheduled = computed(() => Object.keys(scheduled.value).length > 0);
async function load(): Promise<void> {
const { data } = await axios.get('/api/admin/pricing-tiers');
active.value = data.data.active;
scheduled.value = data.data.scheduled || {};
try {
const { data } = await axios.get('/api/admin/pricing-tiers');
active.value = data.data.active;
scheduled.value = data.data.scheduled || {};
} catch (err) {
errorMessage.value = extractErrorMessage(err, 'Не удалось загрузить тарифную сетку.');
}
}
async function submit(): Promise<void> {
saving.value = true;
errorMessage.value = null;
successMessage.value = null;
try {
await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });
editorOpen.value = false;
successMessage.value = `Сохранено: новая сетка вступит в силу с ${nextMonthStart.value}.`;
successToastOpen.value = true;
await load();
} catch (err) {
errorMessage.value = extractErrorMessage(err, 'Не удалось сохранить тарифную сетку.');
// Диалог остаётся открытым пользователь может исправить и повторить.
} finally {
saving.value = false;
}
@@ -169,13 +208,33 @@ async function confirmDelete(effectiveFrom: string): Promise<void> {
if (!window.confirm(`Удалить запланированный набор с ${effectiveFrom}?`)) {
return;
}
await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
await load();
errorMessage.value = null;
successMessage.value = null;
try {
await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
successMessage.value = `Удалено: запланированный набор с ${effectiveFrom}.`;
successToastOpen.value = true;
await load();
} catch (err) {
errorMessage.value = extractErrorMessage(err, 'Не удалось удалить запланированный набор.');
}
}
onMounted(load);
defineExpose({ load, submit, confirmDelete, editorOpen, active, scheduled, editor });
defineExpose({
load,
submit,
confirmDelete,
editorOpen,
active,
scheduled,
editor,
errorMessage,
successMessage,
successToastOpen,
saving,
});
</script>
<style scoped>
@@ -1,6 +1,20 @@
<template>
<div class="admin-supplier-prices-view">
<h1 class="text-h4 mb-6">Цены поставщиков (закупка)</h1>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
class="mb-4"
density="compact"
data-testid="suppliers-fetch-error"
closable
@click:close="fetchError = null"
>
{{ fetchError }}
</v-alert>
<v-card elevation="1">
<v-data-table :headers="headers" :items="suppliers" density="comfortable" class="numeric-tnum">
<template #[`item.cost_rub`]="{ item }">
@@ -38,27 +52,52 @@
/>
</template>
<template #[`item.actions`]="{ item }">
<v-btn size="small" color="primary" :loading="!!saving[item.id]" @click="save(item)">
Сохранить
</v-btn>
<div class="d-flex flex-column align-end ga-1">
<v-btn size="small" color="primary" :loading="!!saving[item.id]" @click="save(item)">
Сохранить
</v-btn>
<v-tooltip v-if="errorMessages[item.id]" location="left">
<template #activator="{ props: tooltipProps }">
<v-icon
v-bind="tooltipProps"
color="error"
size="small"
:data-testid="`supplier-error-${item.id}`"
>
mdi-alert-circle
</v-icon>
</template>
<span>{{ errorMessages[item.id] }}</span>
</v-tooltip>
</div>
</template>
</v-data-table>
</v-card>
<v-snackbar
v-model="successToastOpen"
:timeout="3000"
color="success"
location="bottom right"
data-testid="supplier-success-toast"
>
{{ successToastText }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue';
import axios from 'axios';
import { extractErrorMessage } from '../../api/client';
/**
* SaaS-admin Цены поставщиков (Plan 4 Task 10).
* SaaS-admin Цены поставщиков (Plan 4 Task 10, Sprint 1 G2 error handling).
*
* Backend: AdminSuppliersController (GET/PATCH).
* Палитра Forest + JetBrains Mono для tnum-цифр.
*
* defineExpose ниже для Vitest unit-тестов (`load`/`save`/`suppliers`/
* `saving`). На прод-сборку это не влияет.
* defineExpose ниже для Vitest unit-тестов.
*/
interface SupplierRow {
@@ -72,6 +111,10 @@ interface SupplierRow {
const suppliers = ref<SupplierRow[]>([]);
const saving = reactive<Record<number, boolean>>({});
const errorMessages = reactive<Record<number, string>>({});
const fetchError = ref<string | null>(null);
const successToastOpen = ref(false);
const successToastText = ref('');
const headers = [
{ title: 'Code', key: 'code', sortable: false, width: 80 },
@@ -79,22 +122,32 @@ const headers = [
{ title: 'Cost (₽)', key: 'cost_rub', sortable: false, width: 140 },
{ title: 'Quality', key: 'quality_score', sortable: false, width: 100 },
{ title: 'Active', key: 'is_active', sortable: false, width: 100 },
{ title: '', key: 'actions', sortable: false, width: 120 },
{ title: 'Действия', key: 'actions', sortable: false, width: 120 },
];
async function load(): Promise<void> {
const { data } = await axios.get('/api/admin/suppliers');
suppliers.value = data.data;
fetchError.value = null;
try {
const { data } = await axios.get('/api/admin/suppliers');
suppliers.value = data.data;
} catch (err) {
fetchError.value = extractErrorMessage(err, 'Не удалось загрузить список поставщиков.');
}
}
async function save(s: SupplierRow): Promise<void> {
saving[s.id] = true;
delete errorMessages[s.id]; // очистить предыдущую ошибку перед retry
try {
await axios.patch(`/api/admin/suppliers/${s.id}`, {
cost_rub: s.cost_rub,
quality_score: s.quality_score,
is_active: s.is_active,
});
successToastText.value = `Сохранено: ${s.name} (${s.code}).`;
successToastOpen.value = true;
} catch (err) {
errorMessages[s.id] = extractErrorMessage(err, 'Не удалось сохранить изменения.');
} finally {
saving[s.id] = false;
}
@@ -102,7 +155,7 @@ async function save(s: SupplierRow): Promise<void> {
onMounted(load);
defineExpose({ load, save, suppliers, saving });
defineExpose({ load, save, suppliers, saving, errorMessages, fetchError, successToastOpen, successToastText });
</script>
<style scoped>
@@ -129,7 +129,8 @@ defineExpose({ settingsState, editOpen, editSetting, openEdit, onSettingUpdated,
<v-spacer />
<v-text-field
v-model="search"
placeholder="Поиск по ключу или описанию"
label="Поиск"
placeholder="по ключу или описанию"
prepend-inner-icon="mdi-magnify"
density="compact"
variant="outlined"
@@ -1,24 +0,0 @@
<script setup lang="ts">
import RecoveryCodesView from './RecoveryCodesView.vue';
</script>
<template>
<Story title="Auth / RecoveryCodesView" :layout="{ type: 'single', iframe: true }">
<Variant title="default">
<v-app>
<v-main class="story-form-pane">
<v-container class="d-flex justify-center align-center fill-height">
<RecoveryCodesView />
</v-container>
</v-main>
</v-app>
</Variant>
</Story>
</template>
<style scoped>
.story-form-pane {
background: #f6f3ec;
min-height: 100vh;
}
</style>
@@ -1,106 +0,0 @@
<script setup lang="ts">
/**
* Экран резервных кодов (RecoveryCodesView).
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_login.html секция #form-recovery.
* Источник логики: ТЗ v8.5 §1.6 / Прил. Г.4.2 8 одноразовых 8-символьных кодов;
* после использования код в БД удаляется (recovery_codes table); генерируются один
* раз при включении 2FA, посмотреть повторно нельзя только перегенерация.
*
* MVP: коды-заглушки. Реальные коды будут с backend через POST /2fa/recovery-codes.
*/
import { ref } from 'vue';
// TODO(phase2): получать из API после step1 настройки 2FA.
const codes = ref([
'A4FX-91KZ',
'9MRT-2P3D',
'QH7B-XK4N',
'5VLW-T8RY',
'B2ZJ-N6FP',
'D3WK-Q9MX',
'7YHC-8GVB',
'RP4S-K1NA',
]);
function downloadTxt() {
const blob = new Blob([codes.value.join('\n') + '\n'], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'liderra-recovery-codes.txt';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
async function copyAll() {
try {
await navigator.clipboard.writeText(codes.value.join('\n'));
} catch {
// Fallback на legacy execCommand добавим только если будут жалобы у нас HTTPS-only.
}
}
function handleContinue() {
// TODO(phase2): редирект на /dashboard.
}
</script>
<template>
<v-card variant="flat" :max-width="420" width="100%" color="transparent" class="recovery-card">
<header class="recovery-header">
<h1 class="text-h5 mb-1">Резервные коды</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Сохраните эти 8 одноразовых кодов в безопасном месте. Каждый можно использовать только раз вместо 2FA.
</p>
</header>
<div class="codes-grid">
<span v-for="code in codes" :key="code" class="code-item">{{ code }}</span>
</div>
<v-alert type="warning" variant="tonal" density="compact">
<strong>После закрытия страницы коды нельзя посмотреть снова</strong>. Скачайте файл или сделайте скриншот.
</v-alert>
<div class="d-flex ga-2">
<v-btn variant="outlined" prepend-icon="mdi-download" @click="downloadTxt"> Скачать .txt </v-btn>
<v-btn variant="outlined" prepend-icon="mdi-content-copy" @click="copyAll"> Копировать </v-btn>
</div>
<v-btn color="primary" block size="large" variant="flat" @click="handleContinue"> Понятно продолжить </v-btn>
</v-card>
</template>
<style scoped>
.recovery-card {
display: flex;
flex-direction: column;
gap: 16px;
}
.recovery-header h1 {
font-variation-settings: 'opsz' 26;
letter-spacing: -0.018em;
}
.codes-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.code-item {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 14px;
font-weight: 500;
padding: 10px 12px;
background: #fff;
border: 1px solid #d9d5cd;
border-radius: 6px;
text-align: center;
letter-spacing: 0.04em;
}
</style>
@@ -0,0 +1,68 @@
<script setup lang="ts">
/**
* Правовые документы заглушки оферты и политики конфиденциальности.
*
* Audit A7: ссылки /legal/offer и /legal/privacy в подвале AuthLayout вели
* на 404 (catch-all). Финальные тексты документов требуют юридической
* редактуры (реестр K3 / блокер Б-1) до этого страницы показывают честную
* заглушку «документ готовится», а не фейк-текст (юридический риск).
*
* Один view на оба документа (DRY): контент выбирается по route.params.doc.
* Маршрут /legal/:doc(offer|privacy) иные значения отсекает regex-constraint,
* уходя в catch-all 404.
*/
import { computed } from 'vue';
import { useRoute } from 'vue-router';
interface LegalDoc {
title: string;
intro: string;
}
const DOCS: Record<'offer' | 'privacy', LegalDoc> = {
offer: {
title: 'Договор-оферта',
intro: 'Публичная оферта на оказание услуг сервиса «Лидерра» — условия использования платформы, права и обязанности сторон, порядок оплаты.',
},
privacy: {
title: 'Политика конфиденциальности',
intro: 'Порядок обработки и защиты персональных данных пользователей сервиса «Лидерра» в соответствии с Федеральным законом № 152-ФЗ «О персональных данных».',
},
};
const route = useRoute();
const doc = computed<LegalDoc>(() => (String(route.params.doc) === 'privacy' ? DOCS.privacy : DOCS.offer));
</script>
<template>
<v-card variant="flat" :max-width="480" width="100%" color="transparent" class="legal-card">
<header class="legal-header">
<h1 class="text-h5 mb-1">{{ doc.title }}</h1>
<p class="text-body-2 text-medium-emphasis ma-0">{{ doc.intro }}</p>
</header>
<v-alert type="info" variant="tonal" density="compact" role="note" data-testid="legal-stub-notice">
Финальная редакция документа готовится и будет опубликована до запуска сервиса.
</v-alert>
<RouterLink to="/login" class="text-body-2 text-primary legal-back"> Вернуться ко входу</RouterLink>
</v-card>
</template>
<style scoped>
.legal-card {
display: flex;
flex-direction: column;
gap: 16px;
}
.legal-header h1 {
font-variation-settings: 'opsz' 24;
letter-spacing: -0.01em;
}
.legal-back {
text-decoration: none;
}
.legal-back:hover {
text-decoration: underline;
}
</style>
@@ -76,16 +76,17 @@
:error-messages="errors.daily_limit_target"
/>
<v-autocomplete
v-model="selectedRegions"
:items="REGIONS"
item-title="name"
item-value="code"
label="Регионы (пусто = вся РФ)"
multiple
chips
clearable
/>
<v-alert
v-if="generalError"
type="error"
variant="tonal"
density="compact"
class="mb-3"
closable
@click:close="generalError = null"
>
{{ generalError }}
</v-alert>
<div class="mt-3">
<span class="text-caption">Дни недели приёма</span>
@@ -112,8 +113,7 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import axios from 'axios';
import { REGIONS } from '../../constants/regions';
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
import type { Project } from '../../stores/projectsStore';
import DevIndexBadge from '../../components/DevIndexBadge.vue';
@@ -124,6 +124,9 @@ const props = defineProps<{
}>();
const emit = defineEmits(['update:modelValue', 'saved']);
// region_mask=255 = все 8 ФО (schema default, см. db/schema.sql §projects).
// PDD regions UI отключён до закрытия Plan 6 конфликт с 8-битной ФО-маской
// в PhonePrefixService.php (1 phone prefix 1 ФО, не субъект).
const form = reactive({
name: '',
signal_type: 'site' as 'site' | 'call' | 'sms',
@@ -131,26 +134,13 @@ const form = reactive({
sms_senders: [] as string[],
sms_keyword: '',
daily_limit_target: 50,
region_mask: 0,
region_mask: 255,
region_mode: 'include' as 'include' | 'exclude',
delivery_days_mask: 127,
});
const errors = reactive<Record<string, string[]>>({});
const saving = ref(false);
const selectedRegions = ref<number[]>([]);
watch(selectedRegions, (codes) => {
if (codes.length === 0) {
form.region_mask = 0;
form.region_mode = 'include';
} else {
// 32-bit JS bitwise limit region codes >31 не помещаются в Int32 mask.
// На MVP покрываем 1-31 (см. constants/regions.ts); для >31 нужен bigint
// или array-колонка (Plan 6 schema delta).
form.region_mask = codes.reduce((acc, c) => (c <= 31 ? acc | (1 << c) : acc), 0);
form.region_mode = 'exclude';
}
});
const generalError = ref<string | null>(null);
const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const selectedDays = ref<number[]>([0, 1, 2, 3, 4, 5, 6]);
@@ -166,10 +156,9 @@ function setWorkdays(preset: 'weekdays' | 'all') {
watch(
() => props.modelValue,
(open) => {
if (open) generalError.value = null;
if (open && props.mode === 'edit' && props.project) {
Object.assign(form, props.project);
// TODO: разобрать region_mask обратно в codes (Plan 6 ).
selectedRegions.value = [];
const days: number[] = [];
for (let i = 0; i < 7; i++) if (form.delivery_days_mask & (1 << i)) days.push(i);
selectedDays.value = days;
@@ -181,11 +170,10 @@ watch(
sms_senders: [],
sms_keyword: '',
daily_limit_target: 50,
region_mask: 0,
region_mask: 255,
region_mode: 'include',
delivery_days_mask: 127,
});
selectedRegions.value = [];
selectedDays.value = [0, 1, 2, 3, 4, 5, 6];
}
},
@@ -193,12 +181,14 @@ watch(
async function submit() {
saving.value = true;
generalError.value = null;
Object.keys(errors).forEach((k) => delete errors[k]);
try {
await ensureCsrfCookie();
if (props.mode === 'edit' && props.project) {
await axios.patch(`/api/projects/${props.project.id}`, { ...form });
await apiClient.patch(`/api/projects/${props.project.id}`, { ...form });
} else {
await axios.post('/api/projects', { ...form });
await apiClient.post('/api/projects', { ...form });
}
emit('saved');
close();
@@ -206,6 +196,8 @@ async function submit() {
const err = e as { response?: { status?: number; data?: { errors?: Record<string, string[]> } } };
if (err.response?.status === 422 && err.response.data?.errors) {
Object.assign(errors, err.response.data.errors);
} else {
generalError.value = extractErrorMessage(e);
}
} finally {
saving.value = false;
+284 -15
View File
@@ -1,18 +1,150 @@
<script setup lang="ts">
/**
* Settings API и Webhook. Token + endpoint URL + signing secret + история webhook-вызовов.
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #api.
* Settings API и Webhook (audit D2/D3/D4/D5 + J5).
*
* Реальная логика по ТЗ §5/§5.5 + schema v8.7 webhook_dedup_keys (CTO-17 addendum).
* Token + secret НЕ показываются в открытом виде после генерации (single-time view).
* API-ключ: GET /api/api-keys показывает key_prefix; «Копировать» clipboard
* + toast; «Перегенерировать» POST /api/api-keys/regenerate, полный ключ
* показывается ОДИН раз (затем доступен только префикс).
* Webhook: GET/PUT /api/tenants/me/webhook-settings (target_url + secret_prefix),
* «Тестовый webhook» POST /api/webhooks/test (реальный unsigned POST).
*
* Полный ключ и полный секрет показываются один раз в БД только bcrypt-хэши.
* Подписанная outbound-доставка событий пост-MVP.
*/
import { ref } from 'vue';
import { onMounted, ref } from 'vue';
import { listApiKeys, regenerateApiKey } from '../../api/apiKeys';
import { getWebhookSettings, saveWebhookSettings, testWebhook } from '../../api/webhooks';
import { extractErrorMessage } from '../../api/client';
const apiToken = ref('lpkapi_7g8h********************************2klm');
const webhookUrl = ref('https://crm.example.ru/api/webhook/leads');
const signingSecret = ref('whsec_********************************************');
// --- API-ключ ---
const apiKeyExists = ref(false);
const apiTokenDisplay = ref('');
const fullKeyShown = ref(false);
const tokenVisible = ref(false);
const apiKeyError = ref<string | null>(null);
const regenDialogOpen = ref(false);
const regenerating = ref(false);
// --- Webhook ---
const webhookUrl = ref('');
const secretDisplay = ref('');
const fullSecretShown = ref(false);
const secretVisible = ref(false);
const webhookError = ref<string | null>(null);
const webhookSuccess = ref<string | null>(null);
const savingWebhook = ref(false);
const testingWebhook = ref(false);
// --- общий toast (D2 copy + D5 test) ---
const toastOpen = ref(false);
const toastText = ref('');
const toastColor = ref<'success' | 'error'>('success');
function showToast(text: string, color: 'success' | 'error' = 'success'): void {
toastText.value = text;
toastColor.value = color;
toastOpen.value = true;
}
async function loadApiKey(): Promise<void> {
apiKeyError.value = null;
try {
const keys = await listApiKeys();
const active = keys[0];
if (active) {
apiKeyExists.value = true;
apiTokenDisplay.value = active.key_prefix;
fullKeyShown.value = false;
} else {
apiKeyExists.value = false;
apiTokenDisplay.value = '';
}
} catch (err) {
apiKeyError.value = extractErrorMessage(err, 'Не удалось загрузить API-ключ.');
}
}
async function loadWebhook(): Promise<void> {
webhookError.value = null;
try {
const settings = await getWebhookSettings();
if (settings) {
webhookUrl.value = settings.target_url;
secretDisplay.value = settings.secret_prefix;
fullSecretShown.value = false;
}
} catch (err) {
webhookError.value = extractErrorMessage(err, 'Не удалось загрузить настройки webhook.');
}
}
onMounted(() => {
void loadApiKey();
void loadWebhook();
});
async function copyToken(): Promise<void> {
if (apiTokenDisplay.value === '') return;
try {
await navigator.clipboard.writeText(apiTokenDisplay.value);
showToast('Скопировано в буфер обмена.', 'success');
} catch {
showToast('Не удалось скопировать — выделите и скопируйте вручную.', 'error');
}
}
async function confirmRegenerate(): Promise<void> {
regenerating.value = true;
apiKeyError.value = null;
try {
const result = await regenerateApiKey();
apiTokenDisplay.value = result.key;
fullKeyShown.value = true;
apiKeyExists.value = true;
tokenVisible.value = true;
regenDialogOpen.value = false;
} catch (err) {
apiKeyError.value = extractErrorMessage(err, 'Не удалось перегенерировать ключ.');
regenDialogOpen.value = false;
} finally {
regenerating.value = false;
}
}
async function saveWebhook(): Promise<void> {
if (savingWebhook.value) return;
savingWebhook.value = true;
webhookError.value = null;
webhookSuccess.value = null;
try {
const result = await saveWebhookSettings({ target_url: webhookUrl.value });
if (result.secret) {
secretDisplay.value = result.secret;
fullSecretShown.value = true;
secretVisible.value = true;
} else {
secretDisplay.value = result.secret_prefix;
}
webhookSuccess.value = 'Настройки webhook сохранены.';
} catch (err) {
webhookError.value = extractErrorMessage(err, 'Не удалось сохранить webhook.');
} finally {
savingWebhook.value = false;
}
}
async function runWebhookTest(): Promise<void> {
if (testingWebhook.value) return;
testingWebhook.value = true;
try {
const result = await testWebhook();
showToast(result.message, result.ok ? 'success' : 'error');
} catch (err) {
showToast(extractErrorMessage(err, 'Не удалось отправить тестовый запрос.'), 'error');
} finally {
testingWebhook.value = false;
}
}
</script>
<template>
@@ -25,18 +157,60 @@ const secretVisible = ref(false);
Используется для подписи запросов в публичный API CRM. После регенерации старый ключ перестаёт работать
немедленно.
</p>
<v-alert
v-if="apiKeyError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="api-key-error"
@click:close="apiKeyError = null"
>
{{ apiKeyError }}
</v-alert>
<v-alert
v-if="fullKeyShown"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
data-testid="api-key-once-notice"
>
Новый ключ показывается <strong>один раз</strong>. Скопируйте и сохраните его сейчас.
</v-alert>
<v-text-field
:model-value="apiToken"
:model-value="apiTokenDisplay"
:type="tokenVisible ? 'text' : 'password'"
:placeholder="apiKeyExists ? '' : 'Ключ ещё не создан — нажмите «Перегенерировать»'"
readonly
variant="outlined"
density="comfortable"
data-testid="api-key-field"
:append-inner-icon="tokenVisible ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="tokenVisible = !tokenVisible"
/>
<div class="d-flex ga-2 mt-2">
<v-btn variant="outlined" size="small" prepend-icon="mdi-content-copy">Копировать</v-btn>
<v-btn variant="outlined" size="small" color="warning" prepend-icon="mdi-refresh">
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-content-copy"
:disabled="!apiTokenDisplay"
data-testid="api-key-copy-btn"
@click="copyToken"
>
Копировать
</v-btn>
<v-btn
variant="outlined"
size="small"
color="warning"
prepend-icon="mdi-refresh"
data-testid="api-key-regen-btn"
@click="regenDialogOpen = true"
>
Перегенерировать
</v-btn>
</div>
@@ -48,22 +222,117 @@ const secretVisible = ref(false);
URL источника лидов отправляет POST с подписью HMAC-SHA256. Дедуп по
<code>(tenant_id, source_crm_id)</code> в окне 24 ч (антифрод по phone §10.8.1).
</p>
<v-text-field v-model="webhookUrl" label="Endpoint URL" variant="outlined" density="comfortable" />
<v-alert
v-if="webhookError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="webhook-error"
@click:close="webhookError = null"
>
{{ webhookError }}
</v-alert>
<v-alert
v-if="webhookSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="webhook-success"
@click:close="webhookSuccess = null"
>
{{ webhookSuccess }}
</v-alert>
<v-alert
v-if="fullSecretShown"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
data-testid="webhook-secret-once-notice"
>
Signing secret показывается <strong>один раз</strong>. Сохраните его в настройках вашего приёмника.
</v-alert>
<v-text-field
:model-value="signingSecret"
v-model="webhookUrl"
label="Endpoint URL"
placeholder="https://..."
variant="outlined"
density="comfortable"
data-testid="webhook-url-field"
/>
<v-text-field
:model-value="secretDisplay"
:type="secretVisible ? 'text' : 'password'"
label="Signing secret (HMAC)"
:placeholder="secretDisplay ? '' : 'Появится после первого сохранения URL'"
readonly
variant="outlined"
density="comfortable"
data-testid="webhook-secret-field"
:append-inner-icon="secretVisible ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="secretVisible = !secretVisible"
/>
<div class="d-flex ga-2 mt-2">
<v-btn color="primary" variant="flat" size="small">Сохранить</v-btn>
<v-btn variant="outlined" size="small" prepend-icon="mdi-test-tube"> Тестовый webhook </v-btn>
<v-btn
color="primary"
variant="flat"
size="small"
:loading="savingWebhook"
data-testid="webhook-save-btn"
@click="saveWebhook"
>
Сохранить
</v-btn>
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-test-tube"
:loading="testingWebhook"
data-testid="webhook-test-btn"
@click="runWebhookTest"
>
Тестовый webhook
</v-btn>
</div>
</v-card>
<v-dialog v-model="regenDialogOpen" :max-width="440" data-testid="regen-dialog">
<v-card>
<v-card-title>Перегенерация API-ключа</v-card-title>
<v-card-text>
Текущий ключ перестанет работать немедленно. Все интеграции с ним нужно будет обновить. Продолжить?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" :disabled="regenerating" @click="regenDialogOpen = false">Отмена</v-btn>
<v-btn
color="warning"
variant="flat"
:loading="regenerating"
data-testid="regen-confirm-btn"
@click="confirmRegenerate"
>
Перегенерировать
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="toastOpen"
:timeout="4000"
:color="toastColor"
location="bottom right"
data-testid="api-tab-toast"
>
{{ toastText }}
</v-snackbar>
</div>
</template>
+123 -18
View File
@@ -1,38 +1,129 @@
<script setup lang="ts">
/**
* Settings Профиль. Данные текущего user'а: имя, email, телефон, тайм-зона.
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #profile.
* Settings Профиль. Имя, фамилия, телефон, тайм-зона текущего пользователя
* из auth-store; сохранение через PATCH /api/auth/me (audit D1 / J6).
*
* MVP: form-fields без save (TODO: PATCH /api/me).
* Источник дизайна: liderra_v8_handoff/concepts/v8_settings.html секция #profile.
* Email read-only (меняется через support). Поле «Роль» убрано: в таблице
* users нет колонки роли (было декоративной mock-заглушкой). Кнопка смены
* аватара пока без обработчика загрузка аватара вне scope audit D1.
*/
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useAuthStore } from '../../stores/auth';
import { updateProfile } from '../../api/auth';
import { extractErrorMessage } from '../../api/client';
const fullName = ref('Иван Петров');
const email = ref('ivan.petrov@example.ru');
const phone = ref('+7 (916) 871-23-45');
const timezone = ref('Europe/Moscow');
const role = ref('Владелец');
const auth = useAuthStore();
// auth.user гарантированно не null здесь: router beforeEach дожидается
// fetchMe() до навигации на requiresAuth-маршруты (см. router/index.ts).
// Поэтому watch-ресинк (как в NotificationsTab) не нужен.
const firstName = ref(auth.user?.first_name ?? '');
const lastName = ref(auth.user?.last_name ?? '');
const phone = ref(auth.user?.phone ?? '');
const timezone = ref(auth.user?.timezone ?? 'Europe/Moscow');
const email = computed(() => auth.user?.email ?? '');
const initials = computed(() => {
const f = (auth.user?.first_name ?? '').charAt(0);
const l = (auth.user?.last_name ?? '').charAt(0);
return (f + l).toUpperCase() || '—';
});
const saving = ref(false);
const saveSuccess = ref(false);
const saveError = ref<string | null>(null);
function resetForm(): void {
firstName.value = auth.user?.first_name ?? '';
lastName.value = auth.user?.last_name ?? '';
phone.value = auth.user?.phone ?? '';
timezone.value = auth.user?.timezone ?? 'Europe/Moscow';
saveSuccess.value = false;
saveError.value = null;
}
async function save(): Promise<void> {
if (saving.value) return;
saving.value = true;
saveSuccess.value = false;
saveError.value = null;
try {
const updated = await updateProfile({
first_name: firstName.value,
last_name: lastName.value,
phone: phone.value.trim() === '' ? null : phone.value.trim(),
timezone: timezone.value,
});
auth.user = { ...auth.user!, ...updated };
saveSuccess.value = true;
} catch (err) {
saveError.value = extractErrorMessage(err, 'Не удалось сохранить профиль.');
} finally {
saving.value = false;
}
}
</script>
<template>
<div class="tab-content">
<h2 class="tab-title text-h6 mb-4">Профиль</h2>
<v-alert
v-if="saveSuccess"
type="success"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="profile-save-success"
@click:close="saveSuccess = false"
>
Профиль сохранён.
</v-alert>
<v-alert
v-if="saveError"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
closable
data-testid="profile-save-error"
@click:close="saveError = null"
>
{{ saveError }}
</v-alert>
<v-row class="profile-row">
<v-col cols="auto">
<v-avatar size="80" color="primary">
<span class="text-h5">ИП</span>
<span class="text-h5">{{ initials }}</span>
</v-avatar>
<v-btn variant="text" size="small" class="mt-2" prepend-icon="mdi-camera"> Сменить </v-btn>
</v-col>
<v-col>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field v-model="fullName" label="Полное имя" variant="outlined" density="comfortable" />
<v-text-field
v-model="firstName"
label="Имя"
variant="outlined"
density="comfortable"
data-testid="profile-first-name"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="email"
v-model="lastName"
label="Фамилия"
variant="outlined"
density="comfortable"
data-testid="profile-last-name"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
:model-value="email"
label="Email"
type="email"
variant="outlined"
@@ -43,7 +134,13 @@ const role = ref('Владелец');
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="phone" label="Телефон" variant="outlined" density="comfortable" />
<v-text-field
v-model="phone"
label="Телефон"
variant="outlined"
density="comfortable"
data-testid="profile-phone"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
@@ -53,16 +150,24 @@ const role = ref('Владелец');
density="comfortable"
persistent-hint
hint="Используется в логах и напоминаниях"
data-testid="profile-timezone"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field v-model="role" label="Роль" variant="outlined" density="comfortable" disabled />
</v-col>
</v-row>
<div class="d-flex ga-2 mt-4">
<v-btn color="primary" variant="flat">Сохранить</v-btn>
<v-btn variant="text">Отмена</v-btn>
<v-btn
color="primary"
variant="flat"
:loading="saving"
data-testid="profile-save-btn"
@click="save"
>
Сохранить
</v-btn>
<v-btn variant="text" :disabled="saving" data-testid="profile-cancel-btn" @click="resetForm">
Отмена
</v-btn>
</div>
</v-col>
</v-row>
+6
View File
@@ -28,6 +28,12 @@ Schedule::command('projects:reset-monthly')
->monthlyOn(1, '00:00')
->timezone('Europe/Moscow');
// Audit #2 Phase 14 P2: partition maintenance — создаёт разделы на 3 месяца вперёд.
// Без этой записи partitions:create-months не запускается автоматически.
Schedule::command('partitions:create-months')
->daily()
->timezone('Europe/Moscow');
// Plan 3 Task 8: 5 Schedule entries для supplier-flow.
//
// NB: ->onOneServer() требует cache_locks таблицу, которой у нас нет
+25 -1
View File
@@ -35,6 +35,7 @@ Route::prefix('/api/auth')->group(function () {
Route::post('/reset-password', 'App\Http\Controllers\Api\PasswordResetController@resetPassword');
Route::middleware('auth:sanctum')->group(function () {
Route::get('/me', 'App\Http\Controllers\Api\AuthController@me');
Route::patch('/me', 'App\Http\Controllers\Api\AuthController@updateProfile');
Route::post('/logout', 'App\Http\Controllers\Api\AuthController@logout');
Route::patch('/me/notification-preferences', 'App\Http\Controllers\Api\AuthController@updateNotificationPreferences');
});
@@ -124,6 +125,28 @@ Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->g
Route::post('/export', 'App\Http\Controllers\Api\TenantChargesController@export');
});
// Биллинг тенанта: пополнение/кошелёк/транзакции/счета (audit E1/E3).
// RLS на balance_transactions / saas_invoices требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing')->group(function () {
Route::post('/topup', 'App\Http\Controllers\Api\BillingController@topup');
Route::get('/wallet', 'App\Http\Controllers\Api\BillingController@wallet');
Route::get('/transactions', 'App\Http\Controllers\Api\BillingController@transactions');
Route::get('/invoices', 'App\Http\Controllers\Api\BillingController@invoices');
});
// API-ключи тенанта (audit D2/D3/J5). RLS на api_keys требует tenant middleware.
Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/api-keys')->group(function () {
Route::get('/', 'App\Http\Controllers\Api\ApiKeyController@index');
Route::post('/regenerate', 'App\Http\Controllers\Api\ApiKeyController@regenerate');
});
// Настройки исходящего webhook'а тенанта (audit D4/D5/J5).
Route::middleware(['auth:sanctum', 'tenant'])->group(function () {
Route::get('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@show');
Route::put('/api/tenants/me/webhook-settings', 'App\Http\Controllers\Api\WebhookSettingsController@update');
Route::post('/api/webhooks/test', 'App\Http\Controllers\Api\WebhookSettingsController@test');
});
// Сделки — manual create через UI (NewDealDialog). На prod: middleware
// 'auth:sanctum' + 'tenant', tenant_id берётся из user'а. На MVP — параметром.
//
@@ -194,8 +217,9 @@ Route::view('/register', 'welcome');
Route::view('/forgot', 'welcome');
Route::view('/reset', 'welcome'); // SPA-router рендерит ResetPasswordView для /reset/{token}
Route::view('/2fa', 'welcome');
Route::view('/recovery', 'welcome');
Route::view('/recovery-use', 'welcome');
Route::view('/legal/offer', 'welcome');
Route::view('/legal/privacy', 'welcome');
Route::view('/dashboard', 'welcome');
Route::view('/deals', 'welcome');
Route::view('/kanban', 'welcome');
+258
View File
@@ -0,0 +1,258 @@
// Parse rollup-plugin-visualizer HTML output → Top-15 chunks + critical-path total.
// Reads storage/bundle-analyze.html, extracts the inline `const data = {...};` JSON,
// computes per-chunk raw + gzip + brotli totals by walking each top-level chunk's tree
// and summing the corresponding nodeParts entries.
import { readFileSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const htmlPath = resolve(__dirname, '..', 'storage', 'bundle-analyze.html');
const html = readFileSync(htmlPath, 'utf8');
// Locate the data assignment.
const marker = 'const data = ';
const start = html.indexOf(marker);
if (start === -1) {
console.error('FATAL: marker "const data = " not found');
process.exit(2);
}
// Find the terminating `};\n` that closes the object literal.
// The line ends with `}};` (closing data) — find first `};` after start.
const afterMarker = start + marker.length;
// Trailing semicolon: search for `};` then back up. Simpler: capture until matching brace.
let depth = 0;
let i = afterMarker;
let inString = false;
let escape = false;
let firstBraceFound = false;
for (; i < html.length; i++) {
const ch = html[i];
if (escape) { escape = false; continue; }
if (ch === '\\') { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === '{') { depth++; firstBraceFound = true; }
else if (ch === '}') {
depth--;
if (firstBraceFound && depth === 0) { i++; break; }
}
}
const jsonStr = html.slice(afterMarker, i);
const data = JSON.parse(jsonStr);
// Build uid -> nodePart map (raw=renderedLength, gzip=gzipLength, brotli=brotliLength).
// In visualizer schema v2, each leaf uid lives in `nodeParts`.
const nodeParts = data.nodeParts || {};
// Walk a tree node, collect all uids in subtree.
function collectUids(node, out) {
if (!node) return;
if (node.uid) {
out.push(node.uid);
return;
}
if (Array.isArray(node.children)) {
for (const c of node.children) collectUids(c, out);
}
}
// Top-level: data.tree.children = array of chunks (name = "assets/<chunk>.js" or "assets/<chunk>.css").
const chunks = [];
for (const chunkNode of data.tree.children) {
const uids = [];
collectUids(chunkNode, uids);
let raw = 0;
let gzip = 0;
let brotli = 0;
for (const uid of uids) {
const part = nodeParts[uid];
if (!part) continue;
raw += part.renderedLength || 0;
gzip += part.gzipLength || 0;
brotli += part.brotliLength || 0;
}
chunks.push({
name: chunkNode.name,
raw,
gzip,
brotli,
moduleCount: uids.length,
});
}
// Sort by raw size descending.
chunks.sort((a, b) => b.raw - a.raw);
// Format helper.
const fmt = (n) => `${(n / 1024).toFixed(2)} kB`;
console.log('=== Top-15 chunks (sorted by raw size) ===');
console.log('| # | name | raw | gzip | brotli | modules |');
console.log('|---|---|---|---|---|---|');
chunks.slice(0, 15).forEach((c, idx) => {
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} | ${c.moduleCount} |`);
});
console.log('\n=== All chunks (full list, for critical-path identification) ===');
console.log('| # | name | raw | gzip | brotli |');
console.log('|---|---|---|---|---|');
chunks.forEach((c, idx) => {
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
});
// Critical path: eager chunks loaded on initial page render.
// For an SPA with vite-plugin + dynamic-import lazy routes, the eager set = entry chunks
// (app-*.js + app-*.css) plus any chunks they statically import.
// The entry is `resources/js/app.ts` → produces `app-<hash>.js`.
// Static imports of entry from manifest = critical path; dynamic imports = lazy.
//
// Read the manifest.json (Laravel Vite plugin) to find the entry's static imports.
const manifestPath = resolve(__dirname, '..', 'public', 'build', 'manifest.json');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
// Find entry (isEntry: true).
const entries = Object.entries(manifest).filter(([, v]) => v.isEntry);
console.log('\n=== Manifest entries (isEntry=true) ===');
for (const [key, val] of entries) {
console.log(` ${key} -> ${val.file}`);
if (val.imports) console.log(` imports: ${JSON.stringify(val.imports)}`);
if (val.css) console.log(` css: ${JSON.stringify(val.css)}`);
if (val.dynamicImports) console.log(` dynamicImports.count: ${val.dynamicImports.length}`);
}
// Resolve critical-path file set: entry.file + recursive entry.imports[].file + entry.css[].
const eagerFiles = new Set();
function addEager(manifestKey) {
const entry = manifest[manifestKey];
if (!entry) return;
if (entry.file && !eagerFiles.has(entry.file)) {
eagerFiles.add(entry.file);
// Recurse into static imports only (NOT dynamicImports).
if (Array.isArray(entry.imports)) {
for (const imp of entry.imports) addEager(imp);
}
if (Array.isArray(entry.css)) {
for (const c of entry.css) eagerFiles.add(c);
}
}
}
for (const [key, val] of entries) {
if (val.isEntry) addEager(key);
}
console.log('\n=== Critical-path eager file set (entry + static imports + css) ===');
for (const f of eagerFiles) console.log(` - ${f}`);
// Sum the corresponding chunks' gzip + raw.
let eagerRaw = 0;
let eagerGzip = 0;
let eagerBrotli = 0;
for (const f of eagerFiles) {
// Chunk name in visualizer is "assets/<file>" stripping "assets/" prefix in manifest path.
// Manifest file path is e.g. "assets/app-XXXX.js" already.
const target = chunks.find((c) => c.name === f);
if (target) {
eagerRaw += target.raw;
eagerGzip += target.gzip;
eagerBrotli += target.brotli;
} else {
// CSS files are not in tree (visualizer only tracks JS bundle internals).
// Read CSS size directly from disk.
try {
const cssPath = resolve(__dirname, '..', 'public', 'build', f);
const css = readFileSync(cssPath);
const zlib = await import('node:zlib');
const gzSize = zlib.gzipSync(css).length;
const brSize = zlib.brotliCompressSync(css).length;
eagerRaw += css.length;
eagerGzip += gzSize;
eagerBrotli += brSize;
console.log(` (CSS direct measure) ${f}: raw=${fmt(css.length)} gzip=${fmt(gzSize)} brotli=${fmt(brSize)}`);
} catch (e) {
console.log(` WARN: could not measure ${f}: ${e.message}`);
}
}
}
console.log('\n=== Critical-path eager total (visualizer-attribution; pre-minify source bytes) ===');
console.log(`raw: ${fmt(eagerRaw)}`);
console.log(`gzip: ${fmt(eagerGzip)}`);
console.log(`brotli: ${fmt(eagerBrotli)}`);
console.log(`(${eagerFiles.size} files)`);
// ==========================================================================
// DISK-TRUTH VIEW: measure emitted files directly. Visualizer's renderedLength
// is pre-minify source-byte attribution; on-disk JS is post-minify. Vite's
// build stdout reports on-disk truth. Re-compute Top-15 + critical-path from
// disk for an apples-to-apples view against the Vite log.
// ==========================================================================
import { readdirSync, statSync } from 'node:fs';
import { gzipSync, brotliCompressSync } from 'node:zlib';
const buildDir = resolve(__dirname, '..', 'public', 'build', 'assets');
const allFiles = readdirSync(buildDir);
const diskJs = [];
const diskCss = [];
for (const f of allFiles) {
const full = resolve(buildDir, f);
const st = statSync(full);
if (!st.isFile()) continue;
const content = readFileSync(full);
const entry = {
name: `assets/${f}`,
raw: content.length,
gzip: gzipSync(content).length,
brotli: brotliCompressSync(content).length,
};
if (f.endsWith('.js')) diskJs.push(entry);
else if (f.endsWith('.css')) diskCss.push(entry);
}
diskJs.sort((a, b) => b.raw - a.raw);
diskCss.sort((a, b) => b.raw - a.raw);
console.log('\n=== Top-15 chunks (DISK-TRUTH, .js only, sorted by raw on-disk size) ===');
console.log('| # | name | raw | gzip | brotli |');
console.log('|---|---|---|---|---|');
diskJs.slice(0, 15).forEach((c, idx) => {
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
});
console.log('\n=== Top-10 CSS files (DISK-TRUTH) ===');
console.log('| # | name | raw | gzip | brotli |');
console.log('|---|---|---|---|---|');
diskCss.slice(0, 10).forEach((c, idx) => {
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
});
// Critical-path eager total — DISK TRUTH using same manifest set.
let diskEagerRaw = 0;
let diskEagerGzip = 0;
let diskEagerBrotli = 0;
const diskEagerDetails = [];
for (const f of eagerFiles) {
const target = [...diskJs, ...diskCss].find((c) => c.name === f);
if (target) {
diskEagerRaw += target.raw;
diskEagerGzip += target.gzip;
diskEagerBrotli += target.brotli;
diskEagerDetails.push(target);
} else {
console.log(` WARN: ${f} not found on disk`);
}
}
console.log('\n=== Critical-path eager files (DISK TRUTH details) ===');
console.log('| # | name | raw | gzip | brotli |');
console.log('|---|---|---|---|---|');
diskEagerDetails.forEach((c, idx) => {
console.log(`| ${idx + 1} | ${c.name} | ${fmt(c.raw)} | ${fmt(c.gzip)} | ${fmt(c.brotli)} |`);
});
console.log('\n=== Critical-path eager total (DISK TRUTH = sum of emitted files) ===');
console.log(`raw: ${fmt(diskEagerRaw)}`);
console.log(`gzip: ${fmt(diskEagerGzip)}`);
console.log(`brotli: ${fmt(diskEagerBrotli)}`);
console.log(`(${eagerFiles.size} files)`);
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
use App\Models\ApiKey;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
test('GET /api/api-keys возвращает активные ключи тенанта', function () {
ApiKey::factory()->create(['tenant_id' => $this->tenant->id, 'user_id' => $this->user->id]);
$response = $this->getJson('/api/api-keys');
$response->assertOk();
expect($response->json('data'))->toHaveCount(1);
expect($response->json('data.0'))->toHaveKeys(['id', 'name', 'key_prefix', 'last_used_at', 'created_at']);
expect($response->json('data.0'))->not->toHaveKey('key_hash');
});
test('GET /api/api-keys без auth: 401', function () {
auth()->logout();
$this->getJson('/api/api-keys')->assertStatus(401);
});
test('GET /api/api-keys изолирован по тенанту', function () {
$otherTenant = Tenant::factory()->create();
$otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
ApiKey::factory()->create(['tenant_id' => $otherTenant->id, 'user_id' => $otherUser->id]);
$response = $this->getJson('/api/api-keys');
$response->assertOk();
expect($response->json('data'))->toHaveCount(0);
});
test('GET /api/api-keys не возвращает истёкшие ключи', function () {
ApiKey::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'is_active' => true,
'expires_at' => now()->subDay(),
]);
ApiKey::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'is_active' => true,
'expires_at' => now()->addYear(),
]);
$response = $this->getJson('/api/api-keys');
$response->assertOk();
expect($response->json('data'))->toHaveCount(1);
});
test('POST /api/api-keys/regenerate создаёт ключ и возвращает plaintext один раз', function () {
$response = $this->postJson('/api/api-keys/regenerate');
$response->assertStatus(201);
expect($response->json('key'))->toStartWith('lpkapi_');
expect($response->json('key_prefix'))->toBe(substr($response->json('key'), 0, 10));
expect($response->json())->toHaveKeys(['id', 'name', 'key', 'key_prefix']);
$row = ApiKey::query()->where('tenant_id', $this->tenant->id)->where('is_active', true)->first();
expect($row)->not->toBeNull();
expect($row->key_hash)->not->toBe($response->json('key'));
expect(Hash::check($response->json('key'), $row->key_hash))->toBeTrue();
});
test('POST /api/api-keys/regenerate деактивирует предыдущий активный ключ', function () {
$old = ApiKey::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'is_active' => true,
]);
$this->postJson('/api/api-keys/regenerate')->assertStatus(201);
$old->refresh();
expect($old->is_active)->toBeFalse();
expect(ApiKey::query()->where('tenant_id', $this->tenant->id)->where('is_active', true)->count())->toBe(1);
});
test('POST /api/api-keys/regenerate без auth: 401', function () {
auth()->logout();
$this->postJson('/api/api-keys/regenerate')->assertStatus(401);
});
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create([
'tenant_id' => $this->tenant->id,
'first_name' => 'Новый',
'last_name' => 'Пользователь',
'phone' => null,
'timezone' => 'Europe/Moscow',
]);
$this->actingAs($this->user);
});
test('PATCH /api/auth/me обновляет профиль и возвращает user', function () {
$response = $this->patchJson('/api/auth/me', [
'first_name' => 'Иван',
'last_name' => 'Петров',
'phone' => '+7 916 000-00-00',
'timezone' => 'Asia/Yekaterinburg',
]);
$response->assertOk();
expect($response->json('user.first_name'))->toBe('Иван');
expect($response->json('user.last_name'))->toBe('Петров');
expect($response->json('user.phone'))->toBe('+7 916 000-00-00');
expect($response->json('user.timezone'))->toBe('Asia/Yekaterinburg');
$this->user->refresh();
expect($this->user->first_name)->toBe('Иван');
expect($this->user->timezone)->toBe('Asia/Yekaterinburg');
});
test('PATCH /api/auth/me без auth: 401', function () {
auth()->logout();
$this->patchJson('/api/auth/me', [
'first_name' => 'Иван',
'last_name' => 'Петров',
'timezone' => 'Europe/Moscow',
])->assertStatus(401);
});
test('PATCH /api/auth/me: 422 при пустом first_name', function () {
$this->patchJson('/api/auth/me', [
'first_name' => '',
'last_name' => 'Петров',
'timezone' => 'Europe/Moscow',
])->assertStatus(422)->assertJsonValidationErrorFor('first_name');
});
test('PATCH /api/auth/me: 422 при пустом last_name', function () {
$this->patchJson('/api/auth/me', [
'first_name' => 'Иван',
'last_name' => '',
'timezone' => 'Europe/Moscow',
])->assertStatus(422)->assertJsonValidationErrorFor('last_name');
});
test('PATCH /api/auth/me: 422 при невалидной timezone', function () {
$this->patchJson('/api/auth/me', [
'first_name' => 'Иван',
'last_name' => 'Петров',
'timezone' => 'Mars/Olympus',
])->assertStatus(422)->assertJsonValidationErrorFor('timezone');
});
test('PATCH /api/auth/me: phone опционален (nullable)', function () {
$response = $this->patchJson('/api/auth/me', [
'first_name' => 'Иван',
'last_name' => 'Петров',
'timezone' => 'Europe/Moscow',
]);
$response->assertOk();
expect($response->json('user.phone'))->toBeNull();
});
test('GET /api/auth/me возвращает phone и timezone', function () {
$response = $this->getJson('/api/auth/me');
$response->assertOk();
expect($response->json('user'))->toHaveKeys(['phone', 'timezone']);
});
@@ -0,0 +1,181 @@
<?php
declare(strict_types=1);
use App\Models\BalanceTransaction;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create([
'balance_rub' => '14250.00',
'balance_leads' => 285,
]);
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
// ---- wallet ----
test('GET /api/billing/wallet возвращает баланс тенанта', function () {
$this->getJson('/api/billing/wallet')
->assertOk()
->assertJsonPath('balance_rub', '14250.00')
->assertJsonPath('balance_leads', 285);
});
test('GET /api/billing/wallet возвращает тариф, если он назначен', function () {
$tariffId = DB::table('tariff_plans')->where('code', 'pro')->value('id');
$this->tenant->update(['current_tariff_id' => $tariffId]);
$response = $this->getJson('/api/billing/wallet');
$response->assertOk()
->assertJsonPath('tariff.code', 'pro')
->assertJsonPath('tariff.name', 'Про');
expect($response->json('tariff.features'))->toBeArray();
});
test('GET /api/billing/wallet возвращает tariff=null без назначенного тарифа', function () {
$this->getJson('/api/billing/wallet')
->assertOk()
->assertJsonPath('tariff', null);
});
test('GET /api/billing/wallet: runway_days = null без списаний', function () {
$this->getJson('/api/billing/wallet')
->assertOk()
->assertJsonPath('runway_days', null);
});
test('GET /api/billing/wallet: runway_days рассчитан при наличии списаний', function () {
BalanceTransaction::factory()->create([
'tenant_id' => $this->tenant->id,
'type' => 'lead_charge',
'amount_rub' => '-3000.00',
'created_at' => now()->subDays(10),
]);
// 3000 ₽ / 30 дн = 100 ₽/день; баланс 14250 → floor(142.5) = 142.
expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(142);
});
test('GET /api/billing/wallet: runway_days = 0 при отрицательном балансе', function () {
$this->tenant->update(['balance_rub' => '-500.00']);
BalanceTransaction::factory()->create([
'tenant_id' => $this->tenant->id,
'type' => 'lead_charge',
'amount_rub' => '-3000.00',
'created_at' => now()->subDays(10),
]);
// Баланс уже отрицательный → runway не может быть отрицательным, клампится в 0.
expect($this->getJson('/api/billing/wallet')->json('runway_days'))->toBe(0);
});
test('GET /api/billing/wallet без auth: 401', function () {
auth()->logout();
$this->getJson('/api/billing/wallet')->assertStatus(401);
});
// ---- transactions ----
test('GET /api/billing/transactions возвращает транзакции тенанта', function () {
BalanceTransaction::factory()->count(3)->create(['tenant_id' => $this->tenant->id]);
$response = $this->getJson('/api/billing/transactions');
$response->assertOk();
expect($response->json('data'))->toHaveCount(3);
expect($response->json('meta.total'))->toBe(3);
expect($response->json('data.0'))->toHaveKeys(['id', 'code', 'type', 'amount_rub', 'created_at']);
});
test('GET /api/billing/transactions изолирован по тенанту', function () {
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id]);
$other = Tenant::factory()->create();
BalanceTransaction::factory()->create(['tenant_id' => $other->id]);
expect($this->getJson('/api/billing/transactions')->json('data'))->toHaveCount(1);
});
test('GET /api/billing/transactions фильтрует по type', function () {
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'topup']);
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'lead_charge', 'amount_rub' => '-50.00']);
BalanceTransaction::factory()->create(['tenant_id' => $this->tenant->id, 'type' => 'refund', 'amount_rub' => '10.00']);
$this->getJson('/api/billing/transactions?type=topup')
->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'topup');
$this->getJson('/api/billing/transactions?type=lead_charge')
->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'lead_charge');
$this->getJson('/api/billing/transactions?type=refund')
->assertJsonCount(1, 'data')->assertJsonPath('data.0.type', 'refund');
});
test('GET /api/billing/transactions: пагинация 20/страница', function () {
BalanceTransaction::factory()->count(25)->create(['tenant_id' => $this->tenant->id]);
expect($this->getJson('/api/billing/transactions?page=1')->json('data'))->toHaveCount(20);
expect($this->getJson('/api/billing/transactions?page=2')->json('data'))->toHaveCount(5);
});
test('GET /api/billing/transactions без auth: 401', function () {
auth()->logout();
$this->getJson('/api/billing/transactions')->assertStatus(401);
});
// ---- invoices ----
test('GET /api/billing/invoices возвращает пустой список без счетов', function () {
$this->getJson('/api/billing/invoices')
->assertOk()
->assertJsonCount(0, 'data');
});
test('GET /api/billing/invoices возвращает счета тенанта и изолирует чужие', function () {
$leId = DB::table('legal_entities')->insertGetId([
'code' => 'ooo_test_'.uniqid(),
'name' => 'ООО Тест',
'legal_form' => 'OOO',
'inn' => '7700000000',
'created_at' => now(),
]);
DB::table('saas_invoices')->insert([
'tenant_id' => $this->tenant->id,
'legal_entity_id' => $leId,
'invoice_number' => 'СЧ-2026-00001',
'payer_type' => 'legal',
'amount_net' => '990.00',
'amount_total' => '990.00',
'status' => 'issued',
'issued_at' => now(),
'expires_at' => now()->addDays(5),
]);
$other = Tenant::factory()->create();
DB::table('saas_invoices')->insert([
'tenant_id' => $other->id,
'legal_entity_id' => $leId,
'invoice_number' => 'СЧ-2026-00002',
'payer_type' => 'legal',
'amount_net' => '500.00',
'amount_total' => '500.00',
'status' => 'issued',
'issued_at' => now(),
'expires_at' => now()->addDays(5),
]);
$response = $this->getJson('/api/billing/invoices');
$response->assertOk();
expect($response->json('data'))->toHaveCount(1);
expect($response->json('data.0.invoice_number'))->toBe('СЧ-2026-00001');
});
test('GET /api/billing/invoices без auth: 401', function () {
auth()->logout();
$this->getJson('/api/billing/invoices')->assertStatus(401);
});
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use App\Services\Billing\BillingTopupService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
test('topup кредитует balance_rub и пишет append-only строку topup', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'balance_leads' => 7]);
$tx = app(BillingTopupService::class)->topup($tenant->id, '250.00', null);
expect($tx->type)->toBe('topup')
->and($tx->amount_rub)->toBe('250.00')
->and($tx->amount_leads)->toBe(0)
->and($tx->balance_rub_after)->toBe('750.00')
->and($tx->balance_leads_after)->toBe(7);
expect((string) $tenant->fresh()->balance_rub)->toBe('750.00');
});
test('topup использует bcmath — нет float-дрейфа на 0.1 + 0.2', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '0.10']);
app(BillingTopupService::class)->topup($tenant->id, '0.20', null);
expect((string) $tenant->fresh()->balance_rub)->toBe('0.30');
});
test('topup фиксирует user_id инициатора', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
$user = User::factory()->create(['tenant_id' => $tenant->id]);
$tx = app(BillingTopupService::class)->topup($tenant->id, '100.00', $user->id);
expect($tx->user_id)->toBe($user->id);
});
test('topup-строка получает log_hash через append-only hash-chain триггер', function () {
$tenant = Tenant::factory()->create(['balance_rub' => '0.00']);
$tx = app(BillingTopupService::class)->topup($tenant->id, '100.00', null);
$hasHash = DB::table('balance_transactions')
->where('id', $tx->id)
->whereNotNull('log_hash')
->exists();
expect($hasHash)->toBeTrue();
});
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create(['balance_rub' => '500.00', 'balance_leads' => 12]);
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
test('POST /api/billing/topup кредитует баланс и возвращает 201', function () {
$response = $this->postJson('/api/billing/topup', ['amount_rub' => 250]);
$response->assertStatus(201)
->assertJsonPath('balance_rub', '750.00')
->assertJsonPath('transaction.type', 'topup')
->assertJsonPath('transaction.amount_rub', '250.00');
expect((string) $this->tenant->fresh()->balance_rub)->toBe('750.00');
});
test('POST /api/billing/topup пишет строку balance_transactions с user_id', function () {
$this->postJson('/api/billing/topup', ['amount_rub' => 100])->assertStatus(201);
$this->assertDatabaseHas('balance_transactions', [
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'type' => 'topup',
'amount_rub' => '100.00',
'balance_rub_after' => '600.00',
]);
});
test('POST /api/billing/topup использует bcmath-точность', function () {
$this->tenant->update(['balance_rub' => '0.10']);
$this->postJson('/api/billing/topup', ['amount_rub' => 100.20])->assertStatus(201);
expect((string) $this->tenant->fresh()->balance_rub)->toBe('100.30');
});
test('POST /api/billing/topup не затрагивает баланс чужого тенанта', function () {
$otherTenant = Tenant::factory()->create(['balance_rub' => '777.00']);
$this->postJson('/api/billing/topup', ['amount_rub' => 100])->assertStatus(201);
// Топап эндпоинт не принимает tenant_id — резолвит из auth-пользователя;
// баланс чужого тенанта неприкосновенен (defense-in-depth, паттерн проекта).
expect((string) $otherTenant->fresh()->balance_rub)->toBe('777.00');
});
test('POST /api/billing/topup отклоняет сумму ниже минимума 100 ₽', function () {
$this->postJson('/api/billing/topup', ['amount_rub' => 50])
->assertStatus(422)
->assertJsonValidationErrors('amount_rub');
});
test('POST /api/billing/topup отклоняет отсутствующую сумму', function () {
$this->postJson('/api/billing/topup', [])
->assertStatus(422)
->assertJsonValidationErrors('amount_rub');
});
test('POST /api/billing/topup отклоняет более 2 знаков после запятой', function () {
$this->postJson('/api/billing/topup', ['amount_rub' => 100.123])
->assertStatus(422)
->assertJsonValidationErrors('amount_rub');
});
test('POST /api/billing/topup без auth: 401', function () {
auth()->logout();
$this->postJson('/api/billing/topup', ['amount_rub' => 100])->assertStatus(401);
});
+2 -2
View File
@@ -4,10 +4,10 @@ declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
uses(RefreshDatabase::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
@@ -5,10 +5,10 @@ declare(strict_types=1);
use App\Models\Project;
use App\Models\SupplierProject;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(DatabaseTransactions::class);
uses(RefreshDatabase::class);
test('Project has signal_type, signal_identifier in fillable', function () {
$tenant = Tenant::factory()->create();
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Cache\ArrayStore;
use Illuminate\Support\Facades\Cache;
/**
* Quirk 72 regression guard see tests/TestCase.php::setUp().
*
* Supplier code (SupplierPortalClient::loadSession, RefreshSupplierSessionJob,
* CsvReconcileJob, RouteSupplierLeadJob) hardcodes Cache::store('redis'),
* bypassing phpunit.xml's CACHE_STORE=array. The Redis store is a shared
* external service: under `pest --parallel` every worker collides on the
* global `supplier:session` key, so one worker's afterEach forget()/flush()
* races another worker's mid-test loadSession().
*
* TestCase::setUp() repoints the `redis` cache store at the in-process
* `array` driver, making it hermetic and worker-local. This test fails
* (RedisStore) if that override is ever removed.
*/
test('redis cache store resolves to the in-process array driver under tests', function (): void {
expect(Cache::store('redis')->getStore())->toBeInstanceOf(ArrayStore::class);
});
@@ -5,6 +5,7 @@ declare(strict_types=1);
use App\Exceptions\Supplier\SupplierTransientException;
use App\Jobs\RouteSupplierLeadJob;
use App\Jobs\Supplier\CsvReconcileJob;
use App\Jobs\Supplier\RefreshSupplierSessionJob;
use App\Mail\CsvDriftAlertMail;
use App\Models\SupplierLead;
use App\Models\SupplierProject;
@@ -47,7 +48,18 @@ function putSupplierSession(): void
beforeEach(function () {
Mail::fake();
Bus::fake();
// Partial fake: only RouteSupplierLeadJob is intercepted (what we assert on).
// RefreshSupplierSessionJob must NOT be faked — it must run our mock below
// so that loadSession() can recover if a concurrent afterEach wipes the session.
Bus::fake([RouteSupplierLeadJob::class]);
// Bind a mock that re-puts the session when dispatch_sync triggers it during a race.
app()->bind(RefreshSupplierSessionJob::class, fn () => new class
{
public function handle(): void
{
putSupplierSession();
}
});
// NB: NOT Cache::store('redis')->flush() — flush wipes session keys belonging to
// OTHER parallel tests (cross-pollution). Just forget our reserved keys + re-put.
Cache::store('redis')->forget('supplier:csv_reconcile');
@@ -22,6 +22,8 @@ uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function (): void {
Carbon::setTestNow(Carbon::parse('2026-05-12 10:00:00', 'Europe/Moscow'));
Cache::store('redis')->put('supplier:session', [
'phpsessid' => 'sess',
'csrf' => 'csrf',
+16 -2
View File
@@ -19,6 +19,15 @@ beforeEach(function () {
// Чистим RateLimiter между тестами — иначе lockout из одного теста
// загрязняет следующий.
RateLimiter::clear("webhook:{$this->tenant->id}");
// Audit-fix B3: дефолт isHmacRequired() изменён на true. Тесты, проверяющие
// НЕ-HMAC аспекты (payload-валидация, rate-limit, CSRF), явно ставят флаг в
// false — иначе запрос без подписи получит 401 ещё до этих проверок.
SystemSetting::firstOrCreate(
['key' => 'webhook_hmac_required'],
['value' => 'false', 'type' => 'bool', 'description' => 'test default', 'updated_at' => now()],
);
SystemSetting::where('key', 'webhook_hmac_required')->update(['value' => 'false']);
});
test('POST /api/webhook/{token} с валидным payload возвращает 202 + dispatch ProcessWebhookJob', function () {
@@ -154,15 +163,20 @@ test('HMAC: невалидная подпись → 401, dispatch НЕ прои
Bus::assertNothingDispatched();
});
test('HMAC: отсутствие header → пропускаем (backward-compat) → 202', function () {
test('HMAC: настройка отсутствует → HMAC обязателен по умолчанию (B3) → 401', function () {
Bus::fake();
// Audit-fix B3: code-default isHmacRequired() = true. Удаляем настройку,
// чтобы проверить именно отсутствие ключа в system_settings.
SystemSetting::where('key', 'webhook_hmac_required')->delete();
$r = $this->postJson("/api/webhook/{$this->tenant->webhook_token}", [
'vid' => 1,
'project' => 'X',
'phone' => '+7 (999) 000-00-00',
'time' => time(),
]);
$r->assertStatus(202);
$r->assertStatus(401);
Bus::assertNothingDispatched();
});
test('rate-limit: системный лимит RPS×60 в минуту, 429 + Retry-After на превышении', function () {
@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
use App\Models\OutboundWebhookSubscription;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Http;
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
$this->actingAs($this->user);
});
test('GET webhook-settings: null когда подписки нет', function () {
$response = $this->getJson('/api/tenants/me/webhook-settings');
$response->assertOk();
expect($response->json('data'))->toBeNull();
});
test('GET webhook-settings возвращает подписку тенанта', function () {
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://crm.example.ru/hook',
]);
$response = $this->getJson('/api/tenants/me/webhook-settings');
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
expect($response->json('data'))->toHaveKeys(['target_url', 'secret_prefix', 'events', 'is_active']);
expect($response->json('data'))->not->toHaveKey('secret_hash');
});
test('GET webhook-settings изолирован по тенанту', function () {
$otherTenant = Tenant::factory()->create();
$otherUser = User::factory()->create(['tenant_id' => $otherTenant->id]);
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $otherTenant->id,
'user_id' => $otherUser->id,
'target_url' => 'https://other.example.ru/hook',
]);
$response = $this->getJson('/api/tenants/me/webhook-settings');
$response->assertOk();
expect($response->json('data'))->toBeNull();
});
test('PUT webhook-settings создаёт подписку и возвращает secret один раз', function () {
$response = $this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'https://crm.example.ru/hook',
]);
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://crm.example.ru/hook');
expect($response->json('data.secret'))->toStartWith('whsec_');
expect($response->json('data.events'))->toBeArray()->not->toBeEmpty();
$row = OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->first();
expect($row)->not->toBeNull();
expect(Hash::check($response->json('data.secret'), $row->secret_hash))->toBeTrue();
});
test('PUT webhook-settings обновляет URL существующей подписки без нового secret', function () {
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://old.example.ru/hook',
]);
$response = $this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'https://new.example.ru/hook',
]);
$response->assertOk();
expect($response->json('data.target_url'))->toBe('https://new.example.ru/hook');
expect($response->json('data'))->not->toHaveKey('secret');
expect(OutboundWebhookSubscription::query()->where('tenant_id', $this->tenant->id)->count())->toBe(1);
});
test('PUT webhook-settings: 422 при не-https URL', function () {
$this->putJson('/api/tenants/me/webhook-settings', [
'target_url' => 'http://insecure.example.ru/hook',
])->assertStatus(422)->assertJsonValidationErrorFor('target_url');
});
test('POST webhooks/test отправляет запрос и возвращает результат', function () {
Http::fake(['*' => Http::response(['ok' => true], 200)]);
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://crm.example.ru/hook',
]);
$response = $this->postJson('/api/webhooks/test');
$response->assertOk();
expect($response->json('ok'))->toBeTrue();
expect($response->json('status'))->toBe(200);
Http::assertSent(fn ($req) => $req->url() === 'https://crm.example.ru/hook');
});
test('POST webhooks/test возвращает ok=false при ошибке endpoint', function () {
Http::fake(['*' => Http::response([], 500)]);
OutboundWebhookSubscription::factory()->create([
'tenant_id' => $this->tenant->id,
'user_id' => $this->user->id,
'target_url' => 'https://crm.example.ru/hook',
]);
$response = $this->postJson('/api/webhooks/test');
$response->assertOk();
expect($response->json('ok'))->toBeFalse();
expect($response->json('status'))->toBe(500);
});
test('POST webhooks/test: 422 когда подписки нет', function () {
$this->postJson('/api/webhooks/test')->assertStatus(422);
});
test('GET webhook-settings без auth: 401', function () {
auth()->logout();
$this->getJson('/api/tenants/me/webhook-settings')->assertStatus(401);
});

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