Compare commits

...

209 Commits

Author SHA1 Message Date
Дмитрий 345d14d285 docs(plan): Sprint 5B — markdownlint-fix плана (MD031/MD032)
markdownlint-cli2 --fix: blanks-around-lists/fences в плане 5B.
0 errors. Pre-existing 26 ошибок в планах Sprint 4/5A — вне scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 04:03:36 +03:00
Дмитрий bc24420ad4 style(ui): Sprint 5B — prettier-формат затронутых файлов
Регрессия full: prettier --check на 5 файлах, тронутых Sprint 5B
(T2/T3/T4). Whitespace-only, 0 изменений поведения — Vitest 67/67
на затронутых спеках. Pre-existing prettier-дрейф 28 НЕ-5B файлов
оставлен (вне scope спринта).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 04:03:36 +03:00
Дмитрий 788c7ab336 feat(ui): C6 — degradation-alert в NewDealDialog при провале загрузки списков 2026-05-17 03:48:39 +03:00
Дмитрий eb41b65dad fix(ui): C3 — сброс toast-текста + типизация теста (review-fixup) 2026-05-17 03:44:50 +03:00
Дмитрий 095032a231 feat(ui): C3 — кнопка «Экспорт» в шапке DealsView экспортирует весь список 2026-05-17 03:39:32 +03:00
Дмитрий adb5d87d1d fix(ui): B3 — ⌘K open-only + DOM-тесты палитры (review-fixup) 2026-05-17 03:33:51 +03:00
Дмитрий 8b3ea3ed2e feat(ui): B3 — минимальная ⌘K command-palette навигации 2026-05-17 03:28:05 +03:00
Дмитрий d3746406a6 docs(plan): Sprint 5B — Layout/views (B2/B3/C3/C6/C7)
План 6 задач портал-аудита Sprint 5B. T2 NAV_ITEMS поправлен 7→8
(добавлен «Импорт данных» /import — сверено с origin/main-сайдбаром).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:21:45 +03:00
Дмитрий 1a3a1df604 docs(ui): B2 — актуализация комментариев AppSidebar (review-fixup)
Code-quality review T1: stale JSDoc «Counts — mock» теперь ложный
(count live из API); +поясняющий комментарий к null→undefined цепочке.
Comment-only, 0 изменений поведения. Vitest 6/6 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:20:02 +03:00
Дмитрий 4b0809a82d feat(ui): B2 — счётчик «Сделки» в сайдбаре из API вместо хардкода 2026-05-17 03:14:13 +03:00
Дмитрий cefb71f5fa feat(api): B2 — count_only параметр на GET /api/deals 2026-05-17 03:11:03 +03:00
Дмитрий fef9499e1a docs(plan): Sprint 5A — Auth polish (A1/A4/A5/A6/A8)
План portal-audit Sprint 5 под-план A: 5 P2 UX-debt эпиков подсистемы
Auth — A1 (Yandex SSO disabled+tooltip), A4 (ResetPassword confirm
mismatch error), A5 (ForgotPassword fallback regression-тест),
A6 (TwoFactor реальный TOTP-отсчёт), A8 (DemoSeeder demo:seed + README).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:23:26 +03:00
Дмитрий 72c8cad963 fix(dev): A8 review — production-guard в DemoSeeder + точность README/теста (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий aa77814206 feat(dev): A8 — composer demo:seed + README демо-данные + idempotency-тест (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий fcf8626c26 fix(auth): A6 review — ранний return при redirect на /login (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий be51c97dce feat(auth): A6 — реальный обратный отсчёт TOTP-окна в 2FA (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий 4a1663b426 test(auth): A5 — regression generic fallback ForgotPassword (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий 17d9f16b7d feat(auth): A4 — ResetPassword ошибка несовпадения паролей (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий efb0dea5ed feat(auth): A1 — Yandex 360 SSO disabled + tooltip (Sprint 5A) 2026-05-17 02:23:26 +03:00
Дмитрий 120a386f05 feat(map): automation-graph — раздел «Хотелки» (отложенный backlog)
Слой WISHLIST: панель отложенных хотелок развития мозга/портала + кнопка-легенда «💡 Хотелки» в нижней легенде. Засеяно 4 хотелками раздела E8: K7-spike, мост claude-mem→ReasoningBank, claude-mem #1, двухуровневый ремонтник. Аддитивно — режим легенды наравне с «Разделы»; счётчики узлов/рёбер не меняются.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 22:31:26 +03:00
Дмитрий c64be74992 fix(import): final review — /import в явный список Route::view
Final review (🟢 low): SPA-маршрут /import работал через Route::fallback,
но все остальные app-маршруты перечислены явно в Route::view-блоке
(CLAUDE.md документирует явный список как намеренный паттерн — catch-all
перехватывал бы _test/* runtime-роуты Pest). /import добавлен в список
для консистентности и устойчивости.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:33:15 +03:00
Дмитрий 6a3593de7a fix(import): final review — tenant-изоляция import_unknown_statuses под BYPASSRLS
Final review нашёл: HistoricalImportService::loadStatusOverrides и
persistUnknownStatuses запрашивали import_unknown_statuses без явного
where(tenant_id), полагаясь на RLS через SET LOCAL. Но queue worker на prod
работает под crm_supplier_worker — BYPASSRLS-роль (00_create_roles.sql §5),
SET LOCAL не фильтрует → cross-tenant утечка: импорт тенанта A мог подхватить
resolved-маппинг тенанта B и инкрементировать его occurrences.

Добавлен явный where(tenant_id) в обе выборки (конвенция defense-in-depth
00_create_roles.sql:64 — WHERE-фильтры обязательны под BYPASSRLS). +тест
cross-tenant изоляции (red-green verified: без фикса 'Архив' тенанта A
получал status 'closed' из чужого маппинга).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:31:56 +03:00
Дмитрий de066145d3 feat(import): маршрут /import + сайдбар + инструкция H9
- router/index.ts: добавлен маршрут /import (name=import, layout=app,
  requiresAuth=true, transition=ld-route-fadeup, devIndex=29)
- AppSidebar.vue: пункт «Импорт данных» (mdi-database-import-outline)
  добавлен в группу «Работа» следом за Дашборд
- router.spec.ts: TDD-кейс маршрута /import (layout=app, requiresAuth=true)
- docs/Как_перенести_данные_из_crm-bp-gr.md: инструкция H9 (4 шага + таблица ошибок)
- cspell-words.txt: добавлены формы глагола «замапить»

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:14:04 +03:00
Дмитрий 96cb64f33a refactor(import): Task 10 code-review — POLL_INTERVAL_MS константа
Code-review Task 10 (🟡): магическое число 2000 (интервал polling'а) вынесено
в именованную константу POLL_INTERVAL_MS — паттерн файла (как в DashboardView).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:08:33 +03:00
Дмитрий 59dac9be56 feat(import): ImportView — экран импорта CSV
TDD: spec (3 tests) first, then component.
ImportView.vue: upload form + polling + history table + unknown-statuses banner.
Uses api/imports (uploadImport/listImports/getImport/getUnknownStatuses).
setInterval callback wrapped in named async fn (pollOnce) — no eslint-disable needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 20:05:15 +03:00
Дмитрий 7f05c4ab16 feat(import): api/imports.ts + UnknownStatusesDialog (wizard маппинга)
- api/imports.ts: типы ImportLogResource/UnknownStatus/StatusMapping,
  функции uploadImport/listImports/getImport/getUnknownStatuses/resolveUnknownStatuses
  (apiClient из ./client, стиль api/dashboard.ts)
- UnknownStatusesDialog.vue: wizard маппинга незамапленных статусов воронки
  (ТЗ §6.4/§6.6), 14 канонических slug, defineExpose(selection, save)
- Vitest 3/3 (tests/Frontend/UnknownStatusesDialog.spec.ts)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:58:33 +03:00
Дмитрий 5d64ca552e test(import): Task 9 code-review — cross-tenant тест ImportController::show
Code-review Task 9 (🟡): добавлен тест защиты show() — пользователь одного
тенанта получает 403 при запросе import_log другого тенанта (покрывает
abort_if defense-in-depth в ImportController::show). phpstan-baseline
регенерирован — инкремент count ложного TestCall-срабатывания (квирк 25).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:52:31 +03:00
Дмитрий a7038367e4 feat(import): ImportController + маршруты /api/imports
Task 9 Sprint 4: ImportController с 5 методами (store/index/show/
unknownStatuses/resolveUnknownStatuses), 2 FormRequest (StoreImportRequest
/ ResolveUnknownStatusesRequest), 5 маршрутов в routes/web.php под
auth:sanctum+tenant. Defense-in-depth: явный where(tenant_id) поверх RLS
(postgres superuser обходит BYPASSRLS на dev — паттерн DealController).
Тест 8/8, Larastan baseline regen (только TestCall false positives).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:45:47 +03:00
Дмитрий 15b53a9b2b feat(import): ImportLeadsJob — queued-обработчик CSV-импорта
ShouldQueue-job: читает CSV через Storage::disk('local'), парсит через
CsvLeadsParser, импортирует через HistoricalImportService (4 аргумента),
обновляет import_log (pending→processing→done|failed), шлёт
ImportCompletedNotification. RLS через SET LOCAL в каждой транзакции.
tries=1 (идемпотентность на уровне строк, повторный прогон искажает
счётчики — авто-ретрай отключён). Larastan: 0 новых ошибок.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:31:19 +03:00
Дмитрий 952263b3e5 feat(import): Mailable ImportCompletedNotification
Task 8 — email-уведомление пользователю по завершении CSV-импорта
исторических лидов (ТЗ §6.6). Два исхода: done (счётчики строк) /
failed (сообщение об ошибке). Blade-шаблон markdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:23:00 +03:00
Дмитрий 5416f809a3 fix(import): Task 6 code-review — final-класс + честное имя поля errors
Code-review Task 6 (non-blocking 🟡): HistoricalImportService объявлен final
(симметрия с ImportResult, утилитарный сервис без наследования). Ключ ошибки
upsert'а переименован 'line' → 'source_crm_id' — поле хранит идентификатор из
исходной CRM, а не файловую строку (в отличие от CsvParseResult::errors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:17:59 +03:00
Дмитрий 0b9d73018d feat(import): HistoricalImportService — идемпотентный upsert лидов
Реализован HistoricalImportService с ImportResult DTO и 7 feature-тестами
(TDD). Идемпотентный upsert через pg_advisory_xact_lock + webhook_dedup_keys;
создание партиций через MonthlyPartitionManager; напоминания; unknown-статусы
с tenant-переопределениями; dry_run режим; historical_import tx без списания
баланса. Попутный fix CarbonImmutable-петли в MonthlyPartitionManager::ensureRange.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 19:10:23 +03:00
Дмитрий 29a4d01ff4 fix(import): Task 5 code-review — final-класс CsvLeadsParser + self::EXPECTED_COLUMNS
Code-review Task 5 (non-blocking 🟡): CsvLeadsParser объявлен final (симметрия
с DTO ParsedLeadRow/CsvParseResult, утилитарный класс без наследования);
строка ошибки про число колонок использует self::EXPECTED_COLUMNS вместо
литерала 9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:51:27 +03:00
Дмитрий 8f2b82405a feat(import): CsvLeadsParser + DTO ParsedLeadRow/CsvParseResult
Парсер CSV-выгрузки лидов crm.bp-gr.ru (ТЗ §6.2/§6.3): срезает UTF-8 BOM,
разбирает строки через str_getcsv, валидирует телефон (7XXXXXXXXXX) и даты
(Y/m/d H:i:s), срезает префикс B[123]_ из названия проекта. Невалидные
строки не роняют парсинг — собираются в errors[] с абсолютным номером строки.
Тесты: 5/5 (unit, без DB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:47:15 +03:00
Дмитрий 424987bedb feat(import): сервис StatusRuToSlugMapper (ТЗ §6.4)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:42:49 +03:00
Дмитрий ef4df2925f feat(import): сервис MonthlyPartitionManager + рефактор partitions:create-months
Выносит DDL-логику создания месячных RANGE-партиций из команды
PartitionsCreateMonths в переиспользуемый сервис MonthlyPartitionManager.
Сервис используется командой (DRY) и будет использован HistoricalImportService
для партиций под исторические даты CSV.

- MonthlyPartitionManager::ensureRange(table, from, to) — гарантирует партиции
  под диапазон дат, идемпотентно; отвергает незарегистрированные таблицы
- MonthlyPartitionManager::ensureMonth(table, monthStart) — одна партиция
- PartitionsCreateMonths рефакторена: убраны PARTITIONED_TABLES, partitionExists(),
  use DB; inject MonthlyPartitionManager через handle()
- Test: MonthlyPartitionManagerTest (3 теста, DatabaseTransactions — DDL откат)
- Regression: PartitionsCreateMonthsTest (4 теста) — зелёный, поведение не изменилось

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:37:29 +03:00
Дмитрий 8bc8c53a3b feat(import): Eloquent-модели ImportLog + ImportUnknownStatus
- ImportLog: $attributes зеркалят DB DEFAULT'ов (status/entity_type/dry_run),
  CREATED_AT/UPDATED_AT=null (таблица использует started_at/finished_at),
  casts для mapping_config (array) и dry_run (boolean)
- ImportUnknownStatus: scope unresolved() (whereNull mapped_to_slug),
  BelongsTo tenant
- Фабрики ImportLogFactory + ImportUnknownStatusFactory
- Тест ImportModelsTest (2/2, DatabaseTransactions, idempotent)
- ide-helper:models перегенерирован под новые модели
- phpstan-baseline регенерирован (квирк 25: TestCall::$tenant/$user)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:29:33 +03:00
Дмитрий 98549c52be fix(import): Task 1 code-review — убран фантомный GRANT-блок + усилен UNIQUE-тест
Code-review Task 1: явный per-table GRANT-блок для import_unknown_statuses
использовал несуществующие роли (crm_app_admin / crm_readonly). Реальные роли —
crm_app_user / crm_admin_user / crm_migrator / crm_audit_writer /
crm_supplier_worker (db/00_create_roles.sql). Блок удалён целиком из
db/02_grants.sql и db/schema.sql: import_unknown_statuses — обычная
tenant-scoped таблица, покрыта umbrella GRANT ... ON ALL TABLES +
ALTER DEFAULT PRIVILEGES (как import_log), явный per-table grant не нужен.

ImportSchemaTest: UNIQUE-тест усилен — проверяет состав колонок
(status_ru, tenant_id), а не только наличие constraint'а типа 'u'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:20:12 +03:00
Дмитрий 70f8b210f4 feat(import): H1+H2 — схема import_unknown_statuses + enrichment import_log
Sprint 4 Task 1 (schema delta §6):
- H1: новая таблица import_unknown_statuses (RLS tenant_isolation,
  UNIQUE(tenant_id,status_ru), FK→tenants/import_log/lead_statuses/users)
- H2: +5 колонок import_log (entity_type, source_system, mapping_config,
  unknown_statuses_count, dry_run)
- schema.sql v8.20→v8.21 (64 таблицы / 118 индексов / 40 RLS-политик)
- db/CHANGELOG_schema.md v8.21 entry
- db/02_grants.sql v8.21 section (crm_app_user/crm_app_admin/crm_readonly)
- migrate: hasTable/hasColumn guards (fresh-safe)
- tests: 3 Pest-теста (ImportSchemaTest) + SchemaDeltaTest v8.21 metrics
- ide-helper: _ide_helper.php + _ide_helper_models.php (были отсутствуют
  в worktree, phpstan падал молча из-за missing scanFiles entry)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 17:01:51 +03:00
Дмитрий 4937225da3 docs(plan): Sprint 4 — историческая миграция лидов §6 (H1-H6/H8/H9)
План CSV-импорта исторических лидов из crm.bp-gr.ru. 12 задач: schema delta
(import_unknown_statuses + enrichment import_log), сервисы парсинга/маппинга/
upsert'а, ImportLeadsJob, ImportController, frontend ImportView + wizard
маппинга статусов, маршрут /import + инструкция H9. H7 (импорт проектов)
вынесен — формат CSV проектов не специфицирован в ТЗ §6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:28:30 +03:00
Дмитрий da4d46b0d8 feat(map): automation-graph — полная актуализация по аудиту
Аудит карты против фактического состояния (~/.claude/settings.json,
project .claude/settings.json, .mcp.json, lefthook.yml, .claude/skills,
memory/). +20 узлов (83 → 103):
- плагины 5→9: +skill-creator, claude-code-setup, plugin-dev, context7
- хуки 5→12: +economy-self-check/skill-marker/skill-check/state-guard/
  postcompact/verifier (Stop) + ruflo-queen-hook
- memory 16→24: +audit_B/C, supplier_crm, full_audit_05-12/14, sprint1/2/3
- скилы проекта 2→3: +regression
Квирк 72 устранён (commit 0fa1a73) — 2 конфликта переоценены:
ag_pest↔mcp_redis BLACK→GREEN; ruflo_daemon↔ag_pest → квирки 73/77.
Все 103 узла размечены по разделам; E8 «Самообучение Claude» наполнен
(skill-creator, claude-code-setup). Топология 103 / 106 рёбер /
11 конфликтов (🔴1/3/🟢7). Smoke ✓.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:08:07 +03:00
Дмитрий f9f9fec97d feat(map): automation-graph — раздел E8 «Самообучение Claude»
+1 раздел в блок E «Мета и управление». Итого 40 разделов
(13 наполнены / 27 пусты). E8 — пустой каркас под будущий playbook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:08:07 +03:00
Дмитрий e74e8aa6d6 feat(map): automation-graph — слой функциональных разделов (iter7)
39 разделов деятельности Лидерры (5 блоков A–E) как классификация:
все 83 узла распределены по разделам — 13 наполнены, 26 пусты
(пустые — бизнес-домены, под которые в карте dev-автоматики узлов
ещё нет). Кнопка-панель «📂 Разделы» + строка «Раздел» в Паспорте
узла. Топология карты (83/90/11) и радиальный layout без изменений.
Основа будущего «мозга»: 1 раздел = 1 playbook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:08:07 +03:00
Дмитрий 447ef593fa feat(api): J1 — auth:sanctum+tenant middleware на /api/deals*
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 15:18:13 +03:00
Дмитрий 9f70d89046 feat(api): J2 — стаб-гейт EnsureSaasAdmin на /api/admin/* 2026-05-16 15:01:07 +03:00
Дмитрий 42a246d633 docs(plan): Sprint 3F — API middleware (J1/J2) 2026-05-16 14:56:11 +03:00
Дмитрий ca0c4d9318 feat(admin): G5/G6 frontend — incident detail view + РКН-notify 2026-05-16 14:09:53 +03:00
Дмитрий 3269434746 feat(admin): G6 backend — incident РКН-notify endpoint 2026-05-16 14:09:53 +03:00
Дмитрий 5e12126d71 feat(admin): G5 backend — incident detail endpoint 2026-05-16 14:09:53 +03:00
Дмитрий 8e3e06f3a4 fix(admin): G4 review — real AxiosError in error test + balance/NaN guards + a11y 2026-05-16 14:09:53 +03:00
Дмитрий c85424968e feat(admin): G4 frontend — billing row-actions menu + dialogs 2026-05-16 14:09:53 +03:00
Дмитрий 00f6611bc1 fix(admin): G4 review — lockForUpdate on refund balance + self-contained tariff tests 2026-05-16 14:09:53 +03:00
Дмитрий adabcf15a4 feat(admin): G4 backend — billing tenant actions (status/refund/tariff)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 14:09:53 +03:00
Дмитрий 3ea86d62ff docs(plan): Sprint 3D — Admin actions (G4/G5/G6) implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 14:09:53 +03:00
Дмитрий 9a25e658b3 docs(map): automation-graph — нормативный sync под реколлаж ruflo 16.05
Карта приведена к реколлажу ruflo (Pravila v1.16 / CLAUDE.md v2.2 /
PSR_v1 v3.2 / Tooling v2.2): убраны «уровень −1», «§12 sub-policy»,
«R0 sub-policy delegation pattern».

- 4 узла-правила: лейблы v1.16/v2.2/v3.2/v2.2 + NODE_META.changed 16.05
- nd()-блоки правил: §12 — hard-rule уровня 0, R0 — головной фильтр,
  цепочка 7-уровневая (0–6), §3.5/§4.10 — advisory-подсистема
- ruflo_queen: advisory/automation-подсистема, не entry-point;
  reportsTo → Pravila §14 + CLAUDE.md §3.5/Tooling §4.10
- 4 ребра ruflo_queen→{правило} «перенял sub-policy» → flipped
  {правило}→ruflo_queen (§14 queen-триггер / §3.5 / §4.10 описывают)
- конфликт ruflo_queen↔pravila 🔴🟢 (реколлаж = правило-фикс):
  классификация 🔴2/4/🟢5 → 🔴1/4/🟢6
- §12 sub-policy → hard-rule level 0 в superpowers/hk_economy/mem_sp
  + CONFLICT hk_economy↔superpowers + EDGE_DETAILS

Топология 83/90/11 без изменений (downstream-sync, не iter).
Visual smoke 8/8 PASS (Playwright): 83 узла / 90 рёбер рендерятся,
0 JS-ошибок, легенды отредактированных узлов рендерятся корректно.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 13:44:14 +03:00
Дмитрий 73d2733522 docs(fix): claude-brain spec — битая ссылка на CLAUDE.md (../../../../../→../../../)
Pre-existing баг: 5×../ перелетал repo-root на 2 уровня. Поймана pre-push lychee реколлажа. Корректный путь от docs/superpowers/specs/ до repo-root CLAUDE.md — 3×../

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:42 +03:00
Дмитрий 8b9d9fb029 docs(rules): PSR_v1 R13.1 — счётчик R0.6 «8» → «10 пунктов» (после удаления п.11)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:42 +03:00
Дмитрий 9db66e6f27 docs(rules): Task 6 cross-consistency — вычистить остаточные R0→sub-policy cross-refs
Pravila §11.5 + §13.2 содержали живой cross-ref «PSR_v1 v3.0+, R0 → sub-policy под ruflo Queen-led routing» — после реколлажа R0 уже top-of-stack gate, формулировка стала ложной. Task 1 вычистил §13.9/§13.10, но пропустил §11.5/§13.2. + §10 v1.16-row дополнен; PSR_v1 шапка-нарратив +v3.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:42 +03:00
Дмитрий 9b6fa50c4c docs(plan): ruflo hierarchy factual recollage — implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:42 +03:00
Дмитрий d6f0ff868f docs(rules): CLAUDE.md v2.2 — §5 п.10 убран ruflo-routing loophole (Task 4 review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:42 +03:00
Дмитрий 9929b4a599 docs(rules): CLAUDE.md v2.2 — реколлаж ruflo, убран уровень −1
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:42 +03:00
Дмитрий d84127eaa5 docs(rules): Tooling v2.2 — шапка changelog синхронизирована с §13-записью (Task 3 review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:42 +03:00
Дмитрий 2def31eea9 docs(rules): Tooling v2.2 — реколлаж §4.10 ruflo entry-point → advisory-подсистема
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:41 +03:00
Дмитрий e6556e5a97 docs(rules): PSR_v1 v3.2 — §14 cross-ref + R0.6 п.11 + опечатка (Task 2 review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:41 +03:00
Дмитрий 4d807fb9f2 docs(rules): PSR_v1 v3.2 — реколлаж ruflo, R0 sub-policy → top-of-stack gate
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:41 +03:00
Дмитрий 68f341191b docs(rules): Pravila v1.16 — §10 history row + §14.6 cleanup (Task 1 review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:41 +03:00
Дмитрий 91c64cde70 chore(lefthook): cspell --no-gitignore — staged-файлы под gitignored .claude/worktrees/ не проверялись
cspell.json useGitignore:true заставлял cspell игнорировать все файлы worktree, расположенного под gitignored .claude/worktrees/ (Files checked: 0 — фейковый green). Staged-файлы по определению tracked, потому --no-gitignore для pre-commit cspell безопасен и чинит worktree-коммиты.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:59:41 +03:00
Дмитрий b027a3cfee feat(reports): кнопка «Скачать» → signed download URL (F2 frontend) 2026-05-16 12:45:51 +03:00
Дмитрий ab23baa1d5 fix(reports): download/downloadUrl отклоняют expired-job по expires_at (F2 review fixup) 2026-05-16 12:42:00 +03:00
Дмитрий 086fc1a903 feat(reports): download endpoint + signed URL 24ч (F2 backend) 2026-05-16 12:36:08 +03:00
Дмитрий bd9b8e84fa feat(reports): BillingSummaryProvider + isSupported всех 4 типов (F1 закрыт) 2026-05-16 12:28:57 +03:00
Дмитрий 550e8949d6 feat(reports): SourcesSummaryProvider — агрегат сделок по utm_source (F1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:21:51 +03:00
Дмитрий 4bd419654f feat(reports): ManagersSummaryProvider — агрегат сделок по менеджерам (F1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:14:49 +03:00
Дмитрий b163d8a5ca docs(sprint3c): план Reports F1+F2 — 3 провайдера + download endpoint
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:05:58 +03:00
Дмитрий 6e35193f3b fix(deals): router в DealsViewRedesign.spec + idempotency guard + watch-test (C8/F3 review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:41:09 +03:00
Дмитрий 2504f1b9ec feat(deals): deep-link /deals?openId= из напоминаний и колокольчика (audit C8/F3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:41:09 +03:00
Дмитрий ed61bae482 fix(dashboard): скрыть Live-бейдж при ошибке + formatRub guard + test hardening (C1 review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:41:09 +03:00
Дмитрий bf7f70a5d4 fix(dashboard): восстановить tenant-guard в load() + auth.user в тесте (C1 review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:41:09 +03:00
Дмитрий cadaecdaf8 feat(dashboard): DashboardView на real API /api/dashboard/summary (audit C1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:41:09 +03:00
Дмитрий 283db070e1 fix(dashboard): activity-бакеты в MSK + deltaBlock для leads + test hardening (J3 review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:41:09 +03:00
Дмитрий 7705f022c1 fix(dashboard): runway_days опирается на фикс. 7д-окно, не на range (J3 review fixup)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:41:09 +03:00
Дмитрий 18f132d035 docs(rules): Pravila v1.16 — реколлаж ruflo, §12 sub-policy → hard-rule 2026-05-16 11:41:09 +03:00
Дмитрий e64eb4dbe0 feat(dashboard): GET /api/dashboard/summary — агрегат KPI/баланса/активности (audit J3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:41:08 +03:00
Дмитрий c5261a0b22 docs(plan): Sprint 3B dashboard & deep-links implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 11:41:08 +03:00
Дмитрий 425d58f2a9 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 11:41:08 +03:00
Дмитрий 2f267f15f7 feat(graph): iter6 — кнопки «По использованию» / «Дубли» + режим viewMode
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 10:54:11 +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
247 changed files with 42066 additions and 1300 deletions
+20
View File
@@ -7,6 +7,7 @@ description: |
(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
---
@@ -35,6 +36,23 @@ SaaS-level таблицы (e.g., `supplier_csv_reconcile_log`, `system_settings`
Каждое 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` или указанные изменения).
@@ -77,6 +95,8 @@ Pass: <N>/7
- General SQL style (squawk handles).
- Business logic review (other agents).
- Performance review (separate concern).
- Проверка одной названной таблицы вручную перед коммитом + живой дымовой
тест — сценарий скила `/rls-check`, не агента.
## Verification protocol
+18
View File
@@ -37,6 +37,24 @@
]
},
"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",
+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);
});
+25
View File
@@ -5,6 +5,7 @@ description: |
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
---
@@ -16,6 +17,28 @@ disable-model-invocation: true
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:
@@ -94,3 +117,5 @@ 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`, не скила.
+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/
+5
View File
@@ -37,6 +37,11 @@
"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."
}
}
}
+31 -7
View File
File diff suppressed because one or more lines are too long
+26
View File
@@ -56,3 +56,29 @@ If you discover a security vulnerability within Laravel, please send an e-mail t
## License
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
## Демо-данные (dev)
Демо-tenant создаётся `DemoSeeder` автоматически при `composer setup` /
`php artisan migrate --seed` в окружениях `local` и `testing`
(см. `DatabaseSeeder` — в `production` DemoSeeder не запускается).
**Учётные данные демо-входа:**
- URL: `/login`
- Email: `admin@demo.local`
- Пароль: `password`
Что создаётся: demo-tenant (`subdomain=demo`, баланс 1000 ₽ / 100 лидов),
admin-пользователь, 3 проекта (сайт/звонок/СМС) и ~14 демо-сделок.
**Пере-сидировать демо-данные** (идемпотентно — повторный запуск не создаёт дублей):
```bash
composer demo:seed
```
Эквивалент: `php artisan db:seed --class=DemoSeeder --force`.
Если при логине демо-аккаунта возвращается 422 — демо-данные не засеяны
на текущей dev-БД (например, после `migrate:fresh`); запустите `composer demo:seed`.
@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Console\Commands;
use App\Services\MonthlyPartitionManager;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Создаёт ежемесячные партиции для `deals` и `supplier_lead_costs`
@@ -30,14 +30,7 @@ class PartitionsCreateMonths extends Command
/** @var string */
protected $description = 'Создаёт ежемесячные партиции deals и supplier_lead_costs на N месяцев вперёд (idempotent)';
/**
* Список таблиц, которые партиционируются по received_at помесячно.
*
* @var array<int, string>
*/
private const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
public function handle(): int
public function handle(MonthlyPartitionManager $manager): int
{
$ahead = max(1, (int) $this->option('ahead'));
$now = Carbon::now()->startOfMonth();
@@ -47,27 +40,17 @@ class PartitionsCreateMonths extends Command
for ($i = 0; $i <= $ahead; $i++) {
$monthStart = $now->copy()->addMonths($i);
$monthEnd = $monthStart->copy()->addMonth();
foreach (self::PARTITIONED_TABLES as $table) {
foreach (MonthlyPartitionManager::PARTITIONED_TABLES as $table) {
$partitionName = sprintf('%s_%s', $table, $monthStart->format('Y_m'));
if ($this->partitionExists($partitionName)) {
if ($manager->ensureMonth($table, $monthStart)) {
$created++;
$this->info(" create <fg=green>{$partitionName}</>");
} else {
$skipped++;
$this->line(" skip <fg=gray>{$partitionName}</> (already exists)");
continue;
}
DB::statement(sprintf(
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
$partitionName,
$table,
$monthStart->format('Y-m-d'),
$monthEnd->format('Y-m-d'),
));
$created++;
$this->info(" create <fg=green>{$partitionName}</> [{$monthStart->format('Y-m-d')}{$monthEnd->format('Y-m-d')})");
}
}
@@ -76,17 +59,4 @@ class PartitionsCreateMonths extends Command
return self::SUCCESS;
}
/**
* Проверка существования партиции через pg_class (быстрее information_schema).
*/
private function partitionExists(string $name): bool
{
$row = DB::selectOne(
"SELECT 1 AS exists FROM pg_class WHERE relname = ? AND relkind = 'r'",
[$name],
);
return $row !== null;
}
}
@@ -4,7 +4,10 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
use App\Http\Controllers\Controller;
use App\Models\BalanceTransaction;
use App\Models\SaasAdminAuditLog;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -19,6 +22,183 @@ use Illuminate\Support\Facades\DB;
*/
class AdminBillingController extends Controller
{
use ResolvesAdminUserId;
/** GET /api/admin/billing/tariff-plans — список планов для диалога смены тарифа. */
public function tariffPlans(): JsonResponse
{
$plans = DB::table('tariff_plans')
->select(['id', 'name', 'price_monthly'])
->orderBy('price_monthly')
->get()
->map(fn ($p) => [
'id' => (int) $p->id,
'name' => $p->name,
'price_monthly' => (string) $p->price_monthly,
]);
return response()->json(['plans' => $plans]);
}
/** PATCH /api/admin/billing/tenants/{id}/status — приостановить/разблокировать тенанта. */
public function updateStatus(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'status' => ['required', 'in:active,suspended'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
]);
$tenant = $this->findActiveTenant($id);
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
DB::transaction(function () use ($tenant, $validated, $adminUserId, $request): void {
// tenants / saas_admin_audit_log не под RLS — SET LOCAL не нужен (ср. refund()).
DB::table('tenants')->where('id', $tenant->id)->update([
'status' => $validated['status'],
'updated_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => $validated['status'] === 'suspended' ? 'tenant.suspend' : 'tenant.activate',
'target_type' => 'tenant',
'target_id' => $tenant->id,
'target_tenant_id' => $tenant->id,
'payload_before' => ['status' => $tenant->status],
'payload_after' => ['status' => $validated['status']],
'reason' => $validated['reason'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
});
return response()->json(['id' => $tenant->id, 'status' => $validated['status']]);
}
/** POST /api/admin/billing/tenants/{id}/refund — возврат средств: списание с баланса + ledger-запись. */
public function refund(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'amount_rub' => ['required', 'numeric', 'gt:0'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
]);
$this->findActiveTenant($id); // ранний 404; авторитетный баланс перечитывается под локом ниже
$amount = number_format((float) $validated['amount_rub'], 2, '.', '');
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
/** @var array{transaction_id:int, balance_rub:string} $result */
$result = DB::transaction(function () use ($id, $amount, $validated, $adminUserId, $request): array {
DB::statement('SET LOCAL app.current_tenant_id = '.$id);
// Баланс — money-колонка: перечитываем под row-lock внутри транзакции,
// защита от lost-update (конвенция LedgerService — lockForUpdate на tenants).
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')
->lockForUpdate()->first();
if ($tenant === null) {
abort(404, 'tenant not found');
}
$balance = (string) $tenant->balance_rub;
if (bccomp($amount, $balance, 2) === 1) {
abort(422, 'refund amount exceeds tenant balance');
}
$newBalance = bcsub($balance, $amount, 2);
DB::table('tenants')->where('id', $id)->update([
'balance_rub' => $newBalance,
'updated_at' => now(),
]);
$tx = BalanceTransaction::create([
'tenant_id' => $id,
'type' => BalanceTransaction::TYPE_REFUND,
'amount_rub' => '-'.$amount,
'amount_leads' => 0,
'balance_rub_after' => $newBalance,
'description' => $validated['reason'],
'admin_user_id' => $adminUserId,
'created_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => 'tenant.refund',
'target_type' => 'tenant',
'target_id' => $id,
'target_tenant_id' => $id,
'payload_before' => ['balance_rub' => $balance],
'payload_after' => ['balance_rub' => $newBalance, 'amount_rub' => $amount, 'transaction_id' => $tx->id],
'reason' => $validated['reason'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
return ['transaction_id' => (int) $tx->id, 'balance_rub' => $newBalance];
});
return response()->json([
'id' => $id,
'balance_rub' => $result['balance_rub'],
'transaction_id' => $result['transaction_id'],
]);
}
/** PATCH /api/admin/billing/tenants/{id}/tariff — сменить тарифный план тенанта. */
public function changeTariff(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'tariff_id' => ['required', 'integer', 'exists:tariff_plans,id'],
'reason' => ['required', 'string', 'min:10', 'max:1000'],
]);
$tenant = $this->findActiveTenant($id);
$tariff = DB::table('tariff_plans')->where('id', $validated['tariff_id'])->first();
$adminUserId = $this->resolveAdminUserId($request, 'system-billing@liderra.local', 'System Billing Bot');
DB::transaction(function () use ($tenant, $tariff, $validated, $adminUserId, $request): void {
// tenants / saas_admin_audit_log не под RLS — SET LOCAL не нужен (ср. refund()).
DB::table('tenants')->where('id', $tenant->id)->update([
'current_tariff_id' => $tariff->id,
'updated_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => 'tenant.change_tariff',
'target_type' => 'tenant',
'target_id' => $tenant->id,
'target_tenant_id' => $tenant->id,
'payload_before' => ['current_tariff_id' => $tenant->current_tariff_id],
'payload_after' => ['current_tariff_id' => (int) $tariff->id],
'reason' => $validated['reason'],
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
});
return response()->json([
'id' => $tenant->id,
'tariff_id' => (int) $tariff->id,
'tariff_name' => $tariff->name,
]);
}
/**
* Возвращает не-удалённого тенанта либо abort(404).
*
* @return object{id:int,status:string,balance_rub:string,current_tariff_id:int|null}
*/
private function findActiveTenant(int $id): object
{
$tenant = DB::table('tenants')->where('id', $id)->whereNull('deleted_at')->first();
if ($tenant === null) {
abort(404, 'tenant not found');
}
return $tenant;
}
/** GET /api/admin/billing?search= */
public function index(Request $request): JsonResponse
{
@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Concerns\ResolvesAdminUserId;
use App\Http\Controllers\Controller;
use App\Models\SaasAdminAuditLog;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -21,6 +23,8 @@ use Illuminate\Support\Facades\DB;
*/
class AdminIncidentsController extends Controller
{
use ResolvesAdminUserId;
/** GET /api/admin/incidents?type=&severity=&unresolved_only=&limit=&offset= */
public function index(Request $request): JsonResponse
{
@@ -83,6 +87,116 @@ class AdminIncidentsController extends Controller
]);
}
/** POST /api/admin/incidents/{id}/rkn-notify — зафиксировать уведомление РКН (G6, 152-ФЗ). */
public function notifyRkn(Request $request, int $id): JsonResponse
{
$row = DB::table('incidents_log')->where('id', $id)->first();
if ($row === null) {
abort(404, 'incident not found');
}
if ($row->type !== 'data_breach') {
abort(422, 'РКН-уведомление применимо только к инцидентам типа data_breach');
}
if ($row->rkn_notified_at !== null) {
abort(409, 'РКН уже уведомлён по этому инциденту');
}
$adminUserId = $this->resolveAdminUserId($request, 'system-incidents@liderra.local', 'System Incidents Bot');
DB::transaction(function () use ($row, $adminUserId, $request): void {
DB::table('incidents_log')->where('id', $row->id)->update([
'rkn_notified_at' => now(),
'updated_at' => now(),
]);
SaasAdminAuditLog::create([
'admin_user_id' => $adminUserId,
'action' => 'incident.rkn_notify',
'target_type' => 'incident',
'target_id' => $row->id,
'payload_before' => ['rkn_notified_at' => null],
'payload_after' => ['rkn_notified_at' => now()->toIso8601String()],
'reason' => 'Роскомнадзор уведомлён об утечке ПДн через админ-интерфейс (152-ФЗ).',
'ip_address' => $request->ip() ?? '127.0.0.1',
'user_agent' => $request->userAgent(),
]);
});
return $this->show($id);
}
/** GET /api/admin/incidents/{id} — полная карточка инцидента (drill-down G5). */
public function show(int $id): JsonResponse
{
$row = DB::table('incidents_log')->where('id', $id)->first();
if ($row === null) {
abort(404, 'incident not found');
}
$tenantIds = is_array($row->affected_tenant_ids)
? $row->affected_tenant_ids
: ($row->affected_tenant_ids !== null ? $this->parsePgArrayValues((string) $row->affected_tenant_ids) : []);
$tenants = $tenantIds === []
? collect()
: DB::table('tenants')->whereIn('id', $tenantIds)
->select(['id', 'organization_name'])->get();
$admins = DB::table('saas_admin_users')
->whereIn('id', array_filter([$row->created_by_admin_id, $row->closed_by_admin_id]))
->pluck('full_name', 'id');
return response()->json([
'incident' => [
'id' => (int) $row->id,
'incident_id' => $this->formatIncidentId($row),
'type' => $row->type,
'severity' => $row->severity,
'summary' => $row->summary,
'root_cause' => $row->root_cause,
'postmortem_url' => $row->postmortem_url,
'started_at' => CarbonImmutable::parse($row->started_at)->toIso8601String(),
'detected_at' => CarbonImmutable::parse($row->detected_at)->toIso8601String(),
'resolved_at' => $row->resolved_at !== null
? CarbonImmutable::parse($row->resolved_at)->toIso8601String() : null,
'status' => $this->deriveStatus($row),
'affected_tenants' => $tenants->map(fn ($t) => [
'id' => (int) $t->id,
'organization_name' => $t->organization_name,
])->values(),
'affected_users_count' => $row->affected_users_count !== null ? (int) $row->affected_users_count : null,
'notification_sent_at' => $row->notification_sent_at !== null
? CarbonImmutable::parse($row->notification_sent_at)->toIso8601String() : null,
'rkn_notified' => $row->rkn_notified_at !== null,
'rkn_notified_at' => $row->rkn_notified_at !== null
? CarbonImmutable::parse($row->rkn_notified_at)->toIso8601String() : null,
'rkn_deadline_at' => $row->type === 'data_breach' && $row->rkn_notified_at === null
? CarbonImmutable::parse($row->detected_at)->addHours(24)->toIso8601String() : null,
'created_by_admin' => $admins->get($row->created_by_admin_id),
'closed_by_admin' => $row->closed_by_admin_id !== null ? $admins->get($row->closed_by_admin_id) : null,
'created_at' => $row->created_at !== null
? CarbonImmutable::parse($row->created_at)->toIso8601String() : null,
'updated_at' => $row->updated_at !== null
? CarbonImmutable::parse($row->updated_at)->toIso8601String() : null,
],
]);
}
/**
* PG-array literal '{1,2,3}' массив int.
*
* @return list<int>
*/
private function parsePgArrayValues(string $literal): array
{
$trimmed = trim($literal, '{}');
if ($trimmed === '') {
return [];
}
return array_map('intval', explode(',', $trimmed));
}
/** Уникальный человеко-читаемый ID: INC-YYYY-MMDD-NNNN, NNNN = id padded. */
private function formatIncidentId(object $row): string
{
@@ -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));
}
}
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tenant;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Дашборд агрегат для DashboardView (audit C1/J3).
*
* GET /api/dashboard/summary?tenant_id={id}&range=today|7d|30d
*
* На MVP без auth-middleware (tenant_id параметром, как DealController).
* Production: middleware('auth:sanctum','tenant') tenant_id из user.
*
* Все агрегаты tenant-scoped, deleted_at IS NULL, is_test=false.
* RLS-обёртка SET LOCAL app.current_tenant_id (PgBouncer-safe), как DealController.
*/
class DashboardController extends Controller
{
private const RU_WEEKDAYS = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
public function summary(Request $request): JsonResponse
{
$tenantId = (int) $request->query('tenant_id', '0');
if ($tenantId < 1) {
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
}
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$range = in_array($request->query('range'), ['today', '7d', '30d'], true)
? (string) $request->query('range')
: '7d';
// MSK: activity-бакеты и range-границы должны совпадать с SQL
// `AT TIME ZONE 'Europe/Moscow'`. config('app.timezone') = UTC.
$now = CarbonImmutable::now('Europe/Moscow');
[$windowStart, $prevStart] = match ($range) {
'today' => [$now->startOfDay(), $now->startOfDay()->subDay()],
'30d' => [$now->subDays(30), $now->subDays(60)],
default => [$now->subDays(7), $now->subDays(14)],
};
$data = DB::transaction(function () use ($tenantId, $tenant, $now, $range, $windowStart, $prevStart) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$base = fn () => DB::table('deals')
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->where('is_test', false);
// --- leads received: текущее + предыдущее окно ---
$curLeads = (clone $base())->whereBetween('received_at', [$windowStart, $now])->count();
$prevLeads = (clone $base())->whereBetween('received_at', [$prevStart, $windowStart])->count();
// --- conversion: % статуса 'paid' в окне ---
$curPaid = (clone $base())->where('status', 'paid')
->whereBetween('received_at', [$windowStart, $now])->count();
$prevPaid = (clone $base())->where('status', 'paid')
->whereBetween('received_at', [$prevStart, $windowStart])->count();
$curConv = $curLeads > 0 ? round($curPaid / $curLeads * 100, 1) : 0.0;
$prevConv = $prevLeads > 0 ? round($prevPaid / $prevLeads * 100, 1) : 0.0;
// --- active projects ---
$activeProjects = DB::table('projects')
->where('tenant_id', $tenantId)
->whereNull('archived_at')
->where('is_active', true)
->count();
$maxProjects = (int) (($tenant->limits['max_projects'] ?? 0));
// --- activity: 7 daily-бакетов по received_at (MSK) ---
$activityStart = $now->subDays(6)->startOfDay();
$byDay = (clone $base())
->where('received_at', '>=', $activityStart)
->selectRaw("to_char((received_at AT TIME ZONE 'Europe/Moscow')::date, 'YYYY-MM-DD') AS d, COUNT(*) AS c")
->groupBy('d')
->pluck('c', 'd');
$points = [];
$labels = [];
for ($i = 6; $i >= 0; $i--) {
$day = $now->subDays($i);
$key = $day->format('Y-m-d');
$points[] = (int) ($byDay[$key] ?? 0);
$labels[] = $i === 0 ? 'сегодня' : self::RU_WEEKDAYS[(int) $day->format('w')];
}
$maxPoint = max(0, ...$points);
$axisMax = max(10, (int) (ceil($maxPoint / 10) * 10));
// --- funnel: текущий снимок по статусам ---
$funnel = (clone $base())
->selectRaw('status, COUNT(*) AS c')
->groupBy('status')
->pluck('c', 'status')
->map(fn ($c) => (int) $c)
->toArray();
// --- runway ---
// runway опирается на приток за фиксированное 7-дневное окно,
// независимо от выбранного range (для today/30d $curLeads — не 7-дневный).
$leads7d = (clone $base())->whereBetween('received_at', [$now->subDays(7), $now])->count();
$avgDaily = $leads7d / 7.0;
$balanceLeads = (int) ($tenant->balance_leads ?? 0);
$runwayDays = $avgDaily > 0 ? (int) floor($balanceLeads / $avgDaily) : 0;
return [
'range' => $range,
'leads_received' => self::deltaBlock($curLeads, $prevLeads, 'delta_pct', self::pctDelta($curLeads, $prevLeads)),
'conversion' => self::deltaBlock($curConv, $prevConv, 'delta_pp', round($curConv - $prevConv, 1)),
'active_projects' => ['active' => $activeProjects, 'limit' => $maxProjects],
'balance' => [
'amount_rub' => (string) $tenant->balance_rub,
'runway_days' => $runwayDays,
'runway_leads' => $balanceLeads,
],
'activity' => ['points' => $points, 'labels' => $labels, 'max' => $axisMax],
'funnel' => (object) $funnel,
];
});
return response()->json($data);
}
/** Процентная дельта current vs previous; 0.0 если previous=0. */
private static function pctDelta(float $cur, float $prev): float
{
return $prev > 0 ? round(($cur - $prev) / $prev * 100, 1) : 0.0;
}
/** Блок {value, <deltaKey>, delta_dir}. */
private static function deltaBlock(float $value, float $prev, string $deltaKey, float $delta): array
{
$dir = $value > $prev ? 'up' : ($value < $prev ? 'down' : 'neutral');
return ['value' => $value, $deltaKey => $delta, 'delta_dir' => $dir];
}
}
@@ -7,7 +7,6 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -20,6 +19,8 @@ use Illuminate\Support\Facades\DB;
* bulk + export + helpers). Этот класс отвечает только за многоразовые
* массовые операции; single-resource действия остаются в DealController.
*
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
*
* O-perf-01: N+1 устранён.
*
* transition: сначала SELECT всех сделок tenant'а из ids, чтобы отфильтровать
@@ -41,23 +42,19 @@ class DealBulkActionController extends Controller
/**
* POST /api/deals/transition bulk status-update.
*
* Body: {tenant_id, ids: [int...], status: slug}.
* Body: {ids: [int...], status: slug}.
* Response: {updated, requested, status} (updated = реально изменённых,
* без NO-OP).
*/
public function transition(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'ids' => 'required|array|min:1|max:1000',
'ids.*' => 'integer|min:1',
'status' => 'required|string|max:50',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
$statusExists = DB::table('lead_statuses')->where('slug', $validated['status'])->exists();
if (! $statusExists) {
@@ -67,14 +64,14 @@ class DealBulkActionController extends Controller
], 422);
}
$updated = DB::transaction(function () use ($validated, $tenant) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$updated = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Фаза 1: SELECT — нужны id и предыдущий status для каждой строки,
// чтобы (а) отфильтровать NO-OP и (б) сохранить prev в context.from.
// Defense-in-depth where(tenant_id) — защита от кросс-tenant id.
$rows = Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->get(['id', 'status']);
@@ -88,7 +85,7 @@ class DealBulkActionController extends Controller
// Фаза 2: bulk-UPDATE 1 запросом вместо N.
Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $changedIds)
->update([
'status' => $validated['status'],
@@ -100,7 +97,7 @@ class DealBulkActionController extends Controller
// массив сериализуем в JSON руками, остальные scalar-поля передаём
// напрямую. Триггер audit_chain_hash() заполнит log_hash на уровне БД.
$logRows = $changed->map(fn (Deal $d) => [
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $d->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
@@ -127,7 +124,7 @@ class DealBulkActionController extends Controller
/**
* DELETE /api/deals bulk soft-delete.
*
* Body: {tenant_id, ids: [int...]}.
* Body: {ids: [int...]}.
* Response: {deleted, requested}.
*
* Soft-delete сохраняется (см. документацию в DealController.destroy на
@@ -137,23 +134,19 @@ class DealBulkActionController extends Controller
public function destroy(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'ids' => 'required|array|min:1|max:1000',
'ids.*' => 'integer|min:1',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
$deleted = DB::transaction(function () use ($validated, $tenant) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$deleted = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// SELECT id'шников живых сделок tenant'а из ids — для bulk-INSERT
// в activity_log по списку реально удаляемых (NO-OP idempotency).
$targetIds = Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->whereNull('deleted_at')
->pluck('id')
@@ -166,7 +159,7 @@ class DealBulkActionController extends Controller
$now = now();
Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $targetIds)
->whereNull('deleted_at')
->update([
@@ -175,7 +168,7 @@ class DealBulkActionController extends Controller
]);
$logRows = array_map(fn (int $id) => [
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $id,
'event' => ActivityLog::EVENT_DEAL_DELETED,
@@ -197,30 +190,26 @@ class DealBulkActionController extends Controller
/**
* POST /api/deals/restore bulk restore soft-deleted.
*
* Body: {tenant_id, ids: [int...]}.
* Body: {ids: [int...]}.
* Response: {restored, requested}.
*/
public function restore(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'ids' => 'required|array|min:1|max:1000',
'ids.*' => 'integer|min:1',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
$restored = DB::transaction(function () use ($validated, $tenant) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$restored = DB::transaction(function () use ($validated, $tenantId) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// withTrashed обходит SoftDeletes global scope; whereNotNull —
// NO-OP idempotency для уже живых.
$targetIds = Deal::query()
->withTrashed()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->whereNotNull('deleted_at')
->pluck('id')
@@ -234,7 +223,7 @@ class DealBulkActionController extends Controller
Deal::query()
->withTrashed()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $targetIds)
->whereNotNull('deleted_at')
->update([
@@ -243,7 +232,7 @@ class DealBulkActionController extends Controller
]);
$logRows = array_map(fn (int $id) => [
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $id,
'event' => ActivityLog::EVENT_DEAL_RESTORED,
+33 -51
View File
@@ -9,7 +9,6 @@ use App\Models\ActivityLog;
use App\Models\Deal;
use App\Models\Project;
use App\Models\SupplierLeadCost;
use App\Models\Tenant;
use App\Models\User;
use App\Services\SupplierResolver;
use Illuminate\Http\JsonResponse;
@@ -27,9 +26,7 @@ use Illuminate\Support\Facades\DB;
* `WebhookReceiveController` + `ProcessWebhookJob` (асинхронно через очередь
* с advisory lock + dedup). Этот controller для ручных action'ов из UI.
*
* На MVP без auth-middleware (multi-tenant контекст резолвится по
* `tenant_id` параметру). Production: middleware('auth:sanctum')+'tenant'
* tenant_id из request()->user()->tenant_id; user ID для manager/audit.
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
*
* Manual-create отличается от webhook'а:
* - source_crm_id = NULL (не из webhook).
@@ -42,7 +39,7 @@ use Illuminate\Support\Facades\DB;
class DealController extends Controller
{
/**
* GET /api/deals?tenant_id={id}&status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
* GET /api/deals?status_in[]=...&project_id=...&manager_id=...&search=...&limit=...&offset=...
*
* Список сделок tenant'а с relations (project.name, manager.first/last/email).
* Используется в `DealsView`/`KanbanView` вместо MOCK_DEALS.
@@ -53,20 +50,10 @@ class DealController extends Controller
* (received_at, id)).
*
* RLS: SET LOCAL app.current_tenant_id внутри транзакции (PgBouncer-safe).
* Чужие сделки отфильтрует политика, даже если клиент подсунет чужой
* tenant_id (без auth на MVP, на prod middleware).
*/
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->query('tenant_id', '0');
if ($tenantId < 1) {
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
}
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
$statuses = (array) $request->query('status_in', []);
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
@@ -75,6 +62,7 @@ class DealController extends Controller
$limit = max(1, min(500, (int) $request->query('limit', '100')));
$offset = max(0, (int) $request->query('offset', '0'));
$onlyDeleted = $request->boolean('only_deleted');
$countOnly = $request->boolean('count_only');
$cursorRaw = (string) $request->query('cursor', '');
// Sprint 4 Phase A (audit O-perf-04): keyset pagination через cursor.
@@ -93,7 +81,7 @@ class DealController extends Controller
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
}
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor) {
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
@@ -128,6 +116,12 @@ class DealController extends Controller
});
}
// Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
// и cursor/offset-логику (лёгкий запрос для бейджа в сайдбаре).
if ($countOnly) {
return [collect(), $query->count(), null];
}
if ($cursor !== null) {
// Keyset: PG row constructor через индекс на (received_at DESC, id DESC).
// Не считаем total (дорого без COUNT(*); клиент при необходимости
@@ -172,6 +166,10 @@ class DealController extends Controller
return [$rows, $total, $next];
});
if ($countOnly) {
return response()->json(['total' => $total]);
}
$payload = [
'deals' => $deals->map(fn (Deal $d) => [
'id' => $d->id,
@@ -203,7 +201,7 @@ class DealController extends Controller
}
/**
* GET /api/deals/{id}?tenant_id={id} детали сделки + recent activity events.
* GET /api/deals/{id} детали сделки + recent activity events.
*
* Используется в DealDetailDrawer (правая панель). Возвращает deal с
* relations + до 50 последних activity_log событий по этой сделке.
@@ -213,15 +211,7 @@ class DealController extends Controller
*/
public function show(Request $request, int $id): JsonResponse
{
$tenantId = (int) $request->query('tenant_id', '0');
if ($tenantId < 1) {
return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422);
}
$tenant = Tenant::find($tenantId);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
[$deal, $events] = DB::transaction(function () use ($tenantId, $id) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
@@ -291,7 +281,7 @@ class DealController extends Controller
/**
* PATCH /api/deals/{id} частичное редактирование сделки из DealDetailDrawer.
*
* Body (все поля optional, должно быть хотя бы одно): {tenant_id, comment?,
* Body (все поля optional, должно быть хотя бы одно): {comment?,
* manager_id?, status?}.
*
* Каждое изменение пишется в ActivityLog с правильным event-type:
@@ -309,16 +299,12 @@ class DealController extends Controller
public function update(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'comment' => 'nullable|string|max:5000',
'manager_id' => 'nullable|integer|min:1',
'status' => 'nullable|string|max:50',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
// Validate status slug если передан.
if (array_key_exists('status', $validated) && $validated['status'] !== null) {
@@ -335,7 +321,7 @@ class DealController extends Controller
if (array_key_exists('manager_id', $validated) && $validated['manager_id'] !== null) {
$managerExists = User::query()
->where('id', $validated['manager_id'])
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->where('is_active', true)
->exists();
@@ -347,11 +333,11 @@ class DealController extends Controller
}
}
$deal = DB::transaction(function () use ($validated, $tenant, $id) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$deal = DB::transaction(function () use ($validated, $tenantId, $id) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$deal = Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->where('id', $id)
->first();
@@ -363,7 +349,7 @@ class DealController extends Controller
if (array_key_exists('comment', $validated) && $deal->comment !== $validated['comment']) {
$deal->comment = $validated['comment'];
ActivityLog::create([
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $deal->id,
'event' => 'deal.commented',
@@ -376,7 +362,7 @@ class DealController extends Controller
$deal->manager_id = $validated['manager_id'];
$deal->assigned_at = $validated['manager_id'] !== null ? now() : null;
ActivityLog::create([
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_ASSIGNED,
@@ -388,7 +374,7 @@ class DealController extends Controller
$previousStatus = $deal->status;
$deal->status = $validated['status'];
ActivityLog::create([
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
@@ -425,7 +411,6 @@ class DealController extends Controller
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'project_name' => 'required|string|max:255',
'phone' => 'required|string|max:20',
'contact_name' => 'nullable|string|max:255',
@@ -434,17 +419,14 @@ class DealController extends Controller
'comment' => 'nullable|string|max:5000',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
return response()->json(['message' => 'Тенант не найден.'], 404);
}
$tenantId = (int) $request->user()->tenant_id;
// Manager FK guard: если manager_id передан, он должен принадлежать
// этому tenant'у. Иначе можно назначить чужого менеджера на свою сделку.
if (isset($validated['manager_id'])) {
$managerExists = User::query()
->where('id', $validated['manager_id'])
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->where('is_active', true)
->exists();
@@ -459,16 +441,16 @@ class DealController extends Controller
$statusSlug = $validated['status'] ?? 'new';
// Транзакция + RLS: SET LOCAL внутри (PgBouncer-safe).
$deal = DB::transaction(function () use ($validated, $tenant, $statusSlug) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
$deal = DB::transaction(function () use ($validated, $tenantId, $statusSlug) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$project = Project::firstOrCreate(
['tenant_id' => $tenant->id, 'name' => $validated['project_name']],
['tenant_id' => $tenantId, 'name' => $validated['project_name']],
['type' => 'manual'],
);
$deal = Deal::create([
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'source_crm_id' => null, // manual
'project_id' => $project->id,
'phone' => $validated['phone'],
@@ -499,7 +481,7 @@ class DealController extends Controller
}
ActivityLog::create([
'tenant_id' => $tenant->id,
'tenant_id' => $tenantId,
'user_id' => null, // на prod — request()->user()->id
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_CREATED,
@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use OpenSpout\Common\Entity\Row;
@@ -21,13 +20,15 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
*
* Извлечено из DealController (Sprint 3 Phase A, audit O-refactor-01).
*
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
*
* O-perf-05: streaming устраняет memory pressure. PhpSpreadsheet строил
* полный объект .xlsx в памяти (для 10K сделок 100+ MB). OpenSpout пишет
* в php://output постранично через Writer + Row::fromValues и chunkById(500)
* по сделкам пик памяти O(1) от размера экспорта.
*
* API контракт сохранён:
* POST /api/deals/export {tenant_id, ids[], format?: csv|xlsx}
* POST /api/deals/export {ids[], format?: csv|xlsx}
* Headers Content-Type / Content-Disposition без изменений.
* CSV: UTF-8 + BOM + ;-разделитель (Excel-friendly RU-локаль).
* XLSX: bold-header + auto-size columns.
@@ -43,16 +44,12 @@ class DealExportController extends Controller
public function export(Request $request): StreamedResponse
{
$validated = $request->validate([
'tenant_id' => 'required|integer|min:1',
'ids' => 'required|array|min:1|max:10000',
'ids.*' => 'integer|min:1',
'format' => 'nullable|string|in:csv,xlsx',
]);
$tenant = Tenant::find($validated['tenant_id']);
if ($tenant === null) {
abort(404, 'Тенант не найден.');
}
$tenantId = (int) $request->user()->tenant_id;
$format = $validated['format'] ?? 'csv';
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
@@ -67,13 +64,13 @@ class DealExportController extends Controller
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
];
return new StreamedResponse(function () use ($validated, $tenant, $format) {
return new StreamedResponse(function () use ($validated, $tenantId, $format) {
// RLS-контекст должен быть установлен внутри транзакции на момент
// фактического SELECT. StreamedResponse callback вызывается уже
// после Laravel-response pipeline'а, поэтому открываем транзакцию
// прямо здесь.
DB::transaction(function () use ($validated, $tenant, $format) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenant->id);
DB::transaction(function () use ($validated, $tenantId, $format) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$writer = $this->openWriter($format);
$writer->openToFile('php://output');
@@ -93,7 +90,7 @@ class DealExportController extends Controller
// chunkById(500) — keyset-friendly; в нашем DealsView это
// редкий тяжёлый action, экспортировать могут до 10K id.
Deal::query()
->where('tenant_id', $tenant->id)
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->orderBy('id')
->chunkById(500, function ($deals) use ($writer) {
@@ -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 */
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\ResolveUnknownStatusesRequest;
use App\Http\Requests\StoreImportRequest;
use App\Jobs\ImportLeadsJob;
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* CSV-импорт исторических лидов из crm.bp-gr.ru (ТЗ §6).
*
* Все маршруты под auth:sanctum + tenant (RLS-контекст задан middleware).
* tenant_id берётся из авторизованного пользователя, не из запроса.
*/
class ImportController extends Controller
{
/**
* POST /api/imports загрузка CSV, создание import_log, dispatch job'а.
*/
public function store(StoreImportRequest $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$file = $request->file('file');
$storedName = Str::uuid()->toString().'.csv';
$path = $file->storeAs("imports/{$tenantId}", $storedName, 'local');
$log = ImportLog::create([
'tenant_id' => $tenantId,
'user_id' => $request->user()->id,
'filename' => $file->getClientOriginalName(),
'file_path' => $path,
'status' => 'pending',
'entity_type' => 'leads',
'source_system' => 'crm.bp-gr.ru',
'dry_run' => $request->boolean('dry_run'),
]);
ImportLeadsJob::dispatch($log->id, $tenantId);
return response()->json(['data' => $this->toResource($log)], 201);
}
/**
* GET /api/imports история импортов тенанта (RLS отфильтрует по tenant).
*
* Defense-in-depth: явный where(tenant_id) поверх RLS на dev через
* `postgres` superuser RLS обходится BYPASSRLS, app-фильтр гарантирует
* изоляцию (паттерн из DealController).
*/
public function index(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$logs = ImportLog::query()
->where('tenant_id', $tenantId)
->orderByDesc('id')
->limit(50)
->get()
->map(fn (ImportLog $log) => $this->toResource($log));
return response()->json(['data' => $logs]);
}
/**
* GET /api/imports/{importLog} прогресс одного импорта (для polling'а).
*
* Defense-in-depth: явная проверка tenant_id на принадлежность поверх RLS.
*/
public function show(Request $request, ImportLog $importLog): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
abort_if($importLog->tenant_id !== $tenantId, 403, 'Доступ к импорту другого тенанта запрещён.');
return response()->json(['data' => $this->toResource($importLog)]);
}
/**
* GET /api/imports/unknown-statuses незамапленные статусы (вход wizard'а §6.6).
*
* Defense-in-depth: явный where(tenant_id) поверх RLS.
*/
public function unknownStatuses(Request $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$rows = ImportUnknownStatus::query()
->where('tenant_id', $tenantId)
->unresolved()
->orderByDesc('occurrences')
->get()
->map(fn (ImportUnknownStatus $s) => [
'id' => $s->id,
'status_ru' => $s->status_ru,
'occurrences' => $s->occurrences,
]);
return response()->json(['data' => $rows]);
}
/**
* POST /api/imports/unknown-statuses/resolve ручной маппинг статусов.
*
* Defense-in-depth: явный where(tenant_id) поверх RLS.
*/
public function resolveUnknownStatuses(ResolveUnknownStatusesRequest $request): JsonResponse
{
$tenantId = (int) $request->user()->tenant_id;
$userId = (int) $request->user()->id;
DB::transaction(function () use ($request, $tenantId, $userId): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
foreach ($request->validated()['mappings'] as $mapping) {
ImportUnknownStatus::query()
->where('tenant_id', $tenantId)
->where('status_ru', $mapping['status_ru'])
->update([
'mapped_to_slug' => $mapping['slug'],
'resolved_at' => now(),
'resolved_by' => $userId,
]);
}
});
return response()->json(['data' => ['resolved' => count($request->validated()['mappings'])]]);
}
/**
* @return array<string, mixed>
*/
private function toResource(ImportLog $log): array
{
return [
'id' => $log->id,
'filename' => $log->filename,
'status' => $log->status,
'rows_total' => $log->rows_total,
'rows_added' => $log->rows_added,
'rows_updated' => $log->rows_updated,
'rows_skipped' => $log->rows_skipped,
'unknown_statuses_count' => $log->unknown_statuses_count,
'dry_run' => $log->dry_run,
'error_message' => $log->error_message,
'started_at' => $log->started_at?->toIso8601String(),
'finished_at' => $log->finished_at?->toIso8601String(),
];
}
}
@@ -13,7 +13,9 @@ use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Validation\Rule;
use Symfony\Component\HttpFoundation\Response;
/**
* Reports API (schema §13.5). Все endpoint'ы под `auth:sanctum`.
@@ -340,6 +342,68 @@ class ReportJobController extends Controller
});
}
/**
* GET /api/reports/jobs/{id}/file?tenant=&expires=&signature= скачать
* готовый файл отчёта (F2, OPEN-И-20).
*
* Под `signed`-middleware (не auth:sanctum): подпись URL = capability-token.
* `tenant` в подписи нужен для RLS-контекста (нет авторизованного user'а).
* Подпись покрывает все query-параметры `tenant`/`id` подделать нельзя.
*/
public function download(Request $request, int $id): Response
{
$tenantId = (int) $request->query('tenant', '0');
return DB::transaction(function () use ($id, $tenantId): Response {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$job = ReportJob::query()
->where('id', $id)
->where('tenant_id', $tenantId)
->first();
if ($job === null) {
return response()->json(['message' => 'Отчёт не найден.'], 404);
}
if ($job->status !== ReportJob::STATUS_DONE
|| $job->file_path === null
|| ($job->expires_at !== null && $job->expires_at->isPast())) {
return response()->json(['message' => 'Файл отчёта недоступен или истёк.'], 410);
}
if (! Storage::disk('local')->exists($job->file_path)) {
return response()->json(['message' => 'Файл отчёта не найден в хранилище.'], 404);
}
$extension = pathinfo($job->file_path, PATHINFO_EXTENSION);
return Storage::disk('local')->download(
$job->file_path,
sprintf('report-%d.%s', $job->id, $extension)
);
});
}
/**
* Signed URL (24 ч) на скачивание файла. NULL для не-готовых job'ов или
* после истечения retention (file_path обнулён cron'ом reports:cleanup-expired).
*/
private function downloadUrl(ReportJob $job): ?string
{
if ($job->status !== ReportJob::STATUS_DONE
|| $job->file_path === null
|| ($job->expires_at !== null && $job->expires_at->isPast())) {
return null;
}
return URL::temporarySignedRoute(
'reports.download',
Carbon::now()->addHours(24),
['id' => $job->id, 'tenant' => $job->tenant_id],
);
}
/** @return array<string, mixed> */
private function toResource(ReportJob $job): array
{
@@ -358,6 +422,7 @@ class ReportJobController extends Controller
'is_expired' => $job->expires_at !== null && $job->expires_at->isPast(),
'retry_count' => (int) ($job->parameters['retry_count'] ?? 0),
'retry_max' => self::RETRY_MAX_ATTEMPTS,
'download_url' => $this->downloadUrl($job),
];
}
}
@@ -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();
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Concerns;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Резолв saas_admin_users.id для audit-trail на MVP (saas-admin SSO Б-1).
*
* Берёт admin_user_id из request-параметра; при отсутствии валидного
* создаёт/переиспользует системный стаб-аккаунт (не loginable, is_active=false),
* чтобы соблюсти NOT NULL + FK на saas_admin_users в saas_admin_audit_log.
*
* Паттерн ранее дублировался в AdminPricingTiersController /
* AdminSystemSettingsController; новый код использует этот трейт.
*/
trait ResolvesAdminUserId
{
protected function resolveAdminUserId(Request $request, string $stubEmail, string $stubName): int
{
$requested = $request->input('admin_user_id');
if (is_int($requested) || (is_string($requested) && ctype_digit($requested))) {
$existing = DB::table('saas_admin_users')->where('id', (int) $requested)->value('id');
if ($existing !== null) {
return (int) $existing;
}
}
$existingId = DB::table('saas_admin_users')->where('email', $stubEmail)->value('id');
if ($existingId !== null) {
return (int) $existingId;
}
return (int) DB::table('saas_admin_users')->insertGetId([
'email' => $stubEmail,
'full_name' => $stubName,
'password_hash' => '$2y$04$system-stub-not-loginable',
'role' => 'super_admin',
'is_active' => false,
'sso_provider' => 'local',
'is_break_glass' => false,
]);
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Гейт SaaS-admin зоны (/api/admin/*) audit-находка J2.
*
* СТАБ (Sprint 3F): полноценная авторизация saas-admin требует Yandex 360
* SSO-входа, который гейтится Б-1 (регистрация ООО) + DO-4. До их закрытия
* реального механизма аутентификации нет.
*
* Поведение стаба:
* - dev / testing (local, testing) пропускаем. Admin-панель работает на
* dev; admin_user_id передаётся параметром (трейт ResolvesAdminUserId).
* - прочие окружения (production / staging) fail-closed 503: зона
* закрыта до подключения реального SSO. Явный 503 лучше, чем тихо
* открытый /api/admin/* в проде.
*
* TODO (после Б-1 + DO-4): заменить на проверку Yandex 360 SSO-сессии
* saas-admin (отдельный guard) + роль (compliance и т.п. где требуется).
*/
class EnsureSaasAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (! app()->environment('local', 'testing')) {
abort(503, 'SaaS-admin авторизация не настроена (ожидает Б-1 + DO-4).');
}
return $next($request);
}
}
+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;
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* Валидация ручного маппинга неизвестных статусов воронки (§6.4 wizard).
*/
class ResolveUnknownStatusesRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'mappings' => ['required', 'array', 'min:1'],
'mappings.*.status_ru' => ['required', 'string', 'max:100'],
'mappings.*.slug' => ['required', 'string', Rule::exists('lead_statuses', 'slug')],
];
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* Валидация загрузки CSV-файла импорта (ТЗ §6.2).
*/
class StoreImportRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
// mimes csv,txt — экспорт crm.bp-gr.ru отдаётся как text/csv или text/plain.
'file' => ['required', 'file', 'mimes:csv,txt', 'max:10240'],
'dry_run' => ['sometimes', 'boolean'],
];
}
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Mail\ImportCompletedNotification;
use App\Models\ImportLog;
use App\Models\User;
use App\Services\Import\CsvLeadsParser;
use App\Services\Import\HistoricalImportService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;
/**
* Асинхронная обработка CSV-импорта исторических лидов (ТЗ §6.6).
*
* Жизненный цикл import_log: pending processing done | failed.
* RLS: каждый доступ к БД задаёт SET LOCAL app.current_tenant_id (воркер
* вне middleware-контекста паритет с ProcessWebhookJob).
*/
class ImportLeadsJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $tries = 1;
public int $timeout = 600;
public function __construct(
public int $importLogId,
public int $tenantId,
) {}
public function handle(HistoricalImportService $service, CsvLeadsParser $parser): void
{
$log = $this->loadLog();
if ($log === null) {
Log::error('import.log_not_found', ['import_log_id' => $this->importLogId]);
return;
}
$this->updateLog($log->id, ['status' => 'processing', 'started_at' => now()]);
try {
if (! Storage::disk('local')->exists($log->file_path)) {
throw new RuntimeException("Файл импорта не найден: {$log->file_path}");
}
$content = (string) Storage::disk('local')->get($log->file_path);
$parsed = $parser->parse($content);
$result = $service->import($this->tenantId, $log->user_id, $log, $parsed->rows);
$this->updateLog($log->id, [
'status' => 'done',
'rows_total' => count($parsed->rows) + count($parsed->errors),
'rows_added' => $result->added,
'rows_updated' => $result->updated,
'rows_skipped' => count($parsed->errors) + $result->skipped,
'unknown_statuses_count' => count($result->unknownStatuses),
'finished_at' => now(),
]);
$this->notify($log->user_id, 'done');
} catch (Throwable $e) {
Log::error('import.job_failed', ['import_log_id' => $log->id, 'error' => $e->getMessage()]);
$this->updateLog($log->id, [
'status' => 'failed',
'error_message' => $e->getMessage(),
'finished_at' => now(),
]);
$this->notify($log->user_id, 'failed');
}
}
private function loadLog(): ?ImportLog
{
return DB::transaction(function (): ?ImportLog {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
return ImportLog::query()->find($this->importLogId);
});
}
/**
* @param array<string, mixed> $attributes
*/
private function updateLog(int $logId, array $attributes): void
{
DB::transaction(function () use ($logId, $attributes): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
ImportLog::query()->whereKey($logId)->update($attributes);
});
}
private function notify(int $userId, string $outcome): void
{
$log = $this->loadLog();
$user = DB::transaction(function () use ($userId): ?User {
DB::statement('SET LOCAL app.current_tenant_id = '.$this->tenantId);
return User::query()->find($userId);
});
if ($log === null || $user === null || $user->email === '') {
return;
}
try {
Mail::to($user->email)->send(new ImportCompletedNotification($log, $outcome));
} catch (Throwable $e) {
// Отказ почтового канала не должен валить успешный импорт.
Log::warning('import.mail_failed', ['import_log_id' => $log->id, 'error' => $e->getMessage()]);
}
}
/**
* Финальный callback после исчерпания ретраев ($tries=1).
*/
public function failed(Throwable $e): void
{
$this->updateLog($this->importLogId, [
'status' => 'failed',
'error_message' => $e->getMessage(),
'finished_at' => now(),
]);
Log::error('import.job_failed_permanently', [
'import_log_id' => $this->importLogId,
'exception' => $e->getMessage(),
]);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\ImportLog;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Уведомление о завершении CSV-импорта исторических лидов (ТЗ §6.6).
*/
class ImportCompletedNotification extends Mailable
{
use Queueable;
use SerializesModels;
/**
* @param string $outcome 'done' | 'failed'
*/
public function __construct(
public ImportLog $log,
public string $outcome,
) {}
public function envelope(): Envelope
{
$subject = $this->outcome === 'done'
? 'Импорт данных завершён — Лидерра'
: 'Импорт данных не удался — Лидерра';
return new Envelope(subject: $subject);
}
public function content(): Content
{
return new Content(
markdown: 'mail.import-completed',
with: [
'log' => $this->log,
'outcome' => $this->outcome,
],
);
}
}
+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';
+92
View File
@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\ImportLogFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Журнал CSV-импорта (schema §6.7, Sprint 4).
*
* Tenant-aware модель с RLS: tenant_isolation по current_setting('app.current_tenant_id').
* Sprint 4 enrichment: entity_type / source_system / mapping_config / unknown_statuses_count / dry_run.
*
* @mixin IdeHelperImportLog
*/
class ImportLog extends Model
{
/** @use HasFactory<ImportLogFactory> */
use HasFactory;
public const UPDATED_AT = null;
public const CREATED_AT = null;
protected $table = 'import_log';
/** Зеркало DB DEFAULT'ов: Laravel не читает их из БД после INSERT без refresh(). */
protected $attributes = [
'status' => 'pending',
'entity_type' => 'leads',
'source_system' => 'crm.bp-gr.ru',
'dry_run' => false,
'unknown_statuses_count' => 0,
'rows_total' => 0,
'rows_added' => 0,
'rows_updated' => 0,
'rows_skipped' => 0,
];
protected $fillable = [
'tenant_id',
'user_id',
'filename',
'file_path',
'rows_total',
'rows_added',
'rows_updated',
'rows_skipped',
'status',
'error_message',
'started_at',
'finished_at',
'entity_type',
'source_system',
'mapping_config',
'unknown_statuses_count',
'dry_run',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'user_id' => 'integer',
'rows_total' => 'integer',
'rows_added' => 'integer',
'rows_updated' => 'integer',
'rows_skipped' => 'integer',
'unknown_statuses_count' => 'integer',
'dry_run' => 'boolean',
'mapping_config' => 'array',
'started_at' => 'datetime',
'finished_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);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Database\Factories\ImportUnknownStatusFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Неизвестный статус воронки из CSV-импорта (schema §6.4, Sprint 4 H1).
*
* Tenant-aware модель с RLS. UNIQUE (tenant_id, status_ru): повторный импорт
* инкрементит occurrences и переиспользует ранее проставленный mapped_to_slug.
*
* @mixin IdeHelperImportUnknownStatus
*/
class ImportUnknownStatus extends Model
{
/** @use HasFactory<ImportUnknownStatusFactory> */
use HasFactory;
protected $fillable = [
'tenant_id',
'import_log_id',
'status_ru',
'occurrences',
'mapped_to_slug',
'resolved_at',
'resolved_by',
];
protected function casts(): array
{
return [
'tenant_id' => 'integer',
'import_log_id' => 'integer',
'occurrences' => 'integer',
'resolved_by' => 'integer',
'resolved_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
}
/**
* Незамапленные статусы (mapped_to_slug IS NULL) вход для wizard'а §6.6.
*
* @param Builder<ImportUnknownStatus> $query
* @return Builder<ImportUnknownStatus>
*/
public function scopeUnresolved(Builder $query): Builder
{
return $query->whereNull('mapped_to_slug');
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
@@ -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(),
]);
}
}
+130
View File
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
use Carbon\CarbonImmutable;
use Throwable;
/**
* Парсер CSV-выгрузки лидов из crm.bp-gr.ru (ТЗ §6.2/§6.3).
*
* Формат: UTF-8 с BOM, разделитель запятая, дата `Y/m/d H:i:s`,
* телефон `7XXXXXXXXXX`. Заголовок:
* id,Проект,Тег проекта,Телефон,Создано,Напоминание,Комментарий,Состояние,Имя
*
* Невалидные строки не роняют парсинг собираются в errors[].
* Файл целиком загружается в память (MVP: ожидаемый объём единицы тысяч строк).
*/
final class CsvLeadsParser
{
private const EXPECTED_COLUMNS = 9;
private const DATE_FORMAT = 'Y/m/d H:i:s';
public function parse(string $content): CsvParseResult
{
// Срезаем UTF-8 BOM.
if (str_starts_with($content, "\xEF\xBB\xBF")) {
$content = substr($content, 3);
}
$lines = preg_split('/\r\n|\r|\n/', trim($content)) ?: [];
$rows = [];
$errors = [];
// Строка 1 — заголовок, пропускаем. dataLine — абсолютный номер строки файла (заголовок = 1).
foreach (array_slice($lines, 1) as $index => $rawLine) {
$dataLine = $index + 2; // +2: пропущен заголовок (index 0 → строка 2)
if (trim($rawLine) === '') {
continue;
}
$cells = str_getcsv($rawLine);
if (count($cells) < self::EXPECTED_COLUMNS) {
$errors[] = ['line' => $dataLine, 'message' => 'Ожидалось '.self::EXPECTED_COLUMNS.' колонок, получено '.count($cells)];
continue;
}
$parsed = $this->parseRow($cells, $dataLine, $errors);
if ($parsed !== null) {
$rows[] = $parsed;
}
}
return new CsvParseResult($rows, $errors);
}
/**
* @param array<int, string> $cells
* @param array<int, array{line: int, message: string}> $errors
*/
private function parseRow(array $cells, int $dataLine, array &$errors): ?ParsedLeadRow
{
[$id, $project, $tag, $phone, $createdAt, $reminder, $comment, $status, $name] = $cells;
$phone = trim($phone);
if (preg_match('/^7\d{10}$/', $phone) !== 1) {
$errors[] = ['line' => $dataLine, 'message' => "Невалидный телефон: '{$phone}'"];
return null;
}
$receivedAt = $this->parseDate($createdAt);
if ($receivedAt === null) {
$errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Создано': '{$createdAt}'"];
return null;
}
$reminderAt = trim($reminder) === '' ? null : $this->parseDate($reminder);
if (trim($reminder) !== '' && $reminderAt === null) {
$errors[] = ['line' => $dataLine, 'message' => "Невалидная дата 'Напоминание': '{$reminder}'"];
return null;
}
$status = trim($status);
if ($status === '') {
$errors[] = ['line' => $dataLine, 'message' => 'Пустое поле «Состояние»'];
return null;
}
// Префикс B[123]_ из названия проекта срезается (паритет с ProcessWebhookJob).
$projectName = (string) preg_replace('/^B[123]_/', '', trim($project));
if ($projectName === '') {
$errors[] = ['line' => $dataLine, 'message' => 'Пустое название проекта'];
return null;
}
return new ParsedLeadRow(
sourceCrmId: (int) trim($id),
projectName: $projectName,
projectTag: trim($tag) === '' ? null : trim($tag),
phone: $phone,
receivedAt: $receivedAt,
reminderAt: $reminderAt,
comment: trim($comment) === '' ? null : trim($comment),
statusRu: $status,
contactName: trim($name) === '' ? null : trim($name),
);
}
private function parseDate(string $value): ?CarbonImmutable
{
try {
$date = CarbonImmutable::createFromFormat(self::DATE_FORMAT, trim($value));
} catch (Throwable) {
return null;
}
// createFromFormat возвращает false при несовпадении формата.
return $date instanceof CarbonImmutable ? $date : null;
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
/**
* Результат парсинга CSV: валидные строки + ошибки по номеру строки.
*/
final readonly class CsvParseResult
{
/**
* @param array<int, ParsedLeadRow> $rows
* @param array<int, array{line: int, message: string}> $errors
*/
public function __construct(
public array $rows,
public array $errors,
) {}
}
@@ -0,0 +1,285 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
use App\Models\Deal;
use App\Models\ImportLog;
use App\Models\ImportUnknownStatus;
use App\Models\Project;
use App\Models\Reminder;
use App\Services\MonthlyPartitionManager;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Оркестрация исторической миграции лидов из CSV crm.bp-gr.ru (ТЗ §6).
*
* Идемпотентность через webhook_dedup_keys (та же advisory-lock логика, что
* ProcessWebhookJob). Баланс НЕ списывается: исторические данные не являются
* новыми лидами (ТЗ §6.5) фиксируется одна транзакция типа historical_import.
*/
final class HistoricalImportService
{
public function __construct(
private readonly MonthlyPartitionManager $partitions,
private readonly StatusRuToSlugMapper $statusMapper,
) {}
/**
* @param array<int, ParsedLeadRow> $rows
*/
public function import(int $tenantId, int $userId, ImportLog $log, array $rows): ImportResult
{
$dryRun = $log->dry_run;
if ($rows === []) {
return new ImportResult(0, 0, 0, [], []);
}
// Партиции deals под исторический диапазон дат CSV (один раз заранее).
if (! $dryRun) {
$dates = array_map(fn (ParsedLeadRow $r) => $r->receivedAt, $rows);
$this->partitions->ensureRange(
'deals',
min($dates),
max($dates),
);
}
// Tenant-резолвленные переопределения неизвестных статусов.
$overrides = $this->loadStatusOverrides($tenantId);
$added = 0;
$updated = 0;
$skipped = 0;
$unknown = [];
$errors = [];
foreach ($rows as $row) {
$slug = $this->resolveStatus($row->statusRu, $overrides, $unknown);
if ($dryRun) {
$added++; // проекция: для dry-run не различаем add/update
continue;
}
try {
$wasCreated = $this->upsertRow($tenantId, $userId, $row, $slug);
$wasCreated ? $added++ : $updated++;
} catch (Throwable $e) {
$skipped++;
$errors[] = ['source_crm_id' => $row->sourceCrmId, 'message' => $e->getMessage()];
Log::warning('import.row_failed', ['source_crm_id' => $row->sourceCrmId, 'error' => $e->getMessage()]);
}
}
if (! $dryRun) {
$this->persistUnknownStatuses($tenantId, $log->id, $unknown);
$this->recordHistoricalTransaction($tenantId, $added + $updated);
}
return new ImportResult($added, $updated, $skipped, $unknown, $errors);
}
/**
* @return array<string, string> status_ru => slug (только resolved)
*/
private function loadStatusOverrides(int $tenantId): array
{
return DB::transaction(function () use ($tenantId): array {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Явный where(tenant_id) — defense-in-depth: queue worker на prod
// (crm_supplier_worker) — BYPASSRLS, SET LOCAL не фильтрует
// (00_create_roles.sql §5). Без фильтра — cross-tenant утечка маппинга.
return ImportUnknownStatus::query()
->where('tenant_id', $tenantId)
->whereNotNull('mapped_to_slug')
->pluck('mapped_to_slug', 'status_ru')
->all();
});
}
/**
* Маппит статус: каноническая таблица §6.4 tenant-override fallback 'new'.
* Неизвестный статус инкрементит счётчик в $unknown по ссылке.
*
* @param array<string, string> $overrides
* @param array<string, int> $unknown
*/
private function resolveStatus(string $statusRu, array $overrides, array &$unknown): string
{
$slug = $this->statusMapper->toSlug($statusRu);
if ($slug !== null) {
return $slug;
}
$key = trim($statusRu);
if (isset($overrides[$key])) {
return $overrides[$key];
}
$unknown[$key] = ($unknown[$key] ?? 0) + 1;
return 'new';
}
/**
* Идемпотентный upsert одной строки в собственной транзакции.
* Возвращает true создана новая сделка, false обновлена существующая.
*/
private function upsertRow(int $tenantId, int $userId, ParsedLeadRow $row, string $slug): bool
{
return DB::transaction(function () use ($tenantId, $userId, $row, $slug): bool {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$project = Project::firstOrCreate(
['tenant_id' => $tenantId, 'name' => $row->projectName],
['tag' => $row->projectTag, 'type' => 'import'],
);
// advisory lock (tenant_id, source_crm_id) — сериализует upsert (§6.5).
$lockKey = (($tenantId & 0xFFFFFFFF) << 32) | ($row->sourceCrmId & 0xFFFFFFFF);
DB::statement('SELECT pg_advisory_xact_lock(?)', [$lockKey]);
$existing = DB::selectOne(
'SELECT deal_id, deal_received_at FROM webhook_dedup_keys WHERE tenant_id = ? AND source_crm_id = ?',
[$tenantId, $row->sourceCrmId],
);
if ($existing !== null) {
$deal = Deal::query()
->where('id', $existing->deal_id)
->where('received_at', $existing->deal_received_at)
->firstOrFail();
// §6.5 стадия 3a: для исторической миграции status перезаписывается.
$deal->update([
'status' => $slug,
'contact_name' => $row->contactName,
'comment' => $row->comment,
]);
$this->syncReminder($tenantId, $userId, $deal, $row);
return false;
}
$deal = Deal::create([
'tenant_id' => $tenantId,
'source_crm_id' => $row->sourceCrmId,
'project_id' => $project->id,
'phone' => $row->phone,
'status' => $slug,
'contact_name' => $row->contactName,
'comment' => $row->comment,
'received_at' => $row->receivedAt,
]);
DB::table('webhook_dedup_keys')->insert([
'tenant_id' => $tenantId,
'source_crm_id' => $row->sourceCrmId,
'deal_id' => $deal->id,
'deal_received_at' => $deal->received_at,
'created_at' => now(),
]);
$this->syncReminder($tenantId, $userId, $deal, $row);
return true;
});
}
/**
* Создаёт reminders-строку для непустого «Напоминание» (ТЗ §6.3 поле
* deals.reminder_at удалено в v8.3, заменено таблицей reminders).
* Идемпотентно: не дублирует напоминание при повторном импорте.
*/
private function syncReminder(int $tenantId, int $userId, Deal $deal, ParsedLeadRow $row): void
{
if ($row->reminderAt === null) {
return;
}
$exists = Reminder::query()
->where('deal_id', $deal->id)
->where('remind_at', $row->reminderAt)
->exists();
if ($exists) {
return;
}
Reminder::create([
'tenant_id' => $tenantId,
'deal_id' => $deal->id,
'text' => 'Импортировано из crm.bp-gr.ru',
'remind_at' => $row->reminderAt,
'created_by' => $userId,
]);
}
/**
* upsert import_unknown_statuses: инкремент occurrences, маппинг не трогаем.
*
* @param array<string, int> $unknown
*/
private function persistUnknownStatuses(int $tenantId, int $importLogId, array $unknown): void
{
if ($unknown === []) {
return;
}
DB::transaction(function () use ($tenantId, $importLogId, $unknown): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
foreach ($unknown as $statusRu => $count) {
// Явный where(tenant_id) — defense-in-depth под BYPASSRLS queue worker
// (00_create_roles.sql §5): иначе increment мог бы попасть в строку
// чужого тенанта с тем же status_ru.
$existing = ImportUnknownStatus::query()
->where('tenant_id', $tenantId)
->where('status_ru', $statusRu)
->first();
if ($existing !== null) {
$existing->increment('occurrences', $count);
continue;
}
ImportUnknownStatus::create([
'tenant_id' => $tenantId,
'import_log_id' => $importLogId,
'status_ru' => $statusRu,
'occurrences' => $count,
]);
}
});
}
/**
* Одна информационная транзакция historical_import (баланс не меняется, ТЗ §6.5).
*/
private function recordHistoricalTransaction(int $tenantId, int $count): void
{
if ($count === 0) {
return;
}
DB::transaction(function () use ($tenantId, $count): void {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
DB::table('balance_transactions')->insert([
'tenant_id' => $tenantId,
'type' => 'historical_import',
'amount_rub' => 0,
'amount_leads' => 0,
'description' => "Импортировано {$count} исторических сделок (баланс не списан)",
'created_at' => now(),
]);
});
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
/**
* Итог импорта одного файла.
*/
final readonly class ImportResult
{
/**
* @param array<string, int> $unknownStatuses статус_ru => количество вхождений
* @param array<int, array{source_crm_id: int, message: string}> $errors ошибки upsert'а по строке (идентификатор source_crm_id)
*/
public function __construct(
public int $added,
public int $updated,
public int $skipped,
public array $unknownStatuses,
public array $errors,
) {}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
use Carbon\CarbonImmutable;
/**
* Одна валидная строка CSV-импорта лидов (ТЗ §6.3).
*/
final readonly class ParsedLeadRow
{
public function __construct(
public int $sourceCrmId,
public string $projectName,
public ?string $projectTag,
public string $phone,
public CarbonImmutable $receivedAt,
public ?CarbonImmutable $reminderAt,
public ?string $comment,
public string $statusRu,
public ?string $contactName,
) {}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Services\Import;
/**
* Маппинг русских названий статусов воронки в slug (ТЗ §6.4).
*
* Чистый сервис без зависимостей. Tenant-специфичные переопределения
* неизвестных статусов накладываются вызывающим кодом (HistoricalImportService).
*/
class StatusRuToSlugMapper
{
/** @var array<string, string> Канонический маппинг ТЗ §6.4 (14 статусов воронки). */
private const STATUS_RU_TO_SLUG = [
'Новые' => 'new',
'Просмотрено' => 'viewed',
'Проработан' => 'worked',
'База' => 'base',
'Недозвон' => 'missed',
'Переговоры' => 'negotiations',
'Ожидаем оплаты' => 'waiting_payment',
'Партнерка' => 'partnership',
'Оплачено' => 'paid',
'Закрыто и не реализовано' => 'closed',
'Тест драйв' => 'test_drive',
'Горячий' => 'hot',
'На замену' => 'replacement',
'Конечный недозвон' => 'final_missed',
];
/**
* Возвращает slug или null, если статус не входит в каноническую таблицу.
*/
public function toSlug(string $statusRu): ?string
{
return self::STATUS_RU_TO_SLUG[trim($statusRu)] ?? null;
}
/**
* Полная каноническая таблица для UI wizard'а (показать варианты).
*
* @return array<string, string>
*/
public function map(): array
{
return self::STATUS_RU_TO_SLUG;
}
}
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Services;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
/**
* Создаёт месячные RANGE-партиции для таблиц, партиционированных по received_at.
*
* Native-замена pg_partman (расширение недоступно на Windows-стеке без сборки
* из исходников). Идемпотентна: партиция, которая уже есть, пропускается.
*
* Используется:
* - cron `partitions:create-months` N месяцев вперёд;
* - HistoricalImportService под исторический диапазон дат CSV.
*/
class MonthlyPartitionManager
{
/** @var array<int, string> Таблицы, партиционированные по received_at помесячно. */
public const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
/**
* Гарантирует наличие месячных партиций таблицы для всех месяцев,
* пересекающих [$from, $to] включительно.
*
* @return int Сколько партиций реально создано (0 все уже были).
*/
public function ensureRange(string $table, CarbonInterface $from, CarbonInterface $to): int
{
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
}
$month = $from->copy()->startOfMonth();
$last = $to->copy()->startOfMonth();
$created = 0;
while ($month->lessThanOrEqualTo($last)) {
$created += $this->ensureMonth($table, $month) ? 1 : 0;
$month = $month->addMonth();
}
return $created;
}
/**
* Создаёт одну месячную партицию. Возвращает true, если партиция создана,
* false если уже существовала.
*/
public function ensureMonth(string $table, CarbonInterface $monthStart): bool
{
if (! in_array($table, self::PARTITIONED_TABLES, true)) {
throw new InvalidArgumentException("Таблица '{$table}' не партиционирована помесячно");
}
$start = $monthStart->copy()->startOfMonth();
$end = $start->copy()->addMonth();
$partition = sprintf('%s_%s', $table, $start->format('Y_m'));
$exists = DB::selectOne(
"SELECT 1 AS ok FROM pg_class WHERE relname = ? AND relkind = 'r'",
[$partition],
);
if ($exists !== null) {
return false;
}
DB::statement(sprintf(
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
$partition,
$table,
$start->format('Y-m-d'),
$end->format('Y-m-d'),
));
return true;
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Services\Reports\Providers;
use App\Models\ReportJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* billing_summary агрегат balance_transactions по типу операции (audit F1).
*
* Группировка по balance_transactions.type; count + SUM(amount_rub). Тип
* операции переводится в человекочитаемую метку. parameters: date_from,
* date_to (Y-m-d) фильтр по created_at.
*
* RLS-обёртка SET LOCAL app.current_tenant_id (balance_transactions имеет RLS
* tenant_isolation) + явный where('tenant_id') паттерн BillingController.
*/
class BillingSummaryProvider implements ReportDataProvider
{
/** Канон-типы balance_transactions.type → RU-метка (schema §7.6 CHECK). */
private const TYPE_LABELS = [
'trial_bonus' => 'Стартовый бонус',
'topup' => 'Пополнение',
'lead_charge' => 'Списание за лиды',
'refund' => 'Возврат',
'manual_adjustment' => 'Ручная корректировка',
'historical_import' => 'Импорт истории',
'chargeback_writedown' => 'Chargeback — списание в долг',
'chargeback_repayment' => 'Chargeback — погашение долга',
];
public function headers(): array
{
return ['Тип операции', 'Количество', 'Сумма (₽)'];
}
public function rows(ReportJob $job): array
{
$params = $job->parameters ?? [];
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
$rows = DB::table('balance_transactions')
->where('tenant_id', $job->tenant_id)
->whereBetween('created_at', [$dateFrom, $dateTo])
->groupBy('type')
->orderBy('type')
->selectRaw('type, COUNT(*) AS cnt, COALESCE(SUM(amount_rub), 0) AS sum_rub')
->get();
return $rows->map(function ($row): array {
$label = self::TYPE_LABELS[$row->type] ?? (string) $row->type;
return [$label, (int) $row->cnt, (string) $row->sum_rub];
})->all();
});
}
public function slug(): string
{
return 'billing';
}
}
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Services\Reports\Providers;
use App\Models\ReportJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* managers_summary агрегат сделок по менеджерам за период (audit F1).
*
* Группировка по deals.manager_id; неназначенные (manager_id IS NULL) сводятся
* в строку «Не назначен». «Оплачено» = status='paid' (won-статус воронки, как
* в DashboardController). Конверсия = paid / total * 100, округление до 0.1.
*
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted
* (deleted_at IS NULL) и тестовые (is_test=false) сделки. RLS-обёртка
* SET LOCAL app.current_tenant_id паттерн DealsExportProvider.
*/
class ManagersSummaryProvider implements ReportDataProvider
{
public function headers(): array
{
return ['Менеджер', 'Всего сделок', 'Оплачено', 'Конверсия (%)'];
}
public function rows(ReportJob $job): array
{
$params = $job->parameters ?? [];
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
$rows = DB::table('deals')
->leftJoin('users', 'deals.manager_id', '=', 'users.id')
->where('deals.tenant_id', $job->tenant_id)
->whereNull('deals.deleted_at')
->where('deals.is_test', false)
->whereBetween('deals.received_at', [$dateFrom, $dateTo])
->groupBy('deals.manager_id', 'users.first_name', 'users.last_name', 'users.email')
->orderByRaw('COUNT(*) DESC')
->orderBy('deals.manager_id')
->selectRaw(
"deals.manager_id,
users.first_name, users.last_name, users.email,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE deals.status = 'paid') AS paid"
)
->get();
return $rows->map(function ($row): array {
$name = trim(($row->first_name ?? '').' '.($row->last_name ?? ''));
if ($name === '') {
$name = (string) ($row->email ?? '');
}
if ($name === '') {
$name = 'Не назначен';
}
$total = (int) $row->total;
$paid = (int) $row->paid;
$conversion = $total > 0 ? round($paid / $total * 100, 1) : 0.0;
return [$name, $total, $paid, $conversion];
})->all();
});
}
public function slug(): string
{
return 'managers';
}
}
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Services\Reports\Providers;
use App\Models\ReportJob;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* sources_summary агрегат сделок по источнику (utm_source) за период (audit F1).
*
* Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
* строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
* paid / total * 100, округление до 0.1.
*
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
* сделки. RLS-обёртка SET LOCAL app.current_tenant_id паттерн DealsExportProvider.
*/
class SourcesSummaryProvider implements ReportDataProvider
{
public function headers(): array
{
return ['Источник', 'Всего сделок', 'Оплачено', 'Конверсия (%)'];
}
public function rows(ReportJob $job): array
{
$params = $job->parameters ?? [];
$dateFrom = Carbon::parse($params['date_from'])->startOfDay();
$dateTo = Carbon::parse($params['date_to'])->endOfDay();
return DB::transaction(function () use ($job, $dateFrom, $dateTo): array {
DB::statement('SET LOCAL app.current_tenant_id = '.(int) $job->tenant_id);
$rows = DB::table('deals')
->where('tenant_id', $job->tenant_id)
->whereNull('deleted_at')
->where('is_test', false)
->whereBetween('received_at', [$dateFrom, $dateTo])
->groupBy('utm_source')
->orderByRaw('COUNT(*) DESC')
->orderBy('utm_source')
->selectRaw(
"utm_source,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid"
)
->get();
return $rows->map(function ($row): array {
$source = $row->utm_source !== null && trim((string) $row->utm_source) !== ''
? (string) $row->utm_source
: 'Прямые / без метки';
$total = (int) $row->total;
$paid = (int) $row->paid;
$conversion = $total > 0 ? round($paid / $total * 100, 1) : 0.0;
return [$source, $total, $paid, $conversion];
})->all();
});
}
public function slug(): string
{
return 'sources';
}
}
@@ -10,23 +10,28 @@ use App\Services\Reports\Formatters\JsonFormatter;
use App\Services\Reports\Formatters\PdfStubFormatter;
use App\Services\Reports\Formatters\ReportFormatter;
use App\Services\Reports\Formatters\XlsxFormatter;
use App\Services\Reports\Providers\BillingSummaryProvider;
use App\Services\Reports\Providers\DealsExportProvider;
use App\Services\Reports\Providers\ManagersSummaryProvider;
use App\Services\Reports\Providers\ReportDataProvider;
use App\Services\Reports\Providers\SourcesSummaryProvider;
use InvalidArgumentException;
/**
* Резолвит ReportDataProvider по `type` и ReportFormatter по `format`.
*
* Этап 2 (текущий): 1 provider × 4 formatter = 4 комбинации
* (deals_export × csv|xlsx|json|pdf-stub).
*
* Этап 2b расширит до 4 × 4 = 16 (managers_summary, sources_summary,
* billing_summary). Для PDF на MVP stub, fallback'ит в RuntimeException.
* 4 provider'а (deals_export, managers_summary, sources_summary,
* billing_summary) × 4 formatter'а (csv, xlsx, json, pdf). PDF на MVP
* stub: PdfStubFormatter кидает RuntimeException GenerateReportJob
* ловит failed-job (intended, Post-MVP).
*/
class ReportGeneratorRegistry
{
public function __construct(
private readonly DealsExportProvider $dealsExport,
private readonly ManagersSummaryProvider $managersSummary,
private readonly SourcesSummaryProvider $sourcesSummary,
private readonly BillingSummaryProvider $billingSummary,
private readonly CsvFormatter $csv,
private readonly XlsxFormatter $xlsx,
private readonly JsonFormatter $json,
@@ -37,6 +42,9 @@ class ReportGeneratorRegistry
{
return match ($type) {
'deals_export' => $this->dealsExport,
'managers_summary' => $this->managersSummary,
'sources_summary' => $this->sourcesSummary,
'billing_summary' => $this->billingSummary,
default => throw new InvalidArgumentException("Тип отчёта не реализован: {$type}"),
};
}
@@ -54,18 +62,10 @@ class ReportGeneratorRegistry
public function isSupported(string $type, string $format): bool
{
if (! in_array($type, ReportJob::TYPES, true) || ! in_array($format, ReportJob::FORMATS, true)) {
return false;
}
// Этап 2: только deals_export (этап 2b добавит остальные).
$supportedTypes = ['deals_export'];
if (! in_array($type, $supportedTypes, true)) {
return false;
}
// PDF — stub: validates, но генерация даёт failed-job (intended).
// Считаем «поддерживается» — пусть GenerateReportJob сам catch'ит RuntimeException.
return true;
// Все 4 типа ReportJob::TYPES реализованы (F1, 2026-05-16).
// PDF валидируется, но PdfStubFormatter кидает RuntimeException →
// GenerateReportJob ловит → failed-job (intended, Post-MVP).
return in_array($type, ReportJob::TYPES, true)
&& in_array($format, ReportJob::FORMATS, true);
}
}
+2
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\EnsureSaasAdmin;
use App\Http\Middleware\SetTenantContext;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
@@ -18,6 +19,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'tenant' => SetTenantContext::class,
'saas-admin' => EnsureSaasAdmin::class,
]);
// Webhook receive endpoint (POST /api/webhook/{token}) не должен требовать
+1
View File
@@ -65,6 +65,7 @@
"stan": "@php vendor/bin/phpstan analyse --memory-limit=512M",
"mutation": "@php vendor/bin/infection --threads=2 --min-msi=50",
"audit-offline": "@composer audit --locked",
"demo:seed": "@php artisan db:seed --class=DemoSeeder --force",
"ide-helper": [
"@php artisan ide-helper:generate",
"@php artisan ide-helper:meta"
+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,30 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ImportLog;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<ImportLog> */
class ImportLogFactory extends Factory
{
protected $model = ImportLog::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'user_id' => User::factory(),
'filename' => 'leads-export.csv',
'file_path' => 'imports/1/'.$this->faker->uuid().'.csv',
'status' => 'pending',
'entity_type' => 'leads',
'source_system' => 'crm.bp-gr.ru',
'dry_run' => false,
];
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\ImportUnknownStatus;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<ImportUnknownStatus> */
class ImportUnknownStatusFactory extends Factory
{
protected $model = ImportUnknownStatus::class;
public function definition(): array
{
return [
'tenant_id' => Tenant::factory(),
'status_ru' => $this->faker->unique()->word(),
'occurrences' => $this->faker->numberBetween(1, 20),
'mapped_to_slug' => null,
];
}
}
@@ -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,
];
}
}
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Sprint 4 (H1+H2) историческая миграция лидов §6.
*
* H1: новая таблица import_unknown_statuses (tenant-level resolved mappings).
* H2: enrichment import_log +5 колонок.
*
* Guard'ы: migrate:fresh грузит schema.sql v8.21+ (где delta уже есть) до миграций,
* поэтому каждый кусок применяется только при отсутствии.
*/
return new class extends Migration
{
public function up(): void
{
foreach ([
'entity_type' => "ALTER TABLE import_log ADD COLUMN entity_type VARCHAR(20) NOT NULL DEFAULT 'leads' CHECK (entity_type IN ('leads','projects'))",
'source_system' => "ALTER TABLE import_log ADD COLUMN source_system VARCHAR(50) NOT NULL DEFAULT 'crm.bp-gr.ru'",
'mapping_config' => 'ALTER TABLE import_log ADD COLUMN mapping_config JSONB',
'unknown_statuses_count' => 'ALTER TABLE import_log ADD COLUMN unknown_statuses_count INT NOT NULL DEFAULT 0',
'dry_run' => 'ALTER TABLE import_log ADD COLUMN dry_run BOOLEAN NOT NULL DEFAULT FALSE',
] as $column => $ddl) {
if (! Schema::hasColumn('import_log', $column)) {
DB::statement($ddl);
}
}
if (! Schema::hasTable('import_unknown_statuses')) {
DB::statement(<<<'SQL'
CREATE TABLE import_unknown_statuses (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
import_log_id BIGINT REFERENCES import_log(id) ON DELETE SET NULL,
status_ru VARCHAR(100) NOT NULL,
occurrences INT NOT NULL DEFAULT 0,
mapped_to_slug VARCHAR(50) REFERENCES lead_statuses(slug),
resolved_at TIMESTAMPTZ,
resolved_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
UNIQUE (tenant_id, status_ru)
)
SQL);
DB::statement(
'CREATE INDEX idx_import_unknown_statuses_unresolved
ON import_unknown_statuses (tenant_id) WHERE mapped_to_slug IS NULL'
);
DB::statement('ALTER TABLE import_unknown_statuses ENABLE ROW LEVEL SECURITY');
DB::statement(
"CREATE POLICY tenant_isolation ON import_unknown_statuses
USING (tenant_id = current_setting('app.current_tenant_id')::bigint)"
);
}
}
public function down(): void
{
// down() не симметричен: на проекте rollback применяется только после
// migrate:fresh (см. add_archived_at_to_projects). Для отката v8.21 —
// отдельный schema-bump, не эта миграция.
DB::statement('DROP TABLE IF EXISTS import_unknown_statuses');
foreach (['entity_type', 'source_system', 'mapping_config', 'unknown_statuses_count', 'dry_run'] as $column) {
if (Schema::hasColumn('import_log', $column)) {
Schema::table('import_log', fn ($table) => $table->dropColumn($column));
}
}
}
};
+10
View File
@@ -14,6 +14,16 @@ class DemoSeeder extends Seeder
{
public function run(): void
{
// DemoSeeder создаёт демо-данные и НЕ должен исполняться в production.
// DatabaseSeeder вызывает его только в local/testing — этот guard
// дополнительно защищает прямой вызов `db:seed --class=DemoSeeder`
// (в т.ч. через `composer demo:seed`).
if (app()->isProduction()) {
$this->command->warn('DemoSeeder пропущен: запрещён в production.');
return;
}
$tenant = Tenant::query()->where('subdomain', 'demo')->first()
?? Tenant::factory()->create([
'subdomain' => 'demo',
+458 -56
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
@@ -186,12 +180,54 @@ parameters:
count: 3
path: tests/Feature/Admin/AdminSuppliersControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/AdminBillingActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
identifier: method.notFound
count: 7
path: tests/Feature/AdminBillingActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/AdminBillingActionsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 10
path: tests/Feature/AdminBillingIndexTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
identifier: property.notFound
count: 4
path: tests/Feature/AdminIncidentRknNotifyTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/AdminIncidentRknNotifyTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
identifier: property.notFound
count: 5
path: tests/Feature/AdminIncidentShowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/Feature/AdminIncidentShowTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$adminId\.$#'
identifier: property.notFound
@@ -252,6 +288,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 +529,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 +636,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,22 +678,34 @@ 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
count: 1
path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 9
path: tests/Feature/DashboardSummaryTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 37
count: 15
path: tests/Feature/DealCreateTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealCreateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealCreateTest.php
-
@@ -579,7 +729,19 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 20
count: 11
path: tests/Feature/DealDestroyTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealDestroyTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealDestroyTest.php
-
@@ -621,13 +783,25 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 50
count: 30
path: tests/Feature/DealIndexTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealIndexTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 22
count: 21
path: tests/Feature/DealIndexTest.php
-
@@ -645,7 +819,19 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 18
count: 9
path: tests/Feature/DealRestoreTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealRestoreTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealRestoreTest.php
-
@@ -681,19 +867,31 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 7
count: 6
path: tests/Feature/DealShowTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 20
count: 13
path: tests/Feature/DealShowTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealShowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealShowTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 8
count: 7
path: tests/Feature/DealShowTest.php
-
@@ -711,7 +909,19 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 12
count: 7
path: tests/Feature/DealTransitionTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealTransitionTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealTransitionTest.php
-
@@ -735,19 +945,31 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 10
count: 9
path: tests/Feature/DealUpdateTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 24
count: 15
path: tests/Feature/DealUpdateTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealUpdateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealUpdateTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:patchJson\(\)\.$#'
identifier: method.notFound
count: 10
count: 9
path: tests/Feature/DealUpdateTest.php
-
@@ -786,6 +1008,90 @@ parameters:
count: 17
path: tests/Feature/ImpersonationTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$service\.$#'
identifier: property.notFound
count: 10
path: tests/Feature/Import/HistoricalImportServiceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 23
path: tests/Feature/Import/HistoricalImportServiceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 20
path: tests/Feature/Import/HistoricalImportServiceTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Import/ImportCompletedNotificationTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Import/ImportCompletedNotificationTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 12
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 3
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 5
path: tests/Feature/Import/ImportControllerTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 9
path: tests/Feature/Import/ImportLeadsJobTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 4
path: tests/Feature/Import/ImportLeadsJobTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 4
path: tests/Feature/Import/ImportModelsTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 2
path: tests/Feature/Import/ImportModelsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
@@ -816,6 +1122,12 @@ parameters:
count: 16
path: tests/Feature/LookupsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/LookupsTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
@@ -882,12 +1194,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
@@ -963,7 +1269,49 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 25
count: 9
path: tests/Feature/Reports/BillingSummaryProviderTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 8
path: tests/Feature/Reports/ManagersSummaryProviderTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 14
path: tests/Feature/Reports/ManagersSummaryProviderTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 20
path: tests/Feature/Reports/ReportDownloadTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 14
path: tests/Feature/Reports/ReportDownloadTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 3
path: tests/Feature/Reports/ReportDownloadTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:get\(\)\.$#'
identifier: method.notFound
count: 8
path: tests/Feature/Reports/ReportDownloadTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 31
path: tests/Feature/Reports/ReportJobControllerTest.php
-
@@ -987,7 +1335,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 12
count: 14
path: tests/Feature/Reports/ReportJobControllerTest.php
-
@@ -1032,6 +1380,18 @@ parameters:
count: 12
path: tests/Feature/Reports/ReportLifecycleTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 8
path: tests/Feature/Reports/SourcesSummaryProviderTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 12
path: tests/Feature/Reports/SourcesSummaryProviderTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project1Id\.$#'
identifier: property.notFound
@@ -1056,6 +1416,18 @@ parameters:
count: 5
path: tests/Feature/RlsSmokeTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$app\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/SaasAdminMiddlewareTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/SaasAdminMiddlewareTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
@@ -1116,18 +1488,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 +1518,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
+97
View File
@@ -331,3 +331,100 @@ export async function updateSystemSetting(
);
return data;
}
// === SaaS-admin → Биллинг: row-actions (Sprint 3D G4) ===
export interface AdminTariffPlan {
id: number;
name: string;
price_monthly: string;
}
export async function listAdminTariffPlans(): Promise<AdminTariffPlan[]> {
const { data } = await apiClient.get<{ plans: AdminTariffPlan[] }>('/api/admin/billing/tariff-plans');
return data.plans;
}
export async function updateTenantStatus(
id: number,
status: 'active' | 'suspended',
reason: string,
): Promise<{ id: number; status: string }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ id: number; status: string }>(
`/api/admin/billing/tenants/${id}/status`,
{ status, reason },
);
return data;
}
export async function refundTenant(
id: number,
amountRub: number,
reason: string,
): Promise<{ id: number; balance_rub: string; transaction_id: number }> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ id: number; balance_rub: string; transaction_id: number }>(
`/api/admin/billing/tenants/${id}/refund`,
{ amount_rub: amountRub, reason },
);
return data;
}
export async function changeTenantTariff(
id: number,
tariffId: number,
reason: string,
): Promise<{ id: number; tariff_id: number; tariff_name: string }> {
await ensureCsrfCookie();
const { data } = await apiClient.patch<{ id: number; tariff_id: number; tariff_name: string }>(
`/api/admin/billing/tenants/${id}/tariff`,
{ tariff_id: tariffId, reason },
);
return data;
}
// === SaaS-admin → Инциденты: detail-view + РКН-notify (Sprint 3D G5/G6) ===
export interface ApiIncidentAffectedTenant {
id: number;
organization_name: string;
}
export interface ApiAdminIncidentDetail {
id: number;
incident_id: string;
type: string;
severity: 'low' | 'medium' | 'high' | 'critical';
summary: string;
root_cause: string | null;
postmortem_url: string | null;
started_at: string;
detected_at: string;
resolved_at: string | null;
status: 'open' | 'investigating' | 'resolved';
affected_tenants: ApiIncidentAffectedTenant[];
affected_users_count: number | null;
notification_sent_at: string | null;
rkn_notified: boolean;
rkn_notified_at: string | null;
rkn_deadline_at: string | null;
created_by_admin: string | null;
closed_by_admin: string | null;
created_at: string | null;
updated_at: string | null;
}
export async function getAdminIncidentDetail(id: number): Promise<ApiAdminIncidentDetail> {
const { data } = await apiClient.get<{ incident: ApiAdminIncidentDetail }>(`/api/admin/incidents/${id}`);
return data.incident;
}
export async function notifyIncidentRkn(id: number): Promise<ApiAdminIncidentDetail> {
await ensureCsrfCookie();
const { data } = await apiClient.post<{ incident: ApiAdminIncidentDetail }>(
`/api/admin/incidents/${id}/rkn-notify`,
{},
);
return data.incident;
}
+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;
}
+26
View File
@@ -0,0 +1,26 @@
import { apiClient } from './client';
/**
* API-клиент дашборда (audit C1/J3). Эндпоинт GET /api/dashboard/summary.
* На MVP без auth tenant_id параметром (на prod возьмётся из middleware).
*/
export type DeltaDir = 'up' | 'down' | 'neutral';
export type DashboardRange = 'today' | '7d' | '30d';
export interface DashboardSummary {
range: string;
leads_received: { value: number; delta_pct: number; delta_dir: DeltaDir };
conversion: { value: number; delta_pp: number; delta_dir: DeltaDir };
active_projects: { active: number; limit: number };
balance: { amount_rub: string; runway_days: number; runway_leads: number };
activity: { points: number[]; labels: string[]; max: number };
funnel: Record<string, number>;
}
export async function getDashboardSummary(tenantId: number, range: DashboardRange): Promise<DashboardSummary> {
const { data } = await apiClient.get<DashboardSummary>('/api/dashboard/summary', {
params: { tenant_id: tenantId, range },
});
return data;
}
+11
View File
@@ -233,3 +233,14 @@ export async function listProjects(tenantId: number): Promise<ApiProject[]> {
});
return data.projects;
}
/**
* Лёгкий count-only запрос для бейджа «Сделки» в AppSidebar (audit B2).
* Backend пропускает SELECT строк отдаёт только COUNT(*).
*/
export async function fetchDealsCount(tenantId: number): Promise<number> {
const { data } = await apiClient.get<{ total: number }>('/api/deals', {
params: { tenant_id: tenantId, count_only: 1 },
});
return data.total;
}
+66
View File
@@ -0,0 +1,66 @@
import { apiClient } from './client';
/**
* API-клиент исторической миграции лидов (ТЗ §6).
* Эндпоинты: POST/GET /api/imports, /api/imports/unknown-statuses, /api/imports/unknown-statuses/resolve.
*/
export interface ImportLogResource {
id: number;
filename: string;
status: 'pending' | 'processing' | 'done' | 'failed';
rows_total: number;
rows_added: number;
rows_updated: number;
rows_skipped: number;
unknown_statuses_count: number;
dry_run: boolean;
error_message: string | null;
started_at: string | null;
finished_at: string | null;
}
export interface UnknownStatus {
id: number;
status_ru: string;
occurrences: number;
}
export interface StatusMapping {
status_ru: string;
slug: string;
}
/** POST /api/imports — загрузить CSV. */
export async function uploadImport(file: File, dryRun = false): Promise<ImportLogResource> {
const form = new FormData();
form.append('file', file);
if (dryRun) {
form.append('dry_run', '1');
}
const { data } = await apiClient.post<{ data: ImportLogResource }>('/api/imports', form);
return data.data;
}
/** GET /api/imports — история импортов. */
export async function listImports(): Promise<ImportLogResource[]> {
const { data } = await apiClient.get<{ data: ImportLogResource[] }>('/api/imports');
return data.data;
}
/** GET /api/imports/{id} — прогресс одного импорта. */
export async function getImport(id: number): Promise<ImportLogResource> {
const { data } = await apiClient.get<{ data: ImportLogResource }>(`/api/imports/${id}`);
return data.data;
}
/** GET /api/imports/unknown-statuses — незамапленные статусы. */
export async function getUnknownStatuses(): Promise<UnknownStatus[]> {
const { data } = await apiClient.get<{ data: UnknownStatus[] }>('/api/imports/unknown-statuses');
return data.data;
}
/** POST /api/imports/unknown-statuses/resolve — сохранить маппинг. */
export async function resolveUnknownStatuses(mappings: StatusMapping[]): Promise<void> {
await apiClient.post('/api/imports/unknown-statuses/resolve', { mappings });
}
+1
View File
@@ -32,6 +32,7 @@ export interface ApiReportJob {
parameters: ApiReportParameters;
status: ApiReportStatus;
file_path: string | null;
download_url: string | null;
file_size: number | null;
generation_seconds: number | null;
error_message: string | null;
+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>
@@ -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>
@@ -42,7 +42,8 @@ async function loadLookups(tenantId: number) {
managerIdByName.value = map;
}
} catch {
// Молчаливый fallback на mock UI пользователь всё равно увидит.
// Audit C6: фиксируем провал UI покажет degradation-alert.
lookupsFailed.value = true;
}
}
@@ -76,6 +77,9 @@ const errors = ref<Record<string, string>>({});
const submitError = ref<string | null>(null);
const busy = ref(false);
// Audit C6: loadLookups упал показываем degradation-alert (списки = mock).
const lookupsFailed = ref(false);
// Регенерируем ID на каждое создание для local-mode. На API backend SERIAL.
function nextId(): number {
return Math.floor(Date.now() / 1000) + Math.floor(Math.random() * 1000);
@@ -91,6 +95,7 @@ function reset() {
errors.value = {};
submitError.value = null;
busy.value = false;
lookupsFailed.value = false;
}
watch(
@@ -170,6 +175,8 @@ async function submit() {
}
}
defineExpose({ lookupsFailed });
function close() {
dialogOpen.value = false;
}
@@ -190,6 +197,17 @@ function close() {
>
{{ submitError }}
</v-alert>
<v-alert
v-if="lookupsFailed"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
data-testid="lookups-error-alert"
>
Не удалось загрузить списки проектов и менеджеров показаны примерные значения. Проверьте выбор
перед сохранением.
</v-alert>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
@@ -0,0 +1,125 @@
<script setup lang="ts">
/**
* Wizard маппинга неизвестных статусов воронки из CSV-импорта (ТЗ §6.4/§6.6).
*
* Для каждого незамапленного русского статуса пользователь выбирает один из
* 14 канонических slug'ов. Сохранение POST /api/imports/unknown-statuses/resolve.
*/
import { computed, reactive, ref } from 'vue';
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
const props = defineProps<{
modelValue: boolean;
statuses: UnknownStatus[];
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
resolved: [];
}>();
/** 14 канонических статусов воронки (ТЗ §6.4). */
const STATUS_OPTIONS: { value: string; title: string }[] = [
{ value: 'new', title: 'Новые' },
{ value: 'viewed', title: 'Просмотрено' },
{ value: 'worked', title: 'Проработан' },
{ value: 'base', title: 'База' },
{ value: 'missed', title: 'Недозвон' },
{ value: 'negotiations', title: 'Переговоры' },
{ value: 'waiting_payment', title: 'Ожидаем оплаты' },
{ value: 'partnership', title: 'Партнерка' },
{ value: 'paid', title: 'Оплачено' },
{ value: 'closed', title: 'Закрыто и не реализовано' },
{ value: 'test_drive', title: 'Тест драйв' },
{ value: 'hot', title: 'Горячий' },
{ value: 'replacement', title: 'На замену' },
{ value: 'final_missed', title: 'Конечный недозвон' },
];
const selection = reactive<Record<string, string | null>>({});
const saving = ref(false);
const error = ref<string | null>(null);
const dialogOpen = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
});
const allMapped = computed(
() => props.statuses.length > 0 && props.statuses.every((s) => !!selection[s.status_ru]),
);
async function save(): Promise<void> {
if (!allMapped.value) {
return;
}
saving.value = true;
error.value = null;
try {
const mappings: StatusMapping[] = props.statuses.map((s) => ({
status_ru: s.status_ru,
slug: selection[s.status_ru] as string,
}));
await resolveUnknownStatuses(mappings);
emit('resolved');
} catch {
error.value = 'Не удалось сохранить маппинг. Повторите попытку.';
} finally {
saving.value = false;
}
}
defineExpose({ selection, save });
</script>
<template>
<v-dialog v-model="dialogOpen" max-width="640">
<v-card>
<v-card-title class="text-h6">Маппинг неизвестных статусов</v-card-title>
<v-card-text>
<p class="text-body-2 text-medium-emphasis mb-4">
Эти статусы из CSV не входят в стандартную воронку. Выберите
соответствие повторный импорт применит маппинг автоматически.
</p>
<div
v-for="status in statuses"
:key="status.id"
class="d-flex align-center ga-3 mb-3"
>
<div class="flex-grow-1">
<strong>{{ status.status_ru }}</strong>
<span class="text-caption text-medium-emphasis ml-2">
({{ status.occurrences }} шт.)
</span>
</div>
<v-select
v-model="selection[status.status_ru]"
:items="STATUS_OPTIONS"
label="Статус воронки"
density="compact"
variant="outlined"
hide-details
style="max-width: 280px"
/>
</div>
<v-alert v-if="error" type="error" variant="tonal" class="mt-2">
{{ error }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialogOpen = false">Отмена</v-btn>
<v-btn
data-test="save-mappings"
color="primary"
variant="flat"
:loading="saving"
:disabled="!allMapped"
@click="save"
>
Сохранить
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
@@ -5,11 +5,14 @@
* + active-marker pseudo-element + JetBrains Mono badges.
*
* Brand mark + nav-tree (3 группы: Работа, Финансы, Команда).
* Counts для «Сделки» mock.
* Count для «Сделки» live из API (dealsCount-store, audit B2).
*/
import { computed } from 'vue';
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import Kbd from '../ui/Kbd.vue';
import { useAuthStore } from '../../stores/auth';
import { useDealsCountStore } from '../../stores/dealsCount';
import { useCommandPalette } from '../../composables/useCommandPalette';
interface NavItem {
title: string;
@@ -26,15 +29,31 @@ interface NavGroup {
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
const route = useRoute();
const auth = useAuthStore();
const dealsCount = useDealsCountStore();
const { openPalette } = useCommandPalette();
onMounted(() => {
if (auth.user?.tenant_id) void dealsCount.load(auth.user.tenant_id);
});
const navGroups = computed<NavGroup[]>(() => [
{
eyebrow: 'Работа',
items: [
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals', count: 247 },
// B2: count из dealsCount-store; null undefined (NavItem.count number|undefined),
// resolveCount затем 0 и v-if скрывает бейдж пока счётчик не загружен.
{
title: 'Сделки',
icon: 'mdi-format-list-bulleted',
to: '/deals',
count: dealsCount.count ?? undefined,
countKey: 'deals',
},
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
{ title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
],
},
{
@@ -63,7 +82,15 @@ defineExpose({ navGroups });
<span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
</div>
<div class="ld-cmdk-stub" role="button" tabindex="0">
<div
class="ld-cmdk-stub"
role="button"
tabindex="0"
data-testid="cmdk-stub"
@click="openPalette"
@keydown.enter="openPalette"
@keydown.space.prevent="openPalette"
>
<span class="ld-cmdk-stub__placeholder">Поиск, команды</span>
<Kbd dark>K</Kbd>
</div>
@@ -8,6 +8,7 @@ import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
import { useNotificationsStore } from '../../stores/notifications';
import { useCommandPalette } from '../../composables/useCommandPalette';
defineProps<{
pageTitle: string;
@@ -20,6 +21,7 @@ const emit = defineEmits<{
const auth = useAuthStore();
const notifications = useNotificationsStore();
const router = useRouter();
const { openPalette } = useCommandPalette();
const unreadDisplay = computed(() => {
if (notifications.unreadCount === 0) return '';
@@ -56,8 +58,8 @@ function formatRelative(iso: string | null): string {
async function handleNotificationClick(id: number, dealId: number | null): Promise<void> {
await notifications.markRead(id);
if (dealId !== null) {
// На MVP push на DealsView (deep-link на конкретный drawer отдельный коммит).
await router.push('/deals');
// Audit F3: deep-link на конкретный drawer через ?openId=.
await router.push({ path: '/deals', query: { openId: dealId } });
}
}
@@ -87,11 +89,7 @@ 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"
aria-label="Открыть меню навигации"
@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>
@@ -99,7 +97,14 @@ async function handleLogout(): Promise<void> {
<v-spacer />
<v-btn variant="outlined" size="small" prepend-icon="mdi-magnify" class="searchbar mr-2" disabled>
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-magnify"
class="searchbar mr-2"
data-testid="topbar-search-btn"
@click="openPalette"
>
Поиск
<template #append>
<kbd class="search-kbd">K</kbd>
@@ -0,0 +1,113 @@
<script setup lang="ts">
/**
* Минимальная command-palette (audit B3). Открывается по K / Ctrl+K, кликом
* на плашку в AppSidebar или кнопку «Поиск» в AppTopbar. Список навигация
* по 8 разделам портала; фильтр по подстроке; Enter первый результат.
* Монтируется один раз в AppLayout.
*/
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useCommandPalette } from '../../composables/useCommandPalette';
interface PaletteItem {
title: string;
icon: string;
to: string;
}
const NAV_ITEMS: PaletteItem[] = [
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals' },
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
{ title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' },
{ title: 'Отчёты', icon: 'mdi-chart-box-outline', to: '/reports' },
{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
];
const { open, closePalette } = useCommandPalette();
const router = useRouter();
const query = ref('');
const filteredItems = computed<PaletteItem[]>(() => {
const q = query.value.trim().toLowerCase();
if (q === '') return NAV_ITEMS;
return NAV_ITEMS.filter((i) => i.title.toLowerCase().includes(q));
});
// Сброс query при каждом открытии.
watch(open, (isOpen) => {
if (isOpen) query.value = '';
});
function selectItem(item: PaletteItem): void {
closePalette();
void router.push(item.to);
}
function onSubmit(): void {
const first = filteredItems.value[0];
if (first) selectItem(first);
}
function onGlobalKeydown(e: KeyboardEvent): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
if (open.value) return;
e.preventDefault();
open.value = true;
}
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown));
onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown));
defineExpose({ query, filteredItems, selectItem, onSubmit });
</script>
<template>
<v-dialog v-model="open" :max-width="520" data-testid="command-palette">
<v-card class="cmdk-card">
<v-text-field
v-model="query"
autofocus
placeholder="Поиск разделов…"
variant="plain"
density="comfortable"
hide-details
prepend-inner-icon="mdi-magnify"
class="cmdk-input px-3 pt-2"
data-testid="command-palette-input"
@keydown.enter="onSubmit"
/>
<v-divider />
<v-list density="compact" class="cmdk-list" data-testid="command-palette-list">
<v-list-item
v-for="item in filteredItems"
:key="item.to"
:prepend-icon="item.icon"
:title="item.title"
data-testid="command-palette-item"
@click="selectItem(item)"
/>
<v-list-item
v-if="filteredItems.length === 0"
class="text-medium-emphasis"
title="Ничего не найдено"
data-testid="command-palette-empty"
/>
</v-list>
</v-card>
</v-dialog>
</template>
<style scoped>
.cmdk-card {
overflow: hidden;
}
.cmdk-list {
max-height: 320px;
overflow-y: auto;
}
</style>
@@ -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>
@@ -98,7 +98,8 @@ function canRetry(job: ReportJob): boolean {
</v-chip>
<div class="job-actions">
<v-btn
v-if="job.status === 'done'"
v-if="job.status === 'done' && job.downloadUrl"
:href="job.downloadUrl"
icon="mdi-download"
variant="text"
size="small"
@@ -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.
*/
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;
}
@@ -45,4 +45,5 @@ export interface ReportJob {
progress: number | null; // 0..100 для running
attempt: number; // 1..3
error: string | null;
downloadUrl: string | null; // signed URL (24ч) скачивания готового файла; null для не-готовых
}
@@ -43,6 +43,7 @@ export function mapApiReportJob(api: ApiReportJob, now: Date = new Date()): Repo
progress: api.status === 'processing' ? 50 : null,
attempt: api.retry_count + 1,
error: api.error_message,
downloadUrl: api.download_url,
};
}
@@ -0,0 +1,20 @@
import { ref } from 'vue';
/**
* Глобальное состояние command-palette (K, audit B3). Module-level singleton
* ref AppSidebar/AppTopbar открывают палитру без prop-drilling, CommandPalette
* (смонтирована один раз в AppLayout) использует тот же ref как v-model.
*/
const open = ref(false);
export function useCommandPalette() {
return {
open,
openPalette: (): void => {
open.value = true;
},
closePalette: (): void => {
open.value = false;
},
};
}
-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" />
+2
View File
@@ -18,6 +18,7 @@ import { usePolling } from '../composables/usePolling';
import AppSidebar from '../components/layout/AppSidebar.vue';
import AppTopbar from '../components/layout/AppTopbar.vue';
import DevIndexBadge from '../components/DevIndexBadge.vue';
import CommandPalette from '../components/layout/CommandPalette.vue';
const auth = useAuthStore();
const notifications = useNotificationsStore();
@@ -73,6 +74,7 @@ usePolling(loadReminderCounts, { intervalMs: 60_000, enabled: true });
</RouterView>
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
<CommandPalette />
</v-app>
</template>
+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>
+25 -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',
@@ -180,6 +180,19 @@ const routes: RouteRecordRaw[] = [
devLabel: 'Напоминания',
},
},
{
path: '/import',
name: 'import',
component: () => import('../views/ImportView.vue'),
meta: {
layout: 'app',
title: 'Импорт данных',
requiresAuth: true,
transition: 'ld-route-fadeup',
devIndex: 29,
devLabel: 'Импорт данных',
},
},
// Админка SaaS — отдельный layout с под-брендом ADMIN.
// TODO: дополнительный role-guard на super_admin.
{
@@ -210,6 +223,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/admin/AdminIncidentsView.vue'),
meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true, devIndex: 24, devLabel: 'Admin Incidents' },
},
{
path: '/admin/incidents/:id',
name: 'admin-incident-detail',
component: () => import('../views/admin/AdminIncidentDetailView.vue'),
meta: { layout: 'admin', title: 'Инцидент', requiresAuth: true },
},
{
path: '/admin/system',
name: 'admin-system',
+21
View File
@@ -0,0 +1,21 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { fetchDealsCount } from '../api/deals';
/**
* Счётчик сделок tenant'а для бейджа «Сделки» в AppSidebar (audit B2).
* count=null до загрузки или на fail бейдж скрыт (resolveCount 0).
*/
export const useDealsCountStore = defineStore('dealsCount', () => {
const count = ref<number | null>(null);
async function load(tenantId: number): Promise<void> {
try {
count.value = await fetchDealsCount(tenantId);
} catch {
count.value = null;
}
}
return { count, load };
});
+104 -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,7 @@ const activeView = ref<'overview' | 'charges'>('overview');
align-items: center;
}
.page-stats .sep {
/* WCAG2AA 4.5:1 fix (was #92907b → 2.92:1 on ivory; #6b6356 → 5.33:1). */
/* WCAG2AA 4.5:1: #6b6356 → 5.33:1 on ivory. */
color: #6b6356;
}
+95 -48
View File
@@ -1,67 +1,103 @@
<script setup lang="ts">
/**
* Дашборд стартовая страница для авторизованных пользователей.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html.
* MVP: page-head + 4 KPI-cards (получено лидов / конверсия / активные проекты /
* баланс). Графики (Активность по дням, Воронка из 14 статусов).
*
* Все числа сейчас mock'и — TODO: GET /api/dashboard/summary с tenant-context'ом
* по middleware SetTenantContext (фаза backend).
*
* Sprint 4 Phase B/3 split на DashboardPageHead + DashboardKpiRow +
* DashboardBalance (audit O-refactor-04 закрытие). State (range, kpis, balance)
* остаётся в parent ради единого mock-data flow и future API-fetch'а.
*
* Примечание: «recent deals list» в Phase B/3 plan'е на текущем дашборде нет
* (есть только charts row); если будет добавлено в будущем выносится в
* DashboardRecentDeals.vue по аналогии.
* Дашборд стартовая страница. Audit C1/J3: KPI/баланс/активность/воронка
* грузятся из GET /api/dashboard/summary; при ошибке fallback на mock,
* чтобы UI оставался работоспособным (dev / отсутствие backend).
*/
import { ref } from 'vue';
import { ref, watch } from 'vue';
import ActivityChart from '../components/charts/ActivityChart.vue';
import FunnelChart from '../components/charts/FunnelChart.vue';
import DashboardPageHead from '../components/dashboard/DashboardPageHead.vue';
import DashboardKpiRow, { type Kpi } from '../components/dashboard/DashboardKpiRow.vue';
import DashboardBalance, { type Balance } from '../components/dashboard/DashboardBalance.vue';
import { getDashboardSummary, type DashboardRange, type DashboardSummary } from '../api/dashboard';
import { useAuthStore } from '../stores/auth';
const range = ref<'today' | '7d' | '30d' | 'custom'>('7d');
const auth = useAuthStore();
const range = ref<DashboardRange | 'custom'>('7d');
const kpis: Kpi[] = [
{
label: 'Получено лидов',
value: '247',
delta: { dir: 'up', text: '12.3%' },
sub: 'vs предыдущие 7 дней',
},
{
label: 'Конверсия в оплату',
value: '18.4',
unit: '%',
delta: { dir: 'up', text: '2.1pp' },
sub: 'vs предыдущие 7 дней',
},
{
label: 'Активные проекты',
value: '8',
unit: '/ 10',
delta: { dir: 'neutral', text: '2 свободно' },
sub: 'тариф «Команда»',
},
// runwayMax display-константа полосы (7 сегментов), не из API.
const RUNWAY_MAX = 7;
// Mock-fallback UI работоспособен без backend (dev / 500 / нет auth).
const MOCK_KPIS: Kpi[] = [
{ label: 'Получено лидов', value: '247', delta: { dir: 'up', text: '12.3%' }, sub: 'vs предыдущий период' },
{ label: 'Конверсия в оплату', value: '18.4', unit: '%', delta: { dir: 'up', text: '2.1pp' }, sub: 'vs предыдущий период' },
{ label: 'Активные проекты', value: '8', unit: '/ 10', delta: { dir: 'neutral', text: '' }, sub: 'лимит тарифа' },
];
const MOCK_BALANCE: Balance = { amount: '14 250', runwayDays: 4, runwayMax: RUNWAY_MAX, runwayLeads: 285 };
const balance: Balance = {
amount: '14 250',
runwayDays: 4,
runwayMax: 7,
runwayLeads: 285,
};
const kpis = ref<Kpi[]>(MOCK_KPIS);
const balance = ref<Balance>(MOCK_BALANCE);
const activityPoints = ref<number[]>([16, 31, 27, 47, 39, 56, 50]);
const activityLabels = ref<string[]>(['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'сегодня']);
const activityMax = ref(60);
const funnelCounts = ref<Record<string, number> | undefined>(undefined);
const fetchError = ref(false);
/** Форматирует число с пробелами-разделителями тысяч ('14250.00' → '14 250'). */
function formatRub(raw: string): string {
const n = parseFloat(raw);
if (!Number.isFinite(n)) return '0';
const int = Math.round(n).toString();
return int.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
function applySummary(s: DashboardSummary): void {
kpis.value = [
{
label: 'Получено лидов',
value: String(s.leads_received.value),
delta: { dir: s.leads_received.delta_dir, text: `${s.leads_received.delta_pct}%` },
sub: 'vs предыдущий период',
},
{
label: 'Конверсия в оплату',
value: String(s.conversion.value),
unit: '%',
delta: { dir: s.conversion.delta_dir, text: `${s.conversion.delta_pp}pp` },
sub: 'vs предыдущий период',
},
{
label: 'Активные проекты',
value: String(s.active_projects.active),
unit: `/ ${s.active_projects.limit}`,
delta: { dir: 'neutral', text: '' },
sub: 'лимит тарифа',
},
];
balance.value = {
amount: formatRub(s.balance.amount_rub),
runwayDays: Math.min(s.balance.runway_days, RUNWAY_MAX),
runwayMax: RUNWAY_MAX,
runwayLeads: s.balance.runway_leads,
};
activityPoints.value = s.activity.points;
activityLabels.value = s.activity.labels;
activityMax.value = s.activity.max;
funnelCounts.value = s.funnel;
}
async function load(): Promise<void> {
const tenantId = auth.user?.tenant_id;
if (!tenantId || range.value === 'custom') return;
try {
applySummary(await getDashboardSummary(tenantId, range.value as DashboardRange));
fetchError.value = false;
} catch {
fetchError.value = true; // оставляем последнее значение / mock
}
}
watch(range, load);
load();
</script>
<template>
<v-container fluid class="dashboard pa-6">
<DashboardPageHead v-model="range" />
<div class="ld-meta mt-2">
<div v-show="!fetchError" class="ld-meta mt-2">
<span class="ld-pulse" aria-hidden="true"></span>
<span>Live · обновлено только что</span>
</div>
@@ -71,12 +107,23 @@ const balance: Balance = {
<DashboardBalance :balance="balance" />
</v-row>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
class="mt-3"
data-testid="dashboard-fetch-error"
>
Не удалось обновить данные дашборда показаны последние известные значения.
</v-alert>
<v-row class="charts-row mt-4">
<v-col cols="12" md="7">
<ActivityChart />
<ActivityChart :points="activityPoints" :labels="activityLabels" :max="activityMax" />
</v-col>
<v-col cols="12" md="5">
<FunnelChart />
<FunnelChart :counts="funnelCounts" />
</v-col>
</v-row>
</v-container>
+229 -33
View File
@@ -13,7 +13,8 @@
*
* Источник статусов 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 { useRoute } from 'vue-router';
import { DEALS_TABS, MOCK_DEALS, type MockDeal } from '../composables/mockDeals';
import { mapApiDeal } from '../composables/dealsApiMapper';
import { usePolling } from '../composables/usePolling';
@@ -37,13 +38,64 @@ 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 route = useRoute();
const auth = useAuthStore();
const leadStatusesStore = useLeadStatusesStore();
@@ -115,11 +167,17 @@ async function applyBulkRestoreFromTrash() {
deleteToastOpen.value = true;
}
onMounted(() => {
onMounted(async () => {
void leadStatusesStore.load();
void loadDeals();
await loadDeals();
openDealFromQuery();
});
watch(
() => route.query.openId,
() => openDealFromQuery(),
);
// Polling каждые 30 сек авто-refresh dealsState. Pause при скрытой вкладке.
// Включается только при наличии auth.user (без auth listDeals = no-op anyway).
usePolling(loadDeals);
@@ -168,6 +226,16 @@ function openDeal(deal: MockDeal) {
drawerOpen.value = true;
}
/** Audit C8/F3: deep-link — открыть drawer сделки по ?openId= из URL. */
function openDealFromQuery(): void {
const raw = route.query.openId;
const id = Number(Array.isArray(raw) ? raw[0] : raw);
if (!Number.isInteger(id) || id <= 0) return;
if (selectedDeal.value?.id === id) return;
const deal = dealsState.find((d) => d.id === id);
if (deal) openDeal(deal);
}
async function applyBulkStatus(slug: MockDeal['statusSlug']) {
const ids = [...selected.value];
statusMenuOpen.value = false;
@@ -277,25 +345,42 @@ async function applyBulkExport(format: 'xlsx' | 'csv' = 'xlsx') {
exportToastOpen.value = true;
return;
}
await exportDealIds([...selected.value], format);
}
// С tenant_id backend (RLS-фильтрация чужих id). На fail fallback на
// local CSV (даже если запросили xlsx без backend'а xlsx не построим).
// Audit C3: экспорт всех отфильтрованных сделок кнопка «Экспорт» в page-head.
async function exportAllFiltered(format: 'xlsx' | 'csv' = 'xlsx') {
const ids = filteredDeals.value.map((d) => d.id);
if (ids.length === 0) {
exportToastText.value = 'Список пуст — нечего экспортировать.';
exportToastOpen.value = true;
return;
}
await exportDealIds(ids, format);
}
/**
* Общий экспорт по списку id. С tenant_id backend (RLS-фильтрация чужих id).
* На fail / без tenant fallback на локальный CSV.
*/
async function exportDealIds(ids: number[], format: 'xlsx' | 'csv') {
exportToastText.value = '';
if (auth.user?.tenant_id) {
try {
if (format === 'xlsx') {
const blob = await dealsApi.exportDealsXlsx({
tenant_id: auth.user.tenant_id,
ids: selected.value,
ids,
});
triggerBlobDownload(blob, `deals_export_${new Date().toISOString().slice(0, 10)}.xlsx`);
exportToastText.value = `Экспортировано ${selected.value.length} сделок в XLSX.`;
exportToastText.value = `Экспортировано ${ids.length} сделок в XLSX.`;
} else {
const csv = await dealsApi.exportDeals({
tenant_id: auth.user.tenant_id,
ids: selected.value,
ids,
});
triggerCsvDownload(csv, `deals_export_${new Date().toISOString().slice(0, 10)}.csv`);
exportToastText.value = `Экспортировано ${selected.value.length} сделок в CSV.`;
exportToastText.value = `Экспортировано ${ids.length} сделок в CSV.`;
}
exportToastOpen.value = true;
return;
@@ -304,11 +389,11 @@ async function applyBulkExport(format: 'xlsx' | 'csv' = 'xlsx') {
}
}
buildLocalCsv();
buildLocalCsv(ids);
}
function buildLocalCsv() {
const idSet = new Set(selected.value);
function buildLocalCsv(ids: number[]) {
const idSet = new Set(ids);
const rows = dealsState.filter((d) => idSet.has(d.id));
const headers = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Получено мин назад'];
const csv = buildCsvString(
@@ -330,6 +415,7 @@ defineExpose({
applyBulkStatus,
applyBulkDelete,
applyBulkExport,
exportAllFiltered,
exportToastOpen,
exportToastText,
onDealCreated,
@@ -350,6 +436,18 @@ defineExpose({
trashMode,
toggleTrashMode,
applyBulkRestoreFromTrash,
projectMenuOpen,
managerMenuOpen,
projectMenuDraft,
managerMenuDraft,
applyProjectFilter,
applyManagerFilter,
clearProjectDraft,
clearManagerDraft,
toggleProjectDraft,
toggleManagerDraft,
drawerOpen,
selectedDeal,
});
const leadStatuses = computed(() => leadStatusesStore.statuses);
@@ -433,7 +531,15 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
>
{{ trashMode ? 'К сделкам' : 'Корзина' }}
</v-btn>
<v-btn v-if="!trashMode" variant="outlined" prepend-icon="mdi-download">Экспорт</v-btn>
<v-btn
v-if="!trashMode"
variant="outlined"
prepend-icon="mdi-download"
data-testid="export-all-btn"
@click="exportAllFiltered()"
>
Экспорт
</v-btn>
<v-btn
v-if="!trashMode"
color="primary"
@@ -463,10 +569,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 +580,109 @@ 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>
+240
View File
@@ -0,0 +1,240 @@
<script setup lang="ts">
/**
* Импорт данных загрузка CSV исторических лидов из crm.bp-gr.ru (ТЗ §6).
*
* Flow: выбрать файл загрузить polling прогресса таблица результата.
* Неизвестные статусы маппятся через UnknownStatusesDialog.
*/
import { computed, onMounted, onUnmounted, ref } from 'vue';
import {
getImport,
getUnknownStatuses,
listImports,
uploadImport,
type ImportLogResource,
type UnknownStatus,
} from '../api/imports';
import UnknownStatusesDialog from '../components/import/UnknownStatusesDialog.vue';
const file = ref<File | null>(null);
const dryRun = ref(false);
const uploading = ref(false);
const errorMessage = ref<string | null>(null);
const history = ref<ImportLogResource[]>([]);
const activeImport = ref<ImportLogResource | null>(null);
const unknownStatuses = ref<UnknownStatus[]>([]);
const wizardOpen = ref(false);
/** Интервал опроса прогресса активного импорта, мс. */
const POLL_INTERVAL_MS = 2000;
let pollTimer: ReturnType<typeof setInterval> | null = null;
const canUpload = computed(() => file.value !== null && !uploading.value);
const isProcessing = computed(
() =>
activeImport.value?.status === 'pending' ||
activeImport.value?.status === 'processing',
);
async function refreshHistory(): Promise<void> {
try {
history.value = await listImports();
} catch {
// история не критично, тихо игнорируем
}
}
async function refreshUnknown(): Promise<void> {
try {
unknownStatuses.value = await getUnknownStatuses();
} catch {
unknownStatuses.value = [];
}
}
function stopPolling(): void {
if (pollTimer !== null) {
clearInterval(pollTimer);
pollTimer = null;
}
}
async function pollOnce(id: number): Promise<void> {
try {
activeImport.value = await getImport(id);
if (!isProcessing.value) {
stopPolling();
await refreshHistory();
await refreshUnknown();
}
} catch {
stopPolling();
}
}
function startPolling(id: number): void {
stopPolling();
pollTimer = setInterval(() => {
void pollOnce(id);
}, POLL_INTERVAL_MS);
}
async function submit(): Promise<void> {
if (file.value === null) {
return;
}
uploading.value = true;
errorMessage.value = null;
try {
activeImport.value = await uploadImport(file.value, dryRun.value);
startPolling(activeImport.value.id);
file.value = null;
} catch {
errorMessage.value = 'Не удалось загрузить файл. Проверьте формат (CSV, до 10 МБ).';
} finally {
uploading.value = false;
}
}
async function onWizardResolved(): Promise<void> {
wizardOpen.value = false;
await refreshUnknown();
}
onMounted(async () => {
await refreshHistory();
await refreshUnknown();
});
onUnmounted(stopPolling);
</script>
<template>
<v-container fluid class="import-view pa-6">
<header class="page-head mb-4">
<h1 class="text-h4 mb-2">Импорт данных</h1>
<p class="text-body-2 text-medium-emphasis ma-0">
Перенос исторических лидов из crm.bp-gr.ru. Формат CSV-выгрузка (UTF-8).
</p>
</header>
<v-alert
v-if="unknownStatuses.length > 0"
data-test="unknown-banner"
type="warning"
variant="tonal"
class="mb-4"
>
Найдено {{ unknownStatuses.length }} неизвестных статусов воронки замапьте вручную.
<template #append>
<v-btn size="small" variant="flat" @click="wizardOpen = true">Замапить</v-btn>
</template>
</v-alert>
<v-card variant="outlined" class="pa-6 mb-6">
<v-file-input
v-model="file"
label="CSV-файл выгрузки лидов"
accept=".csv,text/csv"
prepend-icon="mdi-database-import-outline"
variant="outlined"
density="comfortable"
:disabled="uploading"
/>
<v-checkbox
v-model="dryRun"
label="Пробный прогон (проверить файл без записи сделок)"
density="compact"
hide-details
/>
<v-alert v-if="errorMessage" type="error" variant="tonal" class="mt-3">
{{ errorMessage }}
</v-alert>
<div class="mt-4">
<v-btn
data-test="upload-btn"
color="primary"
:loading="uploading"
:disabled="!canUpload"
@click="submit"
>
Загрузить
</v-btn>
</div>
</v-card>
<v-card v-if="activeImport" variant="outlined" class="pa-6 mb-6">
<h2 class="text-h6 mb-3">Текущий импорт {{ activeImport.filename }}</h2>
<v-progress-linear v-if="isProcessing" indeterminate color="primary" class="mb-3" />
<div data-test="active-status" class="text-body-2">
Статус: <strong>{{ activeImport.status }}</strong>
</div>
<v-table v-if="!isProcessing" density="compact" class="mt-3">
<tbody>
<tr>
<td>Добавлено</td>
<td>{{ activeImport.rows_added }}</td>
</tr>
<tr>
<td>Обновлено</td>
<td>{{ activeImport.rows_updated }}</td>
</tr>
<tr>
<td>Пропущено</td>
<td>{{ activeImport.rows_skipped }}</td>
</tr>
<tr>
<td>Неизвестных статусов</td>
<td>{{ activeImport.unknown_statuses_count }}</td>
</tr>
</tbody>
</v-table>
<v-alert
v-if="activeImport.status === 'failed'"
type="error"
variant="tonal"
class="mt-3"
>
{{ activeImport.error_message }}
</v-alert>
</v-card>
<v-card variant="outlined" class="pa-6">
<h2 class="text-h6 mb-3">История импортов</h2>
<v-table v-if="history.length > 0" density="compact">
<thead>
<tr>
<th>Файл</th>
<th>Статус</th>
<th>Добавлено</th>
<th>Обновлено</th>
<th>Пропущено</th>
</tr>
</thead>
<tbody>
<tr v-for="row in history" :key="row.id">
<td>{{ row.filename }}</td>
<td>{{ row.status }}</td>
<td>{{ row.rows_added }}</td>
<td>{{ row.rows_updated }}</td>
<td>{{ row.rows_skipped }}</td>
</tr>
</tbody>
</v-table>
<p v-else class="text-body-2 text-medium-emphasis ma-0">Импортов пока нет.</p>
</v-card>
<UnknownStatusesDialog
v-model="wizardOpen"
:statuses="unknownStatuses"
@resolved="onWizardResolved"
/>
</v-container>
</template>
<style scoped>
.import-view {
max-width: 1100px;
}
</style>
+67 -10
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>

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