Compare commits

..

42 Commits

Author SHA1 Message Date
Дмитрий 33d9c43450 docs(c10): fix lint debt in brainstorming spec (MD032 + optimise→optimize)
Spec committed pre-lefthook (cd56efb) — never lint-checked. MD032
blank-around-lists + British→US spelling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:44:15 +03:00
Дмитрий afcff10892 feat(map): C10 nodes — closes section «Бизнес-процессы (общее)»
3 new nodes (ops_plugin, process_modeling, process_analysis) → NODE_SECTION
C10; 5 reuse cross-refs (mermaid/architecture-patterns/CCPM/product-management/
writing-plans) → NODE_SECTION_SECONDARY; 3 governing edges; 3 nd() + Паспорт
entries. Map 121→124 nodes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:44:15 +03:00
Дмитрий 1a49d7b127 docs(c10): register business-process category — Tooling/PSR/Pravila/CLAUDE.md
C10 #51 operations + #52 process-modeling + #53 process-analysis +
Tooling Прил.Н v2.11 (§4.26-4.29, §0 50→54), PSR_v1 v3.11 (R10.1),
Pravila v1.25 (§13.2), CLAUDE.md v2.11. CLAUDE.md via direct Edit —
worktree-constraint exception to §5 п.10 (A11 v1.24 precedent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:44:15 +03:00
Дмитрий a816c2413b feat(c10): bootstrap docs/process — README + worked example + ADR-008
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:33:52 +03:00
Дмитрий b22b76f96e feat(c10): add self-authored process-analysis skill (discovery/bottleneck)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:33:52 +03:00
Дмитрий ea5e475f32 feat(c10): add self-authored process-modeling skill (BPMN/process maps) 2026-05-18 04:33:52 +03:00
Дмитрий 626baa65ec docs(c10): plan correction — operations is 9 skills, not /ops:* commands
Task 2 install revealed operations@knowledge-work-plugins v1.2.0 ships
9 skills (process-doc, process-optimization, change-request, …) and 0
lifecycle hooks — not /ops:* slash-commands. OPS4 resolved on install;
+OPS5 (boundary vs the 2 self-authored skills); skill "Границы" sharpened.
cspell-words += RACI/DMN/czlonkowski.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:33:51 +03:00
Дмитрий bcba3a153c docs(c10): implementation plan — C10 business-process tooling integration
9-task plan: install operations plugin, author process-modeling +
process-analysis skills, bootstrap docs/process/ + ADR-008, normative
sync (#51-54), map closure (3 nodes + 5 cross-refs). n8n-mcp DEFERRED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:33:12 +03:00
Дмитрий 3e389365d5 docs(c10): brainstorming spec — C10 business-process tooling integration
Design doc for populating the empty C10 «Бизнес-процессы (общее)» map
section. Approach 3 (hybrid + vendoring): operations plugin + 2
self-authored vendored skills (process-modeling, process-analysis) +
5 reuse cross-refs; n8n-mcp DEFERRED.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 04:33:12 +03:00
Дмитрий e29f38280e chore(deals): post-review cleanup — refresh stale §6.4 docs + mapper count assertion 2026-05-18 03:42:41 +03:00
Дмитрий 0f4f7161c8 feat(deals): Kanban — 5-column funnel (comment + test sync) 2026-05-18 03:42:41 +03:00
Дмитрий b4138bbc82 feat(deals): sweep 14->5 funnel slugs — controllers, mocks, stories, tests 2026-05-18 03:42:41 +03:00
Дмитрий 80c1cfd9e4 feat(deals): useStatusPill — add viewed/lost funnel slugs 2026-05-18 03:42:41 +03:00
Дмитрий 37518e6aa2 feat(deals): leadStatuses composable — 5-status funnel snapshot 2026-05-18 03:42:41 +03:00
Дмитрий a2b6293566 feat(deals): StatusRuToSlugMapper — remap supplier RU statuses to 5-slug funnel 2026-05-18 03:42:41 +03:00
Дмитрий 77cc535ab2 feat(deals): migration — remap deals.status + drop obsolete lead_statuses (14->5) 2026-05-18 03:42:41 +03:00
Дмитрий 5e73e0cf0f feat(deals): schema — lead_statuses funnel 14->5 (new/viewed/in_progress/won/lost) 2026-05-18 03:42:41 +03:00
Дмитрий 90be402106 test(deals): make 'one loadDeals' regression test non-vacuous (exercise page!=1)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 03:42:41 +03:00
Дмитрий e9ae43a81b test(deals): drop obsolete ids-based export tests from DealCreateTest (superseded by DealExportTest)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 03:42:40 +03:00
Дмитрий 78333da3d5 test(deals): rewrite DealsView spec for redesign; drop DealsViewRedesign spec + DEALS_TABS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 03:42:40 +03:00
Дмитрий fc7d34a131 fix(deals): DealsView — single reload per filter change, clear search debounce on unmount 2026-05-18 03:42:40 +03:00
Дмитрий efc6dbeb0a feat(deals): DealsView — lead-registry redesign (export panel, per-page, master-detail panel) 2026-05-18 03:42:40 +03:00
Дмитрий d78a72c286 refactor(deals): A9 review nits — drop duplicate spec, single Pinia, accurate comment 2026-05-18 03:42:40 +03:00
Дмитрий ba12fecc5c refactor(deals): extract DealDetailBody; DealDetailDrawer = overlay/inline wrapper 2026-05-18 03:42:40 +03:00
Дмитрий 74cc4408c7 feat(deals): DealsBulkBar — status-change only (drop export/delete/trash) 2026-05-18 03:42:40 +03:00
Дмитрий ccf194ed8a feat(deals): DealsTable — lead-registry columns (Телефон/Источник/Город/Статус/Напоминание/Комментарий/Поставлен) 2026-05-18 03:42:40 +03:00
Дмитрий a2bfeafcea feat(deals): DealsFilters — phone search + Status/Project/City selects 2026-05-18 03:42:40 +03:00
Дмитрий f98a3bf109 feat(deals): DealExportController -- export by delivery-date range, lead-registry columns 2026-05-18 03:42:40 +03:00
Дмитрий 3981fdcbf3 fix(deals): DealController@index — 422 on malformed received_from/received_to date params 2026-05-18 03:42:40 +03:00
Дмитрий 5234e46d92 feat(deals): DealController@index — received_at date-range filter + comment/city/signal_type/next_reminder_at 2026-05-18 03:42:40 +03:00
Дмитрий a3167d5783 feat(deals): mapApiDeal maps city/comment/signalType/receivedAt/nextReminderAt 2026-05-18 03:42:40 +03:00
Дмитрий 7bcfbf6bd4 feat(deals): api/deals — ApiDeal +4 fields, date-range list params, exportDealsByRange 2026-05-18 03:42:40 +03:00
Дмитрий ad2c8f1704 feat(deals): extend MockDeal with city/comment/signalType/receivedAt/nextReminderAt 2026-05-18 03:42:40 +03:00
Дмитрий 55a34af986 feat(deals): redesign groundwork — spec, plan, mockups + sidebar nav cleanup
Deals page redesign: design spec + implementation plan (Phase A page redesign,
Phase B 14->5 status funnel) + v8 HTML mockups (variants comparison + final).
AppSidebar: remove Импорт данных / Отчёты nav links (routes stay reachable by
direct URL); AppLayout.spec updated to 6 nav items. stylelint --fix on mockups;
cspell-words += deals-redesign terms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 03:42:39 +03:00
Дмитрий 54451d2ea6 feat(projects): RegionsBulkDialog — subject-level regions (89 RF subjects) #1426
Bulk regions dialog reworked from federal-district bitmask to subject/region
selection, consistent with ProjectDetailsDrawer/NewProjectDialog. Full-stack:
add_regions/remove_regions on projects.regions INT[], BulkProjectActionRequest
split validation, ProjectService model-instance update. federal-districts.ts
removed (zero consumers). +menuRepositionFix util for v-autocomplete menu.
phpstan-baseline: bump actingAs ignore count 14->15 (new validation test).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 03:41:46 +03:00
Дмитрий 9cf0f0c0c7 docs(adr): ADR-006 Decision-4 — Universal Icons icon-path boundary
Конфликт-аудит карты (docs/automation-graph.html) выявил
нерегламентированную границу: Universal Icons MCP #45 отдаёт raw SVG,
проектная конвенция (CTO-19) — lucide-vue-next + Vuetify IconSet.
ADR-006 регулировал #45 только против 21st logo_search.

- ADR-006: +Decision item 4 + Consequences bullet + Status Amended-строка
  (Lucide-иконки канонически через lucide-vue-next/Vuetify IconSet;
  raw-SVG MCP — только не-Lucide коллекции).
- CLAUDE.md v2.10 -> v2.11: §3.3 #45 +нота, §0 cross-ref Tooling v2.11, §9 +запись.
- Tooling Прил.Н v2.10 -> v2.11: §4.20 +UI3.

Pravila §13.2 / PSR_v1 — не затронуты (assess: §13.2 делегирует к ADR-006,
PSR_v1 R10.1 — role-registry). Счётчики инструментов без изменений (50).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:19:12 +03:00
Дмитрий de66b8b316 docs(map): refresh rule-node versions v1.24/v2.10/v3.10/v2.10 + tooling count (post-A11)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:59:13 +03:00
Дмитрий 008c8a3ad0 feat(map): A11 nodes — closes section «ML / AI-разработка»
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:42:18 +03:00
Дмитрий 18603f6881 docs(a11): register ml-ai-tooling category — promptfoo/Data Scientist skill/Jupyter MCP #48-50 (NUM1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:34:13 +03:00
Дмитрий d7aa5efe30 feat(a11): bootstrap docs/ml — README + promptfoo example + ADR-007
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:17:20 +03:00
Дмитрий 21f5047640 feat(a11): vendor Data Scientist skill into .claude/skills + lint-ignore (ML3)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:28 +03:00
Дмитрий a539b08499 feat(a11): add promptfoo as devDependency for LLM prompt eval (ML1)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:12:11 +03:00
107 changed files with 18847 additions and 2943 deletions
+224
View File
@@ -0,0 +1,224 @@
---
name: data-scientist
description: Expert data scientist for advanced analytics, machine learning, and statistical modeling. Handles complex data analysis, predictive modeling, and business intelligence.
---
## Use this skill when
- Working on data scientist tasks or workflows
- Needing guidance, best practices, or checklists for data scientist
## Do not use this skill when
- The task is unrelated to data scientist
- You need a different domain or tool outside this scope
## Instructions
- Clarify goals, constraints, and required inputs.
- Apply relevant best practices and validate outcomes.
- Provide actionable steps and verification.
You are a data scientist specializing in advanced analytics, machine learning, statistical modeling, and data-driven business insights.
## Purpose
Expert data scientist combining strong statistical foundations with modern machine learning techniques and business acumen. Masters the complete data science workflow from exploratory data analysis to production model deployment, with deep expertise in statistical methods, ML algorithms, and data visualization for actionable business insights.
## Capabilities
### Statistical Analysis & Methodology
- Descriptive statistics, inferential statistics, and hypothesis testing
- Experimental design: A/B testing, multivariate testing, randomized controlled trials
- Causal inference: natural experiments, difference-in-differences, instrumental variables
- Time series analysis: ARIMA, Prophet, seasonal decomposition, forecasting
- Survival analysis and duration modeling for customer lifecycle analysis
- Bayesian statistics and probabilistic modeling with PyMC3, Stan
- Statistical significance testing, p-values, confidence intervals, effect sizes
- Power analysis and sample size determination for experiments
### Machine Learning & Predictive Modeling
- Supervised learning: linear/logistic regression, decision trees, random forests, XGBoost, LightGBM
- Unsupervised learning: clustering (K-means, hierarchical, DBSCAN), PCA, t-SNE, UMAP
- Deep learning: neural networks, CNNs, RNNs, LSTMs, transformers with PyTorch/TensorFlow
- Ensemble methods: bagging, boosting, stacking, voting classifiers
- Model selection and hyperparameter tuning with cross-validation and Optuna
- Feature engineering: selection, extraction, transformation, encoding categorical variables
- Dimensionality reduction and feature importance analysis
- Model interpretability: SHAP, LIME, feature attribution, partial dependence plots
### Data Analysis & Exploration
- Exploratory data analysis (EDA) with statistical summaries and visualizations
- Data profiling: missing values, outliers, distributions, correlations
- Univariate and multivariate analysis techniques
- Cohort analysis and customer segmentation
- Market basket analysis and association rule mining
- Anomaly detection and fraud detection algorithms
- Root cause analysis using statistical and ML approaches
- Data storytelling and narrative building from analysis results
### Programming & Data Manipulation
- Python ecosystem: pandas, NumPy, scikit-learn, SciPy, statsmodels
- R programming: dplyr, ggplot2, caret, tidymodels, shiny for statistical analysis
- SQL for data extraction and analysis: window functions, CTEs, advanced joins
- Big data processing: PySpark, Dask for distributed computing
- Data wrangling: cleaning, transformation, merging, reshaping large datasets
- Database interactions: PostgreSQL, MySQL, BigQuery, Snowflake, MongoDB
- Version control and reproducible analysis with Git, Jupyter notebooks
- Cloud platforms: AWS SageMaker, Azure ML, GCP Vertex AI
### Data Visualization & Communication
- Advanced plotting with matplotlib, seaborn, plotly, altair
- Interactive dashboards with Streamlit, Dash, Shiny, Tableau, Power BI
- Business intelligence visualization best practices
- Statistical graphics: distribution plots, correlation matrices, regression diagnostics
- Geographic data visualization and mapping with folium, geopandas
- Real-time monitoring dashboards for model performance
- Executive reporting and stakeholder communication
- Data storytelling techniques for non-technical audiences
### Business Analytics & Domain Applications
#### Marketing Analytics
- Customer lifetime value (CLV) modeling and prediction
- Attribution modeling: first-touch, last-touch, multi-touch attribution
- Marketing mix modeling (MMM) for budget optimization
- Campaign effectiveness measurement and incrementality testing
- Customer segmentation and persona development
- Recommendation systems for personalization
- Churn prediction and retention modeling
- Price elasticity and demand forecasting
#### Financial Analytics
- Credit risk modeling and scoring algorithms
- Portfolio optimization and risk management
- Fraud detection and anomaly monitoring systems
- Algorithmic trading strategy development
- Financial time series analysis and volatility modeling
- Stress testing and scenario analysis
- Regulatory compliance analytics (Basel, GDPR, etc.)
- Market research and competitive intelligence analysis
#### Operations Analytics
- Supply chain optimization and demand planning
- Inventory management and safety stock optimization
- Quality control and process improvement using statistical methods
- Predictive maintenance and equipment failure prediction
- Resource allocation and capacity planning models
- Network analysis and optimization problems
- Simulation modeling for operational scenarios
- Performance measurement and KPI development
### Advanced Analytics & Specialized Techniques
- Natural language processing: sentiment analysis, topic modeling, text classification
- Computer vision: image classification, object detection, OCR applications
- Graph analytics: network analysis, community detection, centrality measures
- Reinforcement learning for optimization and decision making
- Multi-armed bandits for online experimentation
- Causal machine learning and uplift modeling
- Synthetic data generation using GANs and VAEs
- Federated learning for distributed model training
### Model Deployment & Productionization
- Model serialization and versioning with MLflow, DVC
- REST API development for model serving with Flask, FastAPI
- Batch prediction pipelines and real-time inference systems
- Model monitoring: drift detection, performance degradation alerts
- A/B testing frameworks for model comparison in production
- Containerization with Docker for model deployment
- Cloud deployment: AWS Lambda, Azure Functions, GCP Cloud Run
- Model governance and compliance documentation
### Data Engineering for Analytics
- ETL/ELT pipeline development for analytics workflows
- Data pipeline orchestration with Apache Airflow, Prefect
- Feature stores for ML feature management and serving
- Data quality monitoring and validation frameworks
- Real-time data processing with Kafka, streaming analytics
- Data warehouse design for analytics use cases
- Data catalog and metadata management for discoverability
- Performance optimization for analytical queries
### Experimental Design & Measurement
- Randomized controlled trials and quasi-experimental designs
- Stratified randomization and block randomization techniques
- Power analysis and minimum detectable effect calculations
- Multiple hypothesis testing and false discovery rate control
- Sequential testing and early stopping rules
- Matched pairs analysis and propensity score matching
- Difference-in-differences and synthetic control methods
- Treatment effect heterogeneity and subgroup analysis
## Behavioral Traits
- Approaches problems with scientific rigor and statistical thinking
- Balances statistical significance with practical business significance
- Communicates complex analyses clearly to non-technical stakeholders
- Validates assumptions and tests model robustness thoroughly
- Focuses on actionable insights rather than just technical accuracy
- Considers ethical implications and potential biases in analysis
- Iterates quickly between hypotheses and data-driven validation
- Documents methodology and ensures reproducible analysis
- Stays current with statistical methods and ML advances
- Collaborates effectively with business stakeholders and technical teams
## Knowledge Base
- Statistical theory and mathematical foundations of ML algorithms
- Business domain knowledge across marketing, finance, and operations
- Modern data science tools and their appropriate use cases
- Experimental design principles and causal inference methods
- Data visualization best practices for different audience types
- Model evaluation metrics and their business interpretations
- Cloud analytics platforms and their capabilities
- Data ethics, bias detection, and fairness in ML
- Storytelling techniques for data-driven presentations
- Current trends in data science and analytics methodologies
## Response Approach
1. **Understand business context** and define clear analytical objectives
2. **Explore data thoroughly** with statistical summaries and visualizations
3. **Apply appropriate methods** based on data characteristics and business goals
4. **Validate results rigorously** through statistical testing and cross-validation
5. **Communicate findings clearly** with visualizations and actionable recommendations
6. **Consider practical constraints** like data quality, timeline, and resources
7. **Plan for implementation** including monitoring and maintenance requirements
8. **Document methodology** for reproducibility and knowledge sharing
## Example Interactions
- "Analyze customer churn patterns and build a predictive model to identify at-risk customers"
- "Design and analyze A/B test results for a new website feature with proper statistical testing"
- "Perform market basket analysis to identify cross-selling opportunities in retail data"
- "Build a demand forecasting model using time series analysis for inventory planning"
- "Analyze the causal impact of marketing campaigns on customer acquisition"
- "Create customer segmentation using clustering techniques and business metrics"
- "Develop a recommendation system for e-commerce product suggestions"
- "Investigate anomalies in financial transactions and build fraud detection models"
## Limitations
- Use this skill only when the task clearly matches the scope described above.
- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.
- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.
---
> **Provenance (A11 «ML / AI-разработка»):** vendored into Лидерра 2026-05-17 from
> [`sickn33/antigravity-awesome-skills`](https://github.com/sickn33/antigravity-awesome-skills)
> `skills/data-scientist`. Skill content is licensed **CC BY 4.0**; repository
> tooling is MIT. Aggregator frontmatter (`risk`/`source`/`date_added`) dropped on
> vendor. See `docs/ml/README.md` for the A11 toolset and boundaries.
+62
View File
@@ -0,0 +1,62 @@
---
name: process-analysis
description: Анализ и оптимизация существующего бизнес-процесса — process discovery (реконструкция as-is процесса из кода Laravel и audit-логов), поиск узких мест, трассировка требование→процесс, метрики и KPI процесса. Триггеры — «проанализируй процесс», «где узкое место», «process discovery», «как устроен процесс X», «метрики процесса», «оптимизируй процесс». Раздел C10 карты «Бизнес-процессы (общее)».
---
# Process Analysis
Разбирает **существующий** бизнес-процесс: восстанавливает фактическую модель,
находит узкие места, считает метрики. Парный скил к `process-modeling` — тот
проектирует to-be, этот вскрывает as-is.
## Четыре режима
### 1. Process discovery — реконструкция as-is
Восстановить фактический процесс из артефактов кода (карта источников —
`references/discovery.md`): маршруты + контроллеры (точки входа), джобы/события
(асинхронные шаги), enum статусов + переходы (state-машина), audit-таблицы
(фактические следы), cron/scheduler (периодические шаги). Итог — модель,
которую можно передать `process-modeling` для отрисовки.
### 2. Bottleneck — поиск узких мест
Паттерны: ручной шаг между авто-шагами; шаг с ожиданием внешней системы; точка
сериализации (advisory-lock, `lockForUpdate`); N+1 внутри шага; ретраи/таймауты;
шаг с наибольшей долей исключений.
Граница: это **процессные** узкие места. Runtime/код-производительность —
`perf-analyzer` / скил `analysis:bottleneck-detect` (PA1).
### 3. Трассировка требование→процесс
Связать пункт ТЗ / `Открытые_вопросы` → шаги процесса → код (file:line) →
тесты. Выявить шаги без требования (скрытая логика) и требования без
реализации.
### 4. Метрики процесса
Определить KPI: throughput, cycle time, конверсия между статусами, доля
исключений, объём ручного труда. Числа берутся из БД через `Boost`, не
выдумываются.
Граница: продуктовые метрики — плагин `product-management` (`/metrics-review`).
## Рабочий процесс
1. Определить режим (1-4) по запросу.
2. Собрать факты из кода / БД / логов — никаких допущений без пинов (file:line).
3. Выдать находки: модель / список узких мест / матрицу трассировки / таблицу
метрик.
4. Рекомендации направить в `process-modeling` (to-be) или в задачи. Этот скил
код не правит.
## Границы
- **Проектирование to-be модели** — скил `process-modeling`.
- **Runtime / код-производительность** — `perf-analyzer`,
`analysis:bottleneck-detect` (PA1).
- **Продуктовые метрики** — плагин `product-management`.
- **Документ / change-request процесса** — плагин `operations`.
- **Генерик-методология оптимизации процесса** — скил `process-optimization`
плагина `operations`. Этот скил — про code-grounded discovery конкретного
процесса Лидерры (вскрытие as-is), не про общую методологию и не про
проектирование to-be.
@@ -0,0 +1,32 @@
# Process discovery — карта источников as-is процесса в Лидерре
Где в коде Лидерры лежат факты о фактическом бизнес-процессе.
## Источники
| Артефакт процесса | Где искать |
|---|---|
| Точки входа процесса | `app/routes/*.php` + `app/app/Http/Controllers/**` |
| Синхронные шаги | методы контроллеров + `app/app/Services/**` |
| Асинхронные шаги | `app/app/Jobs/**`, `app/app/Events/**` + listeners |
| State-машина | enum/константы статусов + `db/schema.sql` (воронка — 14 статусов) |
| Фактические следы выполнения | `audit_*` таблицы, `audit_chain_hash` (событийный лог) |
| Периодические шаги | `app/app/Console/**` + scheduler (`partitions:create-months` и пр.) |
| Бизнес-правила в шагах | `calc_lead_score` (SQL), `PricingTierResolver`, `LedgerService` |
## Метод
1. От **точки входа** (route → controller) пройти по вызовам до терминального
состояния.
2. Каждый `dispatch()` / событие — асинхронная ветка; проследить listener/job.
3. Переход статуса = ребро state-машины; собрать все переходы в автомат.
4. Свериться с **audit-логом**: фактический порядок событий в `audit_*` может
расходиться с «проектным» — расхождение само по себе находка.
5. Зафиксировать каждый шаг пином `file:line`; без пина — это допущение, не факт.
## Антипаттерны при discovery
- Принять «happy path» за весь процесс — исключения (catch, failed jobs,
таймауты) тоже шаги.
- Пропустить cron-шаги — они не видны из route-графа.
- Доверять имени метода вместо его тела.
+56
View File
@@ -0,0 +1,56 @@
---
name: process-modeling
description: Моделирование бизнес-процесса — BPMN 2.0 (пулы, дорожки, задачи, гейтвеи, события), карты процессов, customer-journey / value-stream, RACI-матрицы, state-машины. Триггеры — «смоделируй процесс», «нарисуй BPMN», «карта процесса», «swimlane / дорожки», «customer journey», «RACI», проектирование state-машины (воронка сделок, цепочка джобов). Раздел C10 карты «Бизнес-процессы (общее)».
---
# Process Modeling
Превращает словесное описание бизнес-процесса в формальную модель. Скил даёт
**нотацию и методологию** — рендер диаграмм делегируется скилу `mermaid`
(process-modeling не рендерит сам — конфликт-граница OPS1/BPMN1: mermaid
остаётся рендер-SoT).
## Когда какой артефакт
| Нужно | Артефакт |
|---|---|
| Кто-что-в-каком-порядке делает, с ветвлениями | BPMN 2.0 / swimlane |
| Сквозной поток end-to-end крупными блоками | Карта процесса (flowchart) |
| Опыт клиента/лида по этапам + точки боли | Customer-journey map |
| Поток создания ценности + потери и ожидания | Value-stream map |
| Распределение ответственности по шагам | RACI-матрица |
| Конечный автомат (статусы + переходы) | State-диаграмма |
## Рабочий процесс
1. **Собрать процесс** — уточнить: триггер (что запускает), участники (роли),
шаги по порядку, ветвления и условия, итог, исключения. Неясное — один
вопрос за раз.
2. **Выбрать артефакт** по таблице выше.
3. **Построить модель** в нотации (BPMN — см. `references/bpmn.md`).
4. **Отрендерить** — передать исходник скилу `mermaid`.
5. **Свериться** — модель не должна противоречить ТЗ / `db/schema.sql` /
`Открытые_вопросы`. Процесс вне ТЗ И не в реестре открытых вопросов —
hard-стоп (Pravila §7): не моделировать молча, поднять вопрос.
## BPMN 2.0 — ядро
Полная нотация и маппинг на mermaid — `references/bpmn.md`. Кратко:
- **Pool** — организация/система; **Lane** — роль внутри pool.
- **Task** — атомарное действие; **Sub-process** — свёрнутый под-поток.
- **Gateway** — ветвление: exclusive (XOR — один путь), parallel (AND — все
пути), inclusive (OR — один и более).
- **Event** — start / intermediate / end; типы: timer, message, error.
- **Sequence flow** — порядок внутри pool; **Message flow** — между pool'ами.
## Границы
- **Рендер диаграмм** — скил `mermaid` (C10 OPS1/BPMN1). Этот скил исходник не
рисует — отдаёт его mermaid.
- **DDD-границы доменных процессов** — скил `architecture-patterns` (bounded
context = граница бизнес-процесса).
- **Документ процесса, change-request, оптимизация** — плагин `operations`
(скилы `process-doc`, `change-request`, `process-optimization`).
- **Анализ as-is процесса** (discovery, узкие места) — скил `process-analysis`.
- Этот скил — про проектирование **to-be модели**, не про вскрытие as-is.
@@ -0,0 +1,56 @@
# BPMN 2.0 — справочник нотации и рендер в mermaid
mermaid не имеет нативного BPMN-рендера. BPMN-модель выражается через mermaid
`flowchart` (swimlane через `subgraph` = дорожки) или `stateDiagram-v2`.
## Элементы BPMN → mermaid
| BPMN | Смысл | mermaid-выражение |
|---|---|---|
| Pool / Lane | организация / роль | `subgraph Роль ... end` |
| Task | действие | прямоугольник `id[Текст]` |
| Sub-process | свёрнутый поток | `id[[Текст]]` |
| Start event | старт | `id((Старт))` |
| End event | конец | `id((Конец))` |
| Exclusive gateway (XOR) | один путь | ромб `id{Условие?}` + подписи на рёбрах |
| Parallel gateway (AND) | все пути | ромб `id{И}` с несколькими исходящими |
| Sequence flow | порядок | `-->` |
| Message flow | между pool | `-.->` |
## Шаблон swimlane
```mermaid
flowchart TD
subgraph Менеджер
A((Старт)) --> B[Принять лид]
B --> C{Лид валиден?}
end
subgraph Система
C -->|да| D[Создать сделку]
C -->|нет| E((Отклонён))
D --> F((Сделка создана))
end
```
## State-машина
Для конечных автоматов (воронка сделок — 14 статусов из `db/schema.sql`)
использовать `stateDiagram-v2`:
```mermaid
stateDiagram-v2
[*] --> new
new --> in_progress
in_progress --> won
in_progress --> lost
won --> [*]
lost --> [*]
```
Статус-слаги — из `db/schema.sql` (источник истины воронки), не выдумывать.
## Правила
- Один gateway — один вопрос; каждое исходящее ребро подписано условием.
- Каждый путь оканчивается end-событием (нет «висящих» задач).
- Исключения (timer/error) моделировать явно, не прятать в «happy path».
+1
View File
@@ -4,3 +4,4 @@ bin/
CLAUDE.md
.claude/skills/mermaid/
.claude/skills/ccpm/
.claude/skills/data-scientist/
+25 -8
View File
File diff suppressed because one or more lines are too long
@@ -63,10 +63,10 @@ class DashboardController extends Controller
$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')
// --- conversion: % статуса 'won' в окне ---
$curPaid = (clone $base())->where('status', 'won')
->whereBetween('received_at', [$windowStart, $now])->count();
$prevPaid = (clone $base())->where('status', 'paid')
$prevPaid = (clone $base())->where('status', 'won')
->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;
@@ -13,6 +13,7 @@ use App\Models\User;
use App\Services\SupplierResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
@@ -55,6 +56,11 @@ class DealController extends Controller
{
$tenantId = (int) $request->user()->tenant_id;
$request->validate([
'received_from' => 'nullable|date',
'received_to' => 'nullable|date',
]);
$statuses = (array) $request->query('status_in', []);
$projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null;
$managerId = $request->query('manager_id') !== null ? (int) $request->query('manager_id') : null;
@@ -64,6 +70,8 @@ class DealController extends Controller
$onlyDeleted = $request->boolean('only_deleted');
$countOnly = $request->boolean('count_only');
$cursorRaw = (string) $request->query('cursor', '');
$receivedFrom = trim((string) $request->query('received_from', ''));
$receivedTo = trim((string) $request->query('received_to', ''));
// Sprint 4 Phase A (audit O-perf-04): keyset pagination через cursor.
// При передаче cursor — keyset через PG row constructor (received_at, id) < (?, ?),
@@ -81,7 +89,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, $countOnly) {
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly, $receivedFrom, $receivedTo) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
@@ -92,8 +100,16 @@ class DealController extends Controller
// withTrashed() обходит global scope SoftDeletes; явный
// whereNotNull('deleted_at') фильтрует только удалённые.
$query = Deal::query()
->select('deals.*')
->addSelect(['next_reminder_at' => DB::table('reminders')
->select('remind_at')
->whereColumn('reminders.deal_id', 'deals.id')
->whereNull('reminders.completed_at')
->orderBy('remind_at')
->limit(1),
])
->where('tenant_id', $tenantId)
->with(['project:id,name', 'manager:id,email,first_name,last_name']);
->with(['project:id,name,signal_type', 'manager:id,email,first_name,last_name']);
if ($onlyDeleted) {
$query->withTrashed()->whereNotNull('deleted_at');
@@ -115,6 +131,13 @@ class DealController extends Controller
->orWhere('contact_name', 'ilike', $like);
});
}
if ($receivedFrom !== '') {
$query->where('received_at', '>=', Carbon::parse($receivedFrom)->startOfDay());
}
if ($receivedTo !== '') {
// received_to включительно — до конца дня (+1 день, строгое <).
$query->where('received_at', '<', Carbon::parse($receivedTo)->addDay()->startOfDay());
}
// Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
// и cursor/offset-логику (лёгкий запрос для бейджа в сайдбаре).
@@ -187,6 +210,12 @@ class DealController extends Controller
? ManagerController::formatInitials($d->manager->first_name, $d->manager->last_name, $d->manager->email)
: null,
'received_at' => $d->received_at?->toIso8601String(),
'comment' => $d->comment,
'city' => $d->city,
'project_signal_type' => $d->project?->signal_type,
'next_reminder_at' => $d->next_reminder_at
? Carbon::parse($d->next_reminder_at)->toIso8601String()
: null,
]),
'limit' => $limit,
'next_cursor' => $nextCursor,
@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Deal;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use OpenSpout\Common\Entity\Row;
use OpenSpout\Common\Entity\Style\Style;
@@ -16,44 +17,45 @@ use OpenSpout\Writer\XLSX\Writer as XlsxWriter;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Export сделок в CSV / XLSX через OpenSpout streaming.
* Экспорт сделок в CSV / XLSX через OpenSpout streaming.
*
* Извлечено из DealController (Sprint 3 Phase A, audit O-refactor-01).
* Редизайн «Сделки» (2026-05-17, Task A5): экспорт по ДИАПАЗОНУ ДАТ поставки
* (received_at), не по списку id. Окно задаётся received_from/received_to;
* оба опциональны (пусто = весь период). Колонки соответствуют таблице
* страницы (без чекбокса и без «Напоминание» экспорт = дамп лидов).
*
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe).
*
* J1 (Sprint 3F): auth:sanctum+tenant, tenant_id из auth()->user().
*
* O-perf-05: streaming устраняет memory pressure. PhpSpreadsheet строил
* полный объект .xlsx в памяти (для 10K сделок 100+ MB). OpenSpout пишет
* O-perf-05: streaming устраняет memory pressure. OpenSpout пишет
* в php://output постранично через Writer + Row::fromValues и chunkById(500)
* по сделкам пик памяти O(1) от размера экспорта.
*
* API контракт сохранён:
* 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.
*
* RLS-обёртка SET LOCAL внутри транзакции (PgBouncer-safe). Чужие id
* отфильтрует where(tenant_id) defense-in-depth.
*/
class DealExportController extends Controller
{
/** Заголовки таблицы — общие для CSV и XLSX. */
private const HEADERS = ['ID', мя', 'Телефон', 'Статус', 'Проект ID', 'Менеджер ID', 'Получено'];
/** Заголовки — общие для CSV и XLSX. */
private const HEADERS = ['Телефон', сточник', 'Город', 'Статус', 'Комментарий', 'Поставлен'];
/** signal_type → русская метка для колонки «Источник». */
private const SIGNAL_LABELS = ['call' => 'Звонки', 'site' => 'Сайт', 'sms' => 'СМС'];
public function export(Request $request): StreamedResponse
{
$validated = $request->validate([
'ids' => 'required|array|min:1|max:10000',
'ids.*' => 'integer|min:1',
'received_from' => 'nullable|date',
'received_to' => 'nullable|date',
'format' => 'nullable|string|in:csv,xlsx',
]);
$tenantId = (int) $request->user()->tenant_id;
$format = $validated['format'] ?? 'csv';
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
$from = isset($validated['received_from']) && $validated['received_from'] !== ''
? Carbon::parse($validated['received_from'])->startOfDay() : null;
$to = isset($validated['received_to']) && $validated['received_to'] !== ''
? Carbon::parse($validated['received_to'])->addDay()->startOfDay() : null;
$filename = 'deals_export_'.now()->format('Y-m-d').'.'.$format;
$headers = $format === 'xlsx'
? [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
@@ -64,14 +66,16 @@ class DealExportController extends Controller
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
];
return new StreamedResponse(function () use ($validated, $tenantId, $format) {
return new StreamedResponse(function () use ($tenantId, $format, $from, $to) {
// RLS-контекст должен быть установлен внутри транзакции на момент
// фактического SELECT. StreamedResponse callback вызывается уже
// после Laravel-response pipeline'а, поэтому открываем транзакцию
// прямо здесь.
DB::transaction(function () use ($validated, $tenantId, $format) {
DB::transaction(function () use ($tenantId, $format, $from, $to) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
$statusNames = DB::table('lead_statuses')->pluck('name_ru', 'slug');
$writer = $this->openWriter($format);
$writer->openToFile('php://output');
@@ -81,32 +85,41 @@ class DealExportController extends Controller
if ($format === 'xlsx') {
/** @var XlsxWriter $writer */
$writer->getCurrentSheet()->setName('Сделки');
$headerStyle = (new Style)->withFontBold(true);
$writer->addRow(Row::fromValuesWithStyle(self::HEADERS, $headerStyle));
$writer->addRow(Row::fromValuesWithStyle(self::HEADERS, (new Style)->withFontBold(true)));
} else {
$writer->addRow(Row::fromValues(self::HEADERS));
}
// chunkById(500) — keyset-friendly; в нашем DealsView это
// редкий тяжёлый action, экспортировать могут до 10K id.
Deal::query()
$query = Deal::query()
->where('tenant_id', $tenantId)
->whereIn('id', $validated['ids'])
->orderBy('id')
->chunkById(500, function ($deals) use ($writer) {
foreach ($deals as $deal) {
/** @var Deal $deal */
$writer->addRow(Row::fromValues([
$deal->id,
(string) ($deal->contact_name ?? ''),
(string) $deal->phone,
(string) $deal->status,
$deal->project_id,
$deal->manager_id ?? '',
$deal->received_at->toDateTimeString(),
]));
}
});
->with('project:id,name,signal_type')
->orderByDesc('received_at');
if ($from !== null) {
$query->where('received_at', '>=', $from);
}
if ($to !== null) {
$query->where('received_at', '<', $to);
}
// chunkById(500) — keyset-friendly; deals.id — BIGSERIAL (unique),
// корректно для чанкинга даже при партиционированной PK (id, received_at).
$query->chunkById(500, function ($deals) use ($writer, $statusNames) {
foreach ($deals as $deal) {
/** @var Deal $deal */
$signal = $deal->project?->signal_type;
$source = trim(($deal->project?->name ?? '—').' · '
.(self::SIGNAL_LABELS[$signal] ?? '—'));
$writer->addRow(Row::fromValues([
(string) $deal->phone,
$source,
(string) ($deal->city ?? ''),
(string) ($statusNames[$deal->status] ?? $deal->status),
(string) ($deal->comment ?? ''),
$deal->received_at?->toDateTimeString() ?? '',
]));
}
}, 'id');
$writer->close();
});
@@ -120,12 +133,10 @@ class DealExportController extends Controller
}
// CSV: ;-разделитель + UTF-8 BOM (Excel-friendly RU-локаль).
$options = new CsvOptions(
return new CsvWriter(new CsvOptions(
FIELD_DELIMITER: ';',
FIELD_ENCLOSURE: '"',
SHOULD_ADD_BOM: true,
);
return new CsvWriter($options);
));
}
}
@@ -32,10 +32,17 @@ class BulkProjectActionRequest extends FormRequest
'scope.filter.search' => ['nullable', 'string', 'max:255'],
];
if ($action === 'update_regions' || $action === 'update_days') {
$maxMask = $action === 'update_regions' ? 255 : 127;
$rules['add'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
$rules['remove'] = ['nullable', 'integer', 'min:0', "max:{$maxMask}"];
if ($action === 'update_regions') {
// Plan 6.5: субъект-уровневые коды 1..89 (см. resources/js/constants/regions.ts).
$rules['add_regions'] = ['nullable', 'array'];
$rules['add_regions.*'] = ['integer', 'between:1,89'];
$rules['remove_regions'] = ['nullable', 'array'];
$rules['remove_regions.*'] = ['integer', 'between:1,89'];
}
if ($action === 'update_days') {
$rules['add'] = ['nullable', 'integer', 'min:0', 'max:127'];
$rules['remove'] = ['nullable', 'integer', 'min:0', 'max:127'];
}
if ($action === 'update_limit') {
@@ -105,7 +105,7 @@ final class HistoricalImportService
}
/**
* Маппит статус: каноническая таблица §6.4 tenant-override fallback 'new'.
* Маппит статус: StatusRuToSlugMapper tenant-override fallback 'new'.
* Неизвестный статус инкрементит счётчик в $unknown по ссылке.
*
* @param array<string, string> $overrides
@@ -5,29 +5,36 @@ declare(strict_types=1);
namespace App\Services\Import;
/**
* Маппинг русских названий статусов воронки в slug (ТЗ §6.4).
* Маппинг русских названий статусов (старые 14 названий поставщика + новые 5)
* в slug 5-статусной воронки (редизайн 2026-05-17).
*
* Чистый сервис без зависимостей. Tenant-специфичные переопределения
* неизвестных статусов накладываются вызывающим кодом (HistoricalImportService).
*/
class StatusRuToSlugMapper
{
/** @var array<string, string> Канонический маппинг ТЗ §6.4 (14 статусов воронки). */
/** @var array<string, string> Русские названия → 5 slug'ов воронки (редизайн 2026-05-17). */
private const STATUS_RU_TO_SLUG = [
'Новые' => 'new',
// Новые названия 5-статусной воронки.
'Новая сделка' => 'new',
'Просмотрено' => 'viewed',
'Проработан' => 'worked',
'База' => 'base',
'Недозвон' => 'missed',
'Переговоры' => 'negotiations',
'Ожидаем оплаты' => 'waiting_payment',
артнерка' => 'partnership',
'Оплачено' => 'paid',
'Закрыто и не реализовано' => 'closed',
'Тест драйв' => 'test_drive',
'Горячий' => 'hot',
'На замену' => 'replacement',
'Конечный недозвон' => 'final_missed',
'В работе' => 'in_progress',
'Сделка' => 'won',
'Не реализовано' => 'lost',
// Старые 14 названий поставщика → новые slug'и (исторический CSV-импорт).
'Новые' => 'new',
роработан' => 'in_progress',
'База' => 'in_progress',
'Недозвон' => 'in_progress',
'Переговоры' => 'in_progress',
'Ожидаем оплаты' => 'in_progress',
'Партнерка' => 'in_progress',
'Оплачено' => 'won',
'Закрыто и не реализовано' => 'lost',
'Тест драйв' => 'in_progress',
'Горячий' => 'in_progress',
'На замену' => 'in_progress',
'Конечный недозвон' => 'in_progress',
];
/**
@@ -39,7 +46,8 @@ class StatusRuToSlugMapper
}
/**
* Полная каноническая таблица для UI wizard'а (показать варианты).
* Полная таблица соответствия: русское название slug 5-статусной воронки
* (18 ключей старые и новые названия схлопываются в 5 slug'ов).
*
* @return array<string, string>
*/
+30 -11
View File
@@ -115,21 +115,40 @@ class ProjectService
}
/**
* LEGACY (Plan 6): обновляет только bitmask `region_mask` федеральных округов.
* После Plan 6 источник истины региональной фильтрации `regions` INT[];
* outbound SyncSupplierProjectsJob читает `regions[]`, НЕ `region_mask`. Значит
* этот bulk-action на реальную фильтрацию у поставщика не влияет. Субъект-уровневый
* bulk-edit `regions[]` запланирован в Plan 6.5 (spec §13 out of scope C9).
* Plan 6.5: субъект-уровневый bulk-edit `regions` INT[].
*
* Для каждого проекта: regions := unique(regions add_regions) \ remove_regions,
* отсортировано по возрастанию. `regions[]` источник истины региональной
* фильтрации с Plan 6 (outbound SyncSupplierProjectsJob читает именно его).
* Legacy `region_mask` здесь не трогается как и в одиночном PATCH
* /api/projects/{id}; его удаление Plan 6.5 cleanup.
*
* NB: проект с regions=[] («вся РФ») при add_regions сужается до выбранных
* субъектов это осознанное действие оператора bulk-диалога.
*
* Обновление идёт через model-инстанс (не query-builder mass update): каст
* PostgresIntArray::set() сериализует PHP-массив в PG-литерал `{1,2,3}`, а
* mass update каст не применяет. count BULK_MAX (500) допустимо.
*/
private function bulkUpdateRegions($query, array $payload): array
{
$add = (int) ($payload['add'] ?? 0);
$remove = (int) ($payload['remove'] ?? 0);
$add = array_map('intval', $payload['add_regions'] ?? []);
$remove = array_map('intval', $payload['remove_regions'] ?? []);
// region_mask = (region_mask | add) & ~remove, clamped to 8 bits (0255)
$updated = $query->update([
'region_mask' => \DB::raw("(region_mask | {$add}) & ~{$remove} & 255"),
]);
if ($add === [] && $remove === []) {
return ['updated' => 0, 'skipped' => [], 'warnings' => []];
}
$projects = (clone $query)->get(['id', 'regions']);
$updated = 0;
foreach ($projects as $project) {
$next = array_values(array_unique([...($project->regions ?? []), ...$add]));
$next = array_values(array_diff($next, $remove));
sort($next);
$project->update(['regions' => $next]);
$updated++;
}
return ['updated' => $updated, 'skipped' => [], 'warnings' => []];
}
@@ -12,8 +12,8 @@ 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.
* в строку «Не назначен». «Оплачено» = status='won' (won-статус воронки, как
* в DashboardController). Конверсия = won / total * 100, округление до 0.1.
*
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted
* (deleted_at IS NULL) и тестовые (is_test=false) сделки. RLS-обёртка
@@ -48,7 +48,7 @@ class ManagersSummaryProvider implements ReportDataProvider
"deals.manager_id,
users.first_name, users.last_name, users.email,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE deals.status = 'paid') AS paid"
COUNT(*) FILTER (WHERE deals.status = 'won') AS paid"
)
->get();
@@ -12,8 +12,8 @@ use Illuminate\Support\Facades\DB;
* sources_summary агрегат сделок по источнику (utm_source) за период (audit F1).
*
* Группировка по deals.utm_source; сделки без метки (NULL/пусто) сводятся в
* строку «Прямые / без метки». «Оплачено» = status='paid'. Конверсия =
* paid / total * 100, округление до 0.1.
* строку «Прямые / без метки». «Оплачено» = status='won'. Конверсия =
* won / total * 100, округление до 0.1.
*
* parameters: date_from, date_to (Y-m-d). Исключаются soft-deleted и тестовые
* сделки. RLS-обёртка SET LOCAL app.current_tenant_id паттерн DealsExportProvider.
@@ -45,7 +45,7 @@ class SourcesSummaryProvider implements ReportDataProvider
->selectRaw(
"utm_source,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'paid') AS paid"
COUNT(*) FILTER (WHERE status = 'won') AS paid"
)
->get();
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Воронка статусов 14 5 (редизайн «Сделки» 2026-05-17).
*
* Новые 5: new / viewed / in_progress / won / lost. Slug'и `new` и `viewed`
* сохраняются (RouteSupplierLeadJob / DealController@store default'ят 'new').
* Ремап старых 14 5 в deals.status и import_unknown_statuses.mapped_to_slug
* перед DELETE устаревших lead_statuses (FK-safe). tenant_status_overrides
* со старыми slug'ами удаляются (кастомные ярлыки схлопнутых статусов
* обсолетны + исключает PK-коллизию при ремапе).
*
* На migrate:fresh schema.sql уже сеет 5 UPDATE/DELETE здесь no-op.
* down() необратима (схлопывание lossy).
*
* Спека: docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md §3.
*/
return new class extends Migration
{
/** Старый slug → новый. new/viewed не меняются (отсутствуют в карте). */
private const REMAP = [
'worked' => 'in_progress', 'base' => 'in_progress', 'missed' => 'in_progress',
'negotiations' => 'in_progress', 'waiting_payment' => 'in_progress',
'partnership' => 'in_progress', 'test_drive' => 'in_progress', 'hot' => 'in_progress',
'replacement' => 'in_progress', 'final_missed' => 'in_progress',
'paid' => 'won', 'closed' => 'lost',
];
private const KEEP = ['new', 'viewed', 'in_progress', 'won', 'lost'];
public function up(): void
{
DB::transaction(function () {
// 1) Новые slug'и обязаны существовать до ремапа FK-ссылок.
DB::table('lead_statuses')->upsert([
['slug' => 'new', 'name_ru' => 'Новая сделка', 'is_system' => true, 'sort_order' => 1, 'color_hex' => '#3B82F6'],
['slug' => 'viewed', 'name_ru' => 'Просмотрено', 'is_system' => true, 'sort_order' => 2, 'color_hex' => '#8B5CF6'],
['slug' => 'in_progress', 'name_ru' => 'В работе', 'is_system' => true, 'sort_order' => 3, 'color_hex' => '#06B6D4'],
['slug' => 'won', 'name_ru' => 'Сделка', 'is_system' => true, 'sort_order' => 4, 'color_hex' => '#10B981'],
['slug' => 'lost', 'name_ru' => 'Не реализовано', 'is_system' => true, 'sort_order' => 5, 'color_hex' => '#6B7280'],
], ['slug'], ['name_ru', 'is_system', 'sort_order', 'color_hex']);
// 2) Ремап ссылок на старые slug'и.
foreach (self::REMAP as $old => $new) {
DB::table('deals')->where('status', $old)->update(['status' => $new]);
DB::table('import_unknown_statuses')->where('mapped_to_slug', $old)->update(['mapped_to_slug' => $new]);
}
// 3) Обсолетные кастомные ярлыки статусов — удалить (FK на lead_statuses).
DB::table('tenant_status_overrides')->whereNotIn('status_slug', self::KEEP)->delete();
// 4) Удалить устаревшие статусы (все FK-ссылки перенаправлены).
DB::table('lead_statuses')->whereNotIn('slug', self::KEEP)->delete();
});
}
public function down(): void
{
throw new RuntimeException('Воронка 14→5 необратима (схлопывание статусов lossy).');
}
};
+65 -5
View File
@@ -54,12 +54,36 @@ parameters:
count: 1
path: app/Http/Controllers/Api/AdminTenantsController.php
-
message: '#^Access to an undefined property App\\Models\\Deal\:\:\$next_reminder_at\.$#'
identifier: property.notFound
count: 1
path: app/Http/Controllers/Api/DealController.php
-
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 5
path: app/Http/Controllers/Api/DealController.php
-
message: '#^Expression on left side of \?\? is not nullable\.$#'
identifier: nullCoalesce.expr
count: 1
path: app/Http/Controllers/Api/DealExportController.php
-
message: '#^Using nullsafe method call on non\-nullable type Illuminate\\Support\\Carbon\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Http/Controllers/Api/DealExportController.php
-
message: '#^Using nullsafe property access "\?\-\>name" on left side of \?\? is unnecessary\. Use \-\> instead\.$#'
identifier: nullsafe.neverNull
count: 1
path: app/Http/Controllers/Api/DealExportController.php
-
message: '#^Cannot call method toIso8601String\(\) on null\.$#'
identifier: method.nonObject
@@ -411,7 +435,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 14
count: 15
path: tests/Feature/Api/ProjectBulkActionsTest.php
-
@@ -837,7 +861,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 25
count: 10
path: tests/Feature/DealCreateTest.php
-
@@ -882,6 +906,42 @@ parameters:
count: 2
path: tests/Feature/DealDestroyTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 5
path: tests/Feature/DealExportTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 6
path: tests/Feature/DealExportTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#'
identifier: property.notFound
count: 1
path: tests/Feature/DealExportTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#'
identifier: method.notFound
count: 1
path: tests/Feature/DealExportTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:post\(\)\.$#'
identifier: method.notFound
count: 4
path: tests/Feature/DealExportTest.php
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#'
identifier: method.notFound
count: 2
path: tests/Feature/DealExportTest.php
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$manager\.$#'
identifier: property.notFound
@@ -897,7 +957,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$project\.$#'
identifier: property.notFound
count: 32
count: 38
path: tests/Feature/DealIndexTest.php
-
@@ -909,7 +969,7 @@ parameters:
-
message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#'
identifier: property.notFound
count: 36
count: 41
path: tests/Feature/DealIndexTest.php
-
@@ -927,7 +987,7 @@ parameters:
-
message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#'
identifier: method.notFound
count: 24
count: 29
path: tests/Feature/DealIndexTest.php
-
+29
View File
@@ -130,6 +130,26 @@ export async function exportDealsXlsx(payload: Omit<ExportDealsPayload, 'format'
return data;
}
export interface ExportDealsByRangePayload {
tenant_id: number;
received_from?: string;
received_to?: string;
format: 'csv' | 'xlsx';
}
/**
* Экспорт сделок по диапазону дат поставки. format='xlsx' → Blob, 'csv' → строка.
*/
export async function exportDealsByRange(payload: ExportDealsByRangePayload): Promise<Blob | string> {
await ensureCsrfCookie();
if (payload.format === 'xlsx') {
const { data } = await apiClient.post<Blob>('/api/deals/export', payload, { responseType: 'blob' });
return data;
}
const { data } = await apiClient.post<string>('/api/deals/export', payload, { responseType: 'text' });
return data;
}
export interface ApiDeal {
id: number;
tenant_id: number;
@@ -142,6 +162,10 @@ export interface ApiDeal {
manager_name: string | null;
manager_initials: string | null;
received_at: string | null;
comment: string | null;
city: string | null;
project_signal_type: string | null;
next_reminder_at: string | null;
}
export interface ApiDealEvent {
@@ -175,6 +199,9 @@ export interface ListDealsParams {
projectId?: number;
managerId?: number;
search?: string;
/** Диапазон дат поставки (received_at). ISO-дата 'YYYY-MM-DD'. */
receivedFrom?: string;
receivedTo?: string;
limit?: number;
offset?: number;
/** «Корзина» — вернуть ТОЛЬКО soft-deleted сделки. */
@@ -196,6 +223,8 @@ export async function listDeals(params: ListDealsParams): Promise<ListDealsRespo
project_id: params.projectId,
manager_id: params.managerId,
search: params.search,
received_from: params.receivedFrom,
received_to: params.receivedTo,
limit: params.limit,
offset: params.offset,
only_deleted: params.onlyDeleted ? 'true' : undefined,
@@ -24,11 +24,11 @@ import FunnelChart from './FunnelChart.vue';
</v-app>
</Variant>
<Variant title="концентрация на 'Оплачено'">
<Variant title="концентрация на 'Сделка'">
<v-app>
<v-main class="story-pane">
<v-container>
<FunnelChart :counts="{ paid: 100, new: 5, viewed: 5, worked: 5 }" />
<FunnelChart :counts="{ won: 100, new: 5, viewed: 5, in_progress: 5 }" />
</v-container>
</v-main>
</v-app>
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Воронка распределения лидов по 14 статусам.
* Воронка распределения лидов по 5 статусам воронки.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_dashboard.html секция .panel
* с #funnel-title (segmented bar + funnel-list).
@@ -13,7 +13,7 @@
* Рендер:
* 1. Segmented horizontal bar — каждый сегмент пропорционален count'у статуса
* и закрашен colorHex из lead_statuses.
* 2. funnel-list — 14 строк с цветным dot + name + count, отсортированы по
* 2. funnel-list — 5 строк с цветным dot + name + count, отсортированы по
* убыванию count'а (как в handoff).
*/
import { computed } from 'vue';
@@ -26,23 +26,14 @@ interface Props {
// Default counts инлайнятся в withDefaults — Vue SFC compiler требует чтобы
// factory-функция в withDefaults не реферировала модуль-уровневые const'ы
// (checkInvalidScopeReference). Mock-распределение ~247 лидов по 14 статусам.
// (checkInvalidScopeReference). Mock-распределение ~190 лидов по 5 статусам.
const props = withDefaults(defineProps<Props>(), {
counts: () => ({
new: 18,
viewed: 14,
worked: 22,
base: 9,
missed: 16,
negotiations: 11,
waiting_payment: 7,
partnership: 4,
paid: 45,
closed: 3,
test_drive: 38,
hot: 5,
replacement: 5,
final_missed: 39,
new: 24,
viewed: 18,
in_progress: 96,
won: 41,
lost: 11,
}),
title: 'Воронка',
});
@@ -0,0 +1,340 @@
<script setup lang="ts">
/**
* Тело панели деталей сделки (hero + параметры + комментарий + напоминания +
* timeline). Извлечено из DealDetailDrawer (редизайн 2026-05-17) — общее тело
* для overlay-дровера (Канбан) и inline-панели master-detail («Сделки»).
*
* Backend: GET /api/deals/{id}, PATCH /api/deals/{id}, GET /api/deals/{id}/events.
*/
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import type { MockDeal } from '../../composables/mockDeals';
import { type DealEvent } from '../../composables/mockDealEvents';
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
import * as dealsApi from '../../api/deals';
import * as remindersApi from '../../api/reminders';
import type { ApiReminder } from '../../api/reminders';
import { useLeadStatusesStore } from '../../stores/leadStatuses';
import DealDetailHero from './DealDetailHero.vue';
import DealDetailTimeline from './DealDetailTimeline.vue';
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
const leadStatusesStore = useLeadStatusesStore();
const props = defineProps<{
deal: MockDeal | null;
tenantId?: number;
}>();
const emit = defineEmits<{ close: [] }>();
const status = computed(() => {
if (!props.deal) return null;
return leadStatusesStore.findBySlug(props.deal.statusSlug);
});
function formatCost(cost: number): string {
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
}
const events = ref<DealEvent[]>([]);
const eventsLoading = ref(false);
const eventsFetchError = ref(false);
const commentDraft = ref<string>('');
const commentSaving = ref(false);
const commentSaveError = ref(false);
const commentToastOpen = ref(false);
const commentToastText = ref('');
const reminders = ref<ApiReminder[]>([]);
const remindersLoading = ref(false);
const reminderDialogOpen = ref(false);
async function loadReminders() {
if (!props.deal || !props.tenantId) {
reminders.value = [];
return;
}
remindersLoading.value = true;
try {
const res = await remindersApi.listReminders({ filter: 'active', dealId: props.deal.id });
reminders.value = res.items;
} catch {
reminders.value = [];
} finally {
remindersLoading.value = false;
}
}
async function completeReminderInDrawer(id: number) {
try {
await remindersApi.completeReminder(id);
reminders.value = reminders.value.filter((r) => r.id !== id);
} catch {
/* silent */
}
}
function onReminderSaved() {
void loadReminders();
}
function formatReminderTime(iso: string | null): string {
if (!iso) return '—';
const ms = new Date(iso).getTime() - Date.now();
const min = Math.round(Math.abs(ms) / 60_000);
const future = ms > 0;
if (min < 60) return future ? `через ${min} мин` : `${min} мин назад`;
const hr = Math.round(min / 60);
if (hr < 24) return future ? `через ${hr} ч` : `${hr} ч назад`;
const days = Math.round(hr / 24);
return future ? `через ${days} д` : `${days} д назад`;
}
async function loadEvents() {
if (!props.deal || !props.tenantId) {
events.value = [];
commentDraft.value = '';
return;
}
eventsLoading.value = true;
eventsFetchError.value = false;
try {
const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
events.value = res.events.map((e) => mapApiDealEvent(e));
commentDraft.value = res.deal.comment ?? '';
} catch {
eventsFetchError.value = true;
events.value = [];
commentDraft.value = '';
} finally {
eventsLoading.value = false;
}
}
async function saveComment() {
if (!props.deal || !props.tenantId) return;
commentSaving.value = true;
commentSaveError.value = false;
try {
await dealsApi.updateDeal(props.deal.id, {
tenant_id: props.tenantId,
comment: commentDraft.value || null,
});
commentToastText.value = 'Комментарий сохранён.';
commentToastOpen.value = true;
await loadEvents();
} catch {
commentSaveError.value = true;
commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
commentToastOpen.value = true;
} finally {
commentSaving.value = false;
}
}
// Загрузка при появлении/смене сделки. Компонент смонтирован всегда — тело (<div v-if="deal">) рендерится только при deal != null.
watch(
() => [props.deal?.id, props.tenantId] as const,
() => {
if (props.deal) {
loadEvents();
void loadReminders();
}
},
{ immediate: true },
);
defineExpose({
events, eventsLoading, eventsFetchError, loadEvents,
commentDraft, commentSaving, commentSaveError, commentToastOpen, commentToastText, saveComment,
});
</script>
<template>
<div v-if="deal" class="drawer-content">
<DealDetailHero :deal="deal" :status="status" @close="emit('close')" />
<v-divider />
<section class="section pa-5">
<h3 class="section-title text-subtitle-2 mb-3">Параметры</h3>
<dl class="params">
<div class="param">
<dt class="text-caption text-medium-emphasis">Проект</dt>
<dd class="text-body-2">{{ deal.project }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
<dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Менеджер</dt>
<dd class="text-body-2">
<v-avatar size="20" color="secondary" class="mr-1">
<span class="text-caption">{{ deal.manager.initials }}</span>
</v-avatar>
{{ deal.manager.name }}
</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Источник</dt>
<dd class="text-body-2 link">Я.Директ landing-1</dd>
</div>
</dl>
</section>
<v-divider />
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
<v-textarea
v-model="commentDraft"
placeholder="Заметка менеджера…"
variant="outlined"
density="comfortable"
auto-grow
rows="3"
hide-details
counter="5000"
data-testid="comment-textarea"
/>
<div class="d-flex ga-2 mt-2 justify-end">
<v-btn
:loading="commentSaving"
color="primary"
size="small"
prepend-icon="mdi-content-save-outline"
data-testid="save-comment-btn"
@click="saveComment"
>
Сохранить
</v-btn>
</div>
</section>
<v-divider v-if="tenantId" />
<section v-if="tenantId && deal" class="section pa-5" data-testid="reminders-section">
<div class="d-flex justify-space-between align-center mb-3">
<h3 class="section-title text-subtitle-2 mb-0">Напоминания</h3>
<v-btn
size="x-small"
variant="text"
prepend-icon="mdi-plus"
data-testid="add-reminder-btn"
@click="reminderDialogOpen = true"
>
Создать
</v-btn>
</div>
<div v-if="reminders.length === 0 && !remindersLoading" class="text-caption text-medium-emphasis">
Нет активных напоминаний.
</div>
<ul v-else class="reminders-list">
<li v-for="r in reminders" :key="r.id" class="reminder-row" data-testid="drawer-reminder-item">
<v-btn
icon="mdi-check-circle-outline"
size="x-small"
variant="text"
density="comfortable"
:data-testid="`drawer-complete-${r.id}`"
@click="completeReminderInDrawer(r.id)"
/>
<div class="reminder-body">
<div class="reminder-text">{{ r.text || 'Без описания' }}</div>
<div class="reminder-meta text-caption text-medium-emphasis">
{{ formatReminderTime(r.remind_at) }}
</div>
</div>
</li>
</ul>
</section>
<v-divider v-if="tenantId && deal" />
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
<v-snackbar
v-model="commentToastOpen"
:timeout="3000"
:color="commentSaveError ? 'warning' : undefined"
data-testid="comment-toast"
location="bottom right"
>
{{ commentToastText }}
</v-snackbar>
<ReminderDialog
v-if="tenantId && deal"
v-model="reminderDialogOpen"
:deal-id="deal.id"
@saved="onReminderSaved"
/>
</div>
</template>
<style scoped>
.drawer-content {
display: flex;
flex-direction: column;
}
.section-title {
font-weight: 600;
color: #081319;
}
.params {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 12px;
margin: 0;
}
.param dt {
font-size: 11px;
margin-bottom: 2px;
}
.param dd {
margin: 0;
color: #081319;
}
.param .link {
color: #0f6e56;
cursor: pointer;
}
.param .link:hover {
text-decoration: underline;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
.reminders-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.reminder-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid #e8e3d6;
border-radius: 6px;
background: #fdfaf3;
}
.reminder-body {
flex: 1;
min-width: 0;
}
.reminder-text {
font-size: 13px;
line-height: 1.4;
color: #081319;
}
.reminder-meta {
margin-top: 2px;
}
</style>
@@ -7,7 +7,7 @@ const open1 = ref(true);
const open2 = ref(true);
const dealNew = MOCK_DEALS.find((d) => d.statusSlug === 'new')!;
const dealPaid = MOCK_DEALS.find((d) => d.statusSlug === 'paid')!;
const dealWon = MOCK_DEALS.find((d) => d.statusSlug === 'won')!;
</script>
<template>
@@ -20,10 +20,10 @@ const dealPaid = MOCK_DEALS.find((d) => d.statusSlug === 'paid')!;
</v-app>
</Variant>
<Variant title="paid status">
<Variant title="won status">
<v-app>
<v-main class="story-main">
<DealDetailDrawer v-model:open="open2" :deal="dealPaid" />
<DealDetailDrawer v-model:open="open2" :deal="dealWon" />
</v-main>
</v-app>
</Variant>
@@ -1,43 +1,23 @@
<script setup lang="ts">
/**
* Правая панель с деталями сделки. Открывается при click на строку в DealsView
* или на карточку в KanbanView.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_deal_card.html.
* MVP: hero (имя + телефон + статус-chip + close), параметры (Проект/Стоимость/
* Источник/Email), Activity timeline (5-7 событий).
*
* Не входит в этот коммит:
* - Редактирование параметров (input-fields + save).
* - Смена статуса через dropdown (на Канбане через DnD).
* - Tag management, manager assignment, reminders, comment/templates
* отдельные секции, отдельные коммиты.
*
* Backend:
* - GET /api/deals/{id} full detail with events.
* - PATCH /api/deals/{id} частичное обновление полей.
* - GET /api/deals/{id}/events `activity_log` фильтр по deal_id.
* Обёртка панели деталей сделки. `inline=false` (по умолчанию) overlay
* v-navigation-drawer (Канбан). `inline=true` боковая панель master-detail
* для страницы «Сделки» (список сжимается, панель встаёт рядом, не перекрывает).
* Тело общий DealDetailBody.vue.
*/
import { computed, defineAsyncComponent, ref, watch } from 'vue';
import { computed } from 'vue';
import type { MockDeal } from '../../composables/mockDeals';
import { type DealEvent } from '../../composables/mockDealEvents';
import { mapApiDealEvent } from '../../composables/dealsApiMapper';
import * as dealsApi from '../../api/deals';
import * as remindersApi from '../../api/reminders';
import type { ApiReminder } from '../../api/reminders';
import { useLeadStatusesStore } from '../../stores/leadStatuses';
import DealDetailHero from './DealDetailHero.vue';
import DealDetailTimeline from './DealDetailTimeline.vue';
// Sprint 2 Phase B / O-perf-06: ReminderDialog гейтится через v-model chunk-split.
const ReminderDialog = defineAsyncComponent(() => import('../reminders/ReminderDialog.vue'));
import DealDetailBody from './DealDetailBody.vue';
const leadStatusesStore = useLeadStatusesStore();
const props = defineProps<{
open: boolean;
deal: MockDeal | null;
tenantId?: number;
}>();
const props = withDefaults(
defineProps<{
open: boolean;
deal: MockDeal | null;
tenantId?: number;
inline?: boolean;
}>(),
{ inline: false },
);
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
@@ -46,265 +26,24 @@ const drawerOpen = computed({
set: (v) => emit('update:open', v),
});
const status = computed(() => {
if (!props.deal) return null;
return leadStatusesStore.findBySlug(props.deal.statusSlug);
});
function formatCost(cost: number): string {
return new Intl.NumberFormat('ru-RU').format(cost) + ' ₽';
function close() {
emit('update:open', false);
}
// Activity timeline: при наличии tenant_id делаем GET /api/deals/{id} и
// показываем реальные events. На fail / без tenant_id events пуст + eventsFetchError.
const events = ref<DealEvent[]>([]);
const eventsLoading = ref(false);
const eventsFetchError = ref(false);
// Comment editor редактирование текущего комментария сделки.
const commentDraft = ref<string>('');
const commentSaving = ref(false);
const commentSaveError = ref(false);
const commentToastOpen = ref(false);
const commentToastText = ref('');
// Reminders на сделку отдельная секция с inline-create + список.
const reminders = ref<ApiReminder[]>([]);
const remindersLoading = ref(false);
const reminderDialogOpen = ref(false);
async function loadReminders() {
if (!props.deal || !props.tenantId) {
reminders.value = [];
return;
}
remindersLoading.value = true;
try {
const res = await remindersApi.listReminders({ filter: 'active', dealId: props.deal.id });
reminders.value = res.items;
} catch {
reminders.value = [];
} finally {
remindersLoading.value = false;
}
}
async function completeReminderInDrawer(id: number) {
try {
await remindersApi.completeReminder(id);
reminders.value = reminders.value.filter((r) => r.id !== id);
} catch {
/* silent */
}
}
function onReminderSaved() {
void loadReminders();
}
function formatReminderTime(iso: string | null): string {
if (!iso) return '—';
const ms = new Date(iso).getTime() - Date.now();
const min = Math.round(Math.abs(ms) / 60_000);
const future = ms > 0;
if (min < 60) return future ? `через ${min} мин` : `${min} мин назад`;
const hr = Math.round(min / 60);
if (hr < 24) return future ? `через ${hr} ч` : `${hr} ч назад`;
const days = Math.round(hr / 24);
return future ? `через ${days} д` : `${days} д назад`;
}
async function loadEvents() {
if (!props.deal || !props.tenantId) {
events.value = [];
commentDraft.value = '';
return;
}
eventsLoading.value = true;
eventsFetchError.value = false;
try {
const res = await dealsApi.getDeal(props.deal.id, props.tenantId);
events.value = res.events.map((e) => mapApiDealEvent(e));
commentDraft.value = res.deal.comment ?? '';
} catch {
eventsFetchError.value = true;
events.value = [];
commentDraft.value = '';
} finally {
eventsLoading.value = false;
}
}
async function saveComment() {
if (!props.deal || !props.tenantId) return;
commentSaving.value = true;
commentSaveError.value = false;
try {
await dealsApi.updateDeal(props.deal.id, {
tenant_id: props.tenantId,
comment: commentDraft.value || null,
});
commentToastText.value = 'Комментарий сохранён.';
commentToastOpen.value = true;
// Reload events чтобы показать новый deal.commented в timeline.
await loadEvents();
} catch {
commentSaveError.value = true;
commentToastText.value = 'Не удалось сохранить — попробуйте позже.';
commentToastOpen.value = true;
} finally {
commentSaving.value = false;
}
}
// Fetch при открытии drawer'а или смене сделки.
watch(
() => [props.open, props.deal?.id, props.tenantId] as const,
([open]) => {
if (open) {
loadEvents();
void loadReminders();
}
},
{ immediate: true },
);
defineExpose({
events,
eventsLoading,
eventsFetchError,
loadEvents,
commentDraft,
commentSaving,
commentSaveError,
commentToastOpen,
commentToastText,
saveComment,
});
</script>
<template>
<v-navigation-drawer v-model="drawerOpen" location="right" temporary :width="480" class="deal-drawer">
<div v-if="deal" class="drawer-content">
<DealDetailHero :deal="deal" :status="status" @close="drawerOpen = false" />
<v-divider />
<section class="section pa-5">
<h3 class="section-title text-subtitle-2 mb-3">Параметры</h3>
<dl class="params">
<div class="param">
<dt class="text-caption text-medium-emphasis">Проект</dt>
<dd class="text-body-2">{{ deal.project }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Стоимость лида</dt>
<dd class="text-body-2 num">{{ formatCost(deal.cost) }}</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Менеджер</dt>
<dd class="text-body-2">
<v-avatar size="20" color="secondary" class="mr-1">
<span class="text-caption">{{ deal.manager.initials }}</span>
</v-avatar>
{{ deal.manager.name }}
</dd>
</div>
<div class="param">
<dt class="text-caption text-medium-emphasis">Источник</dt>
<dd class="text-body-2 link">Я.Директ landing-1</dd>
</div>
</dl>
</section>
<v-divider />
<section v-if="tenantId" class="section pa-5" data-testid="comment-section">
<h3 class="section-title text-subtitle-2 mb-3">Комментарий</h3>
<v-textarea
v-model="commentDraft"
placeholder="Заметка менеджера…"
variant="outlined"
density="comfortable"
auto-grow
rows="3"
hide-details
counter="5000"
data-testid="comment-textarea"
/>
<div class="d-flex ga-2 mt-2 justify-end">
<v-btn
:loading="commentSaving"
color="primary"
size="small"
prepend-icon="mdi-content-save-outline"
data-testid="save-comment-btn"
@click="saveComment"
>
Сохранить
</v-btn>
</div>
</section>
<v-divider v-if="tenantId" />
<section v-if="tenantId && deal" class="section pa-5" data-testid="reminders-section">
<div class="d-flex justify-space-between align-center mb-3">
<h3 class="section-title text-subtitle-2 mb-0">Напоминания</h3>
<v-btn
size="x-small"
variant="text"
prepend-icon="mdi-plus"
data-testid="add-reminder-btn"
@click="reminderDialogOpen = true"
>
Создать
</v-btn>
</div>
<div v-if="reminders.length === 0 && !remindersLoading" class="text-caption text-medium-emphasis">
Нет активных напоминаний.
</div>
<ul v-else class="reminders-list">
<li v-for="r in reminders" :key="r.id" class="reminder-row" data-testid="drawer-reminder-item">
<v-btn
icon="mdi-check-circle-outline"
size="x-small"
variant="text"
density="comfortable"
:data-testid="`drawer-complete-${r.id}`"
@click="completeReminderInDrawer(r.id)"
/>
<div class="reminder-body">
<div class="reminder-text">{{ r.text || 'Без описания' }}</div>
<div class="reminder-meta text-caption text-medium-emphasis">
{{ formatReminderTime(r.remind_at) }}
</div>
</div>
</li>
</ul>
</section>
<v-divider v-if="tenantId && deal" />
<DealDetailTimeline :events="events" :events-fetch-error="eventsFetchError" />
<v-snackbar
v-model="commentToastOpen"
:timeout="3000"
:color="commentSaveError ? 'warning' : undefined"
data-testid="comment-toast"
location="bottom right"
>
{{ commentToastText }}
</v-snackbar>
<ReminderDialog
v-if="tenantId && deal"
v-model="reminderDialogOpen"
:deal-id="deal.id"
@saved="onReminderSaved"
/>
</div>
<aside v-if="inline" v-show="open" class="deal-detail-inline" data-testid="deal-detail-panel">
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
</aside>
<v-navigation-drawer
v-else
v-model="drawerOpen"
location="right"
temporary
:width="480"
class="deal-drawer"
>
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
</v-navigation-drawer>
</template>
@@ -312,75 +51,16 @@ defineExpose({
.deal-drawer {
background: #fff;
}
.drawer-content {
display: flex;
flex-direction: column;
}
.section-title {
font-weight: 600;
color: #081319;
}
.params {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px 12px;
margin: 0;
}
.param dt {
font-size: 11px;
margin-bottom: 2px;
}
.param dd {
margin: 0;
color: #081319;
}
.param .link {
color: #0f6e56;
cursor: pointer;
}
.param .link:hover {
text-decoration: underline;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
}
.reminders-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.reminder-row {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
.deal-detail-inline {
flex: 0 0 400px;
width: 400px;
background: #fff;
border: 1px solid #e8e3d6;
border-radius: 6px;
background: #fdfaf3;
}
.reminder-body {
flex: 1;
min-width: 0;
}
.reminder-text {
font-size: 13px;
line-height: 1.4;
color: #081319;
}
.reminder-meta {
margin-top: 2px;
border-radius: 8px;
overflow-y: auto;
align-self: flex-start;
max-height: calc(100vh - 160px);
position: sticky;
top: 16px;
}
</style>
@@ -1,20 +1,13 @@
<script setup lang="ts">
/**
* Sticky-bar bulk-actions для выбранных сделок (Sprint 3 Phase C).
*
* Показывается когда selectedCount > 0. В trash-mode только кнопка
* «Восстановить»; в обычном режиме Сменить статус (menu со списком),
* Экспорт, Удалить.
*
* Контракт: stateless presentation родитель держит `selected`, `statusMenuOpen`,
* `leadStatuses`, передаёт через props и слушает emit'ы.
* Sticky-bar массовой смены статуса для выбранных сделок (редизайн 2026-05-17).
* Только смена статуса корзина/экспорт убраны (экспорт панель по датам).
*/
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
defineProps<{
selectedCount: number;
trashMode: boolean;
statusMenuOpen: boolean;
leadStatuses: LeadStatus[];
}>();
@@ -22,9 +15,6 @@ defineProps<{
defineEmits<{
'update:statusMenuOpen': [value: boolean];
'apply-status': [slug: MockDeal['statusSlug']];
'apply-export': [];
'request-delete': [];
'apply-restore-trash': [];
'clear-selected': [];
}>();
</script>
@@ -39,73 +29,38 @@ defineEmits<{
data-testid="bulk-bar"
>
<div class="bulk-bar-inner">
<span class="bulk-count">
Выбрано <span class="num">{{ selectedCount }}</span>
</span>
<span class="bulk-count">Выбрано <span class="num">{{ selectedCount }}</span></span>
<v-spacer />
<!-- В trash-mode только Восстановить; в обычном режиме полный набор. -->
<v-btn
v-if="trashMode"
variant="tonal"
color="success"
size="small"
prepend-icon="mdi-restore"
data-testid="bulk-restore-trash-btn"
@click="$emit('apply-restore-trash')"
<v-menu
:model-value="statusMenuOpen"
:close-on-content-click="false"
@update:model-value="(v: boolean) => $emit('update:statusMenuOpen', v)"
>
Восстановить
</v-btn>
<template v-if="!trashMode">
<v-menu
:model-value="statusMenuOpen"
:close-on-content-click="false"
@update:model-value="(v: boolean) => $emit('update:statusMenuOpen', v)"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
variant="tonal"
size="small"
prepend-icon="mdi-tag-arrow-right"
data-testid="bulk-status-btn"
>
Сменить статус
</v-btn>
</template>
<v-list density="compact" max-height="320" min-width="240">
<v-list-item
v-for="s in leadStatuses"
:key="s.slug"
:data-testid="`bulk-status-item-${s.slug}`"
@click="$emit('apply-status', s.slug)"
>
<template #prepend>
<span class="status-dot" :style="{ background: s.colorHex }" />
</template>
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn
variant="tonal"
size="small"
prepend-icon="mdi-download"
data-testid="bulk-export-btn"
@click="$emit('apply-export')"
>
Экспорт
</v-btn>
<v-btn
variant="tonal"
color="error"
size="small"
prepend-icon="mdi-trash-can-outline"
data-testid="bulk-delete-btn"
@click="$emit('request-delete')"
>
Удалить
</v-btn>
</template>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
variant="tonal"
size="small"
prepend-icon="mdi-tag-arrow-right"
data-testid="bulk-status-btn"
>
Сменить статус
</v-btn>
</template>
<v-list density="compact" max-height="320" min-width="240">
<v-list-item
v-for="s in leadStatuses"
:key="s.slug"
:data-testid="`bulk-status-item-${s.slug}`"
@click="$emit('apply-status', s.slug)"
>
<template #prepend>
<span class="status-dot" :style="{ background: s.colorHex }" />
</template>
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-btn
icon="mdi-close"
variant="text"
@@ -123,7 +78,6 @@ defineEmits<{
font-feature-settings: 'tnum';
font-weight: 500;
}
.status-dot {
display: inline-block;
width: 6px;
@@ -131,7 +85,6 @@ defineEmits<{
border-radius: 50%;
margin-right: 6px;
}
.bulk-bar {
position: sticky;
top: 0;
@@ -1,123 +1,114 @@
<script setup lang="ts">
/**
* Filter-bar для DealsView (Sprint 3 Phase C):
* - btn-toggle с DEALS_TABS (active/all/...) + chip-counts
* - search input (имя/телефон/проект)
* - multi-select Проект и Менеджер
* - кнопка «Сбросить фильтры» (если хоть один из multi-select заполнен)
*
* Состояние держится в родителе через v-model:* (двунаправленные связки).
* Фильтр-бар реестра «Сделки»: поиск по телефону + 3 select'а (Статус, Проект,
* Город). Состояние держит родитель через v-model:*. Город пока без данных
* (источник §4 спеки не определён): select disabled при пустом availableCities.
*/
import { DEALS_TABS } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
defineProps<{
activeTab: (typeof DEALS_TABS)[number]['id'];
searchQuery: string;
filterProjects: string[];
filterManagers: string[];
availableProjects: string[];
availableManagers: { name: string; initials: string }[];
counts: Record<string, number>;
const props = defineProps<{
searchPhone: string;
filterStatus: string | null;
filterProject: number | null;
filterCity: string | null;
leadStatuses: LeadStatus[];
availableProjects: { id: number; name: string }[];
availableCities: string[];
}>();
defineEmits<{
'update:activeTab': [value: (typeof DEALS_TABS)[number]['id']];
'update:searchQuery': [value: string];
'update:filterProjects': [value: string[]];
'update:filterManagers': [value: string[]];
'update:searchPhone': [value: string];
'update:filterStatus': [value: string | null];
'update:filterProject': [value: number | null];
'update:filterCity': [value: string | null];
'clear-filters': [];
}>();
const hasActiveFilter = () =>
props.filterStatus !== null || props.filterProject !== null || props.filterCity !== null;
</script>
<template>
<div class="filter-bar mt-4">
<v-btn-toggle
:model-value="activeTab"
mandatory
color="primary"
density="comfortable"
variant="outlined"
@update:model-value="(v: (typeof DEALS_TABS)[number]['id']) => $emit('update:activeTab', v)"
>
<v-btn v-for="tab in DEALS_TABS" :key="tab.id" :value="tab.id" size="small">
{{ tab.label }}
<v-chip size="x-small" class="ml-2 chip-count" variant="tonal">
{{ counts[tab.id] }}
</v-chip>
</v-btn>
</v-btn-toggle>
<div class="deals-filters">
<v-text-field
:model-value="searchQuery"
placeholder="Поиск: имя, телефон, проект…"
:model-value="searchPhone"
placeholder="Поиск по телефону…"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
clearable
class="search-input ml-4"
@update:model-value="(v: string) => $emit('update:searchQuery', v ?? '')"
class="filters-search"
data-testid="filter-search-phone"
@update:model-value="(v: string) => $emit('update:searchPhone', v ?? '')"
/>
<v-select
:model-value="filterProjects"
:model-value="filterStatus"
:items="leadStatuses"
item-title="nameRu"
item-value="slug"
label="Статус"
variant="outlined"
density="compact"
hide-details
clearable
class="filters-select"
data-testid="filter-status"
@update:model-value="(v: string | null) => $emit('update:filterStatus', v ?? null)"
/>
<v-select
:model-value="filterProject"
:items="availableProjects"
multiple
chips
closable-chips
clearable
item-title="name"
item-value="id"
label="Проект"
variant="outlined"
density="compact"
hide-details
label="Проект"
style="min-width: 180px; max-width: 260px"
data-testid="filter-projects"
@update:model-value="(v: string[]) => $emit('update:filterProjects', v ?? [])"
clearable
class="filters-select"
data-testid="filter-project"
@update:model-value="(v: number | null) => $emit('update:filterProject', v ?? null)"
/>
<v-select
:model-value="filterManagers"
:items="availableManagers"
item-title="name"
item-value="name"
multiple
chips
closable-chips
clearable
:model-value="filterCity"
:items="availableCities"
label="Город"
variant="outlined"
density="compact"
hide-details
label="Менеджер"
style="min-width: 180px; max-width: 260px"
data-testid="filter-managers"
@update:model-value="(v: string[]) => $emit('update:filterManagers', v ?? [])"
clearable
:disabled="availableCities.length === 0"
class="filters-select"
data-testid="filter-city"
@update:model-value="(v: string | null) => $emit('update:filterCity', v ?? null)"
/>
<v-btn
v-if="filterProjects.length > 0 || filterManagers.length > 0"
v-if="hasActiveFilter()"
variant="text"
size="small"
prepend-icon="mdi-filter-off"
data-testid="clear-filters-btn"
@click="$emit('clear-filters')"
>
Сбросить фильтры
Сбросить
</v-btn>
</div>
</template>
<style scoped>
.filter-bar {
.deals-filters {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search-input {
flex: 1 1 320px;
max-width: 360px;
.filters-search {
flex: 1 1 240px;
max-width: 320px;
}
.chip-count {
font-family: 'JetBrains Mono', ui-monospace, monospace;
.filters-select {
min-width: 170px;
max-width: 220px;
}
</style>
@@ -1,32 +1,21 @@
<script setup lang="ts">
/**
* Таблица сделок (Sprint 3 Phase C extraction из DealsView).
*
* Логически замкнутый блок: v-data-table со всеми типизированными слотами
* (Vuetify 3.12 VDataTableSlots, Sprint 2 Phase B / O-stack-05).
*
* Контракт:
* props:
* - deals: MockDeal[] отфильтрованный список (computed в родителе).
* - selectedIds: number[] v-model:selected (двунаправленно).
* - statusBySlug: Map<string, LeadStatus> для status-chip color/label.
* emits:
* - update:selectedIds sync v-model selected с родителем.
* - row-click(deal) раскрыть drawer.
* Таблица реестра лидов «Сделки» (редизайн 2026-05-17).
* Колонки: чекбокс · Телефон · Источник · Город · Статус · Напоминание ·
* Комментарий · Поставлен. Напоминание/Комментарий read-only.
*/
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
import StatusPill from '../ui/StatusPill.vue';
withDefaults(
const props = withDefaults(
defineProps<{
deals: MockDeal[];
selectedIds: number[];
statusBySlug: Map<string, LeadStatus>;
// Task 15: row height from density toggle (44 comfortable / 36 compact).
rowHeight?: number;
activeDealId?: number | null;
}>(),
{ rowHeight: 44 },
{ activeDealId: null },
);
const emit = defineEmits<{
@@ -34,18 +23,22 @@ const emit = defineEmits<{
'row-click': [deal: MockDeal];
}>();
function onSelectedUpdate(value: number[]) {
emit('update:selectedIds', value);
const SIGNAL_LABELS: Record<string, string> = { call: 'Звонки', site: 'Сайт', sms: 'СМС' };
function signalLabel(t: MockDeal['signalType']): string {
return t ? (SIGNAL_LABELS[t] ?? '') : '';
}
function formatRelative(minutes: number): string {
if (minutes < 60) return `${minutes} мин назад`;
if (minutes < 60 * 24) return `${Math.floor(minutes / 60)} ч назад`;
return `${Math.floor(minutes / (60 * 24))} д назад`;
function formatDateTime(iso: string | null | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
return new Intl.DateTimeFormat('ru-RU', {
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
}).format(d);
}
function formatCost(cost: number): string {
return new Intl.NumberFormat('ru-RU').format(cost) + '';
function rowProps(deal: MockDeal): Record<string, unknown> {
return { class: deal.id === props.activeDealId ? 'deals-row-active' : '' };
}
</script>
@@ -55,72 +48,61 @@ function formatCost(cost: number): string {
:model-value="selectedIds"
:items="deals"
:headers="[
{ title: 'Лид', key: 'name', sortable: true },
{ title: 'Телефон', key: 'phone', sortable: true },
{ title: 'Источник', key: 'project', sortable: false },
{ title: 'Город', key: 'city', sortable: false },
{ title: 'Статус', key: 'statusSlug', sortable: false },
{ title: 'Проект', key: 'project', sortable: false },
{ title: 'Менеджер', key: 'manager', sortable: false },
{ title: 'Стоимость', key: 'cost', align: 'end', sortable: true },
{ title: 'Время', key: 'receivedMinutesAgo', align: 'end', sortable: true },
{ title: 'Напоминание', key: 'nextReminderAt', sortable: true },
{ title: 'Комментарий', key: 'comment', sortable: false },
{ title: 'Поставлен', key: 'receivedAt', align: 'end', sortable: true },
]"
show-select
item-value="id"
items-per-page="-1"
hide-default-footer
hover
:density="rowHeight && rowHeight < 40 ? 'compact' : 'comfortable'"
:row-props="() => ({ class: 'ld-hover-lift ld-stagger-row', style: { height: rowHeight + 'px' } })"
@update:model-value="onSelectedUpdate"
:row-props="(p: { item: MockDeal }) => rowProps(p.item)"
@update:model-value="(v: number[]) => emit('update:selectedIds', v)"
@click:row="(_e: Event, { item }: { item: MockDeal }) => emit('row-click', item)"
>
<!--
Vuetify 3.12 типизированные слоты VDataTable (Sprint 2 Phase B / O-stack-05).
`:items="deals"` (MockDeal[]) Vuetify через VDataTableSlots<ItemType<T>>
выводит `item` как `MockDeal` автоматически. Дополнительная inline-аннотация
`{ item }: { item: MockDeal }` фиксирует этот контракт явно IDE и vue-tsc
проверяют доступ к полям статически.
-->
<template #[`item.name`]="{ item }: { item: MockDeal }">
<div class="cell-deal">
<v-avatar size="32" color="primary" class="mr-3">
<span class="text-caption font-weight-medium">{{
item.name
.split(' ')
.map((p: string) => p[0])
.join('')
.slice(0, 2)
}}</span>
</v-avatar>
<div>
<div class="deal-name">{{ item.name }}</div>
<div class="deal-phone text-caption text-medium-emphasis ld-mono-s">{{ item.phone }}</div>
</div>
<template #[`item.phone`]="{ item }: { item: MockDeal }">
<span class="num ld-mono">{{ item.phone }}</span>
</template>
<template #[`item.project`]="{ item }: { item: MockDeal }">
<div class="cell-source">
<span class="source-project">{{ item.project }}</span>
<span v-if="signalLabel(item.signalType)" class="source-signal">{{
signalLabel(item.signalType)
}}</span>
</div>
</template>
<template #[`item.city`]="{ item }: { item: MockDeal }">
<span :class="{ 'text-medium-emphasis': !item.city }">{{ item.city || '—' }}</span>
</template>
<template #[`item.statusSlug`]="{ item }: { item: MockDeal }">
<!-- Task 15: StatusPill заменяет v-chip + ручной dot. Label fallback на slug
если nameRu отсутствует (leadStatuses store ещё не загружен). -->
<StatusPill
:slug="item.statusSlug"
:label="statusBySlug.get(item.statusSlug)?.nameRu ?? item.statusSlug"
/>
</template>
<template #[`item.manager`]="{ item }: { item: MockDeal }">
<div class="cell-manager">
<v-avatar size="22" color="secondary" class="mr-2">
<span class="text-caption">{{ item.manager.initials }}</span>
</v-avatar>
{{ item.manager.name }}
</div>
<template #[`item.nextReminderAt`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s" :class="{ 'text-medium-emphasis': !item.nextReminderAt }">{{
formatDateTime(item.nextReminderAt)
}}</span>
</template>
<template #[`item.cost`]="{ item }: { item: MockDeal }">
<span class="num ld-mono">{{ formatCost(item.cost) }}</span>
<template #[`item.comment`]="{ item }: { item: MockDeal }">
<span class="cell-comment" :class="{ 'text-medium-emphasis': !item.comment }">{{
item.comment || '—'
}}</span>
</template>
<template #[`item.receivedMinutesAgo`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s text-medium-emphasis">{{ formatRelative(item.receivedMinutesAgo) }}</span>
<template #[`item.receivedAt`]="{ item }: { item: MockDeal }">
<span class="num ld-mono-s">{{ formatDateTime(item.receivedAt) }}</span>
</template>
<template #[`header.data-table-select`]="{ allSelected, selectAll, someSelected }">
@@ -135,8 +117,8 @@ function formatCost(cost: number): string {
<template #[`item.data-table-select`]="{ isSelected, toggleSelect, internalItem, item }">
<v-checkbox-btn
:model-value="isSelected(internalItem)"
:aria-label="`Выбрать сделку «${(item as MockDeal).name}»`"
@update:model-value="(v: boolean | null) => toggleSelect(internalItem)"
:aria-label="`Выбрать сделку «${(item as MockDeal).phone}»`"
@update:model-value="() => toggleSelect(internalItem)"
/>
</template>
</v-data-table>
@@ -151,34 +133,32 @@ function formatCost(cost: number): string {
.deals-table-card {
background: #fff;
}
.num {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-weight: 500;
}
.cell-deal {
.cell-source {
display: flex;
align-items: center;
padding: 6px 0;
flex-direction: column;
line-height: 1.3;
}
.deal-name {
.source-project {
font-weight: 500;
color: #081319;
}
.cell-manager {
display: flex;
align-items: center;
.source-signal {
font-size: 11px;
color: #6b6356;
}
.status-dot {
.cell-comment {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 6px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
:deep(.deals-row-active) {
background: rgba(15, 110, 86, 0.07);
}
</style>
@@ -3,7 +3,7 @@
* Wizard маппинга неизвестных статусов воронки из CSV-импорта (ТЗ §6.4/§6.6).
*
* Для каждого незамапленного русского статуса пользователь выбирает один из
* 14 канонических slug'ов. Сохранение POST /api/imports/unknown-statuses/resolve.
* 5 slug'ов воронки. Сохранение POST /api/imports/unknown-statuses/resolve.
*/
import { computed, reactive, ref } from 'vue';
import { resolveUnknownStatuses, type StatusMapping, type UnknownStatus } from '../../api/imports';
@@ -18,22 +18,13 @@ const emit = defineEmits<{
resolved: [];
}>();
/** 14 канонических статусов воронки (ТЗ §6.4). */
/** 5 статусов воронки (редизайн 2026-05-17). */
const STATUS_OPTIONS: { value: string; title: string }[] = [
{ value: 'new', title: 'Новые' },
{ 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: 'Конечный недозвон' },
{ value: 'in_progress', title: 'В работе' },
{ value: 'won', title: 'Сделка' },
{ value: 'lost', title: 'Не реализовано' },
];
const selection = reactive<Record<string, string | null>>({});
@@ -4,9 +4,9 @@ import { LEAD_STATUSES } from '../../composables/leadStatuses';
import { MOCK_DEALS } from '../../composables/mockDeals';
const newStatus = LEAD_STATUSES.find((s) => s.slug === 'new')!;
const paidStatus = LEAD_STATUSES.find((s) => s.slug === 'paid')!;
const wonStatus = LEAD_STATUSES.find((s) => s.slug === 'won')!;
const newDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'new');
const paidDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'paid');
const wonDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'won');
</script>
<template>
@@ -19,10 +19,10 @@ const paidDeals = MOCK_DEALS.filter((d) => d.statusSlug === 'paid');
</v-app>
</Variant>
<Variant title=Оплачено» (2 сделки)">
<Variant title=Сделка» (2 сделки)">
<v-app>
<v-main class="story-pane">
<KanbanColumn :status="paidStatus" :deals="paidDeals" />
<KanbanColumn :status="wonStatus" :deals="wonDeals" />
</v-main>
</v-app>
</Variant>
@@ -53,14 +53,12 @@ const navGroups = computed<NavGroup[]>(() => [
},
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
{ title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
],
},
{
eyebrow: 'Финансы',
items: [
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' },
{ title: 'Отчёты', icon: 'mdi-chart-box-outline', to: '/reports' },
],
},
{
@@ -4,6 +4,7 @@ import axios from 'axios';
import type { Project } from '../../stores/projectsStore';
import { useProjectsStore } from '../../stores/projectsStore';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
const props = defineProps<{ project: Project | null }>();
const emit = defineEmits<{ close: []; saved: [] }>();
@@ -152,6 +153,7 @@ const dayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
density="comfortable"
hide-details
data-testid="pdd-regions"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
@@ -3,41 +3,69 @@
<v-card>
<v-card-title>Регионы для {{ count }} проектов</v-card-title>
<v-card-text>
<div class="mb-4">
<div class="text-caption text-success font-weight-medium mb-2"> Добавить</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="r in FEDERAL_DISTRICTS"
:key="`add-${r.bit}`"
:data-testid="`region-add-${r.bit}`"
:color="addMask & r.bit ? 'success' : undefined"
:variant="addMask & r.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleAdd(r.bit)"
>{{ r.label }}</v-chip
>
</div>
<p class="text-caption text-medium-emphasis mb-4">
Изменения применяются к каждому из {{ count }} выбранных проектов: выбранные субъекты
добавляются к их регионам или убираются из них.
</p>
<div class="mb-2">
<div class="text-caption text-success font-weight-medium mb-2"> Добавить регионы</div>
<v-autocomplete
v-model="addRegions"
:items="selectableRegions"
item-title="name"
item-value="code"
label="Субъекты РФ"
multiple
chips
clearable
density="comfortable"
data-testid="region-add-select"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #subtitle>
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
</template>
</v-list-item>
</template>
</v-autocomplete>
</div>
<div>
<div class="text-caption text-error font-weight-medium mb-2"> Убрать</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="r in FEDERAL_DISTRICTS"
:key="`remove-${r.bit}`"
:data-testid="`region-remove-${r.bit}`"
:color="removeMask & r.bit ? 'error' : undefined"
:variant="removeMask & r.bit ? 'flat' : 'outlined'"
size="small"
@click="toggleRemove(r.bit)"
>{{ r.label }}</v-chip
>
</div>
<div class="text-caption text-error font-weight-medium mb-2"> Убрать регионы</div>
<v-autocomplete
v-model="removeRegions"
:items="selectableRegions"
item-title="name"
item-value="code"
label="Субъекты РФ"
multiple
chips
clearable
density="comfortable"
data-testid="region-remove-select"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #subtitle>
{{ FEDERAL_DISTRICT_NAMES[item.raw.federalDistrict] || '' }}
</template>
</v-list-item>
</template>
</v-autocomplete>
</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn data-testid="cancel" @click="open = false">Отмена</v-btn>
<v-btn color="primary" data-testid="apply" :disabled="addMask === 0 && removeMask === 0" @click="apply"
<v-btn
color="primary"
data-testid="apply"
:disabled="addRegions.length === 0 && removeRegions.length === 0"
@click="apply"
>Применить к {{ count }}</v-btn
>
</v-card-actions>
@@ -47,47 +75,40 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { FEDERAL_DISTRICTS } from '../../constants/federal-districts';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
const props = defineProps<{ modelValue: boolean; count: number }>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
apply: [payload: { add: number; remove: number }];
apply: [payload: { add_regions: number[]; remove_regions: number[] }];
}>();
// code:0 sentinel «Вся РФ»; в bulk add/remove субъектов не выбирается.
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
const open = ref(props.modelValue);
const addMask = ref(0);
const removeMask = ref(0);
const addRegions = ref<number[]>([]);
const removeRegions = ref<number[]>([]);
watch(
() => props.modelValue,
(val) => {
open.value = val;
if (val) {
addMask.value = 0;
removeMask.value = 0;
addRegions.value = [];
removeRegions.value = [];
}
},
);
watch(open, (val) => {
emit('update:modelValue', val);
});
function toggleAdd(bit: number) {
addMask.value ^= bit;
if (addMask.value & bit) removeMask.value &= ~bit;
}
function toggleRemove(bit: number) {
removeMask.value ^= bit;
if (removeMask.value & bit) addMask.value &= ~bit;
}
watch(open, (val) => emit('update:modelValue', val));
function apply() {
emit('apply', { add: addMask.value, remove: removeMask.value });
addMask.value = 0;
removeMask.value = 0;
emit('apply', { add_regions: [...addRegions.value], remove_regions: [...removeRegions.value] });
addRegions.value = [];
removeRegions.value = [];
open.value = false;
}
defineExpose({ addRegions, removeRegions, apply });
</script>
@@ -73,5 +73,10 @@ export function mapApiDeal(api: ApiDeal, now: Date = new Date()): MockDeal {
},
cost: 0,
receivedMinutesAgo,
signalType: (api.project_signal_type as MockDeal['signalType']) ?? null,
city: api.city,
comment: api.comment,
receivedAt: api.received_at,
nextReminderAt: api.next_reminder_at,
};
}
+5 -14
View File
@@ -1,5 +1,5 @@
/**
* 14 системных и пользовательских статусов воронки.
* 5 системных статусов воронки (редизайн 2026-05-17).
*
* Источник истины: db/schema.sql:2130 (lead_statuses seed). НЕ из BRANDBOOK_v2 §3.6
* (расхождение #1 handoff vs ТЗ из реестра v1.13: handoff содержит 14 «обобщённых»
@@ -18,18 +18,9 @@ export interface LeadStatus {
}
export const LEAD_STATUSES: LeadStatus[] = [
{ slug: 'new', nameRu: 'Новые', isSystem: true, sortOrder: 1, colorHex: '#3B82F6' },
{ slug: 'new', nameRu: 'Новая сделка', isSystem: true, sortOrder: 1, colorHex: '#3B82F6' },
{ slug: 'viewed', nameRu: 'Просмотрено', isSystem: true, sortOrder: 2, colorHex: '#8B5CF6' },
{ slug: 'worked', nameRu: 'Проработан', isSystem: true, sortOrder: 3, colorHex: '#06B6D4' },
{ slug: 'base', nameRu: 'База', isSystem: false, sortOrder: 4, colorHex: '#64748B' },
{ slug: 'missed', nameRu: 'Недозвон', isSystem: false, sortOrder: 5, colorHex: '#F59E0B' },
{ slug: 'negotiations', nameRu: 'Переговоры', isSystem: false, sortOrder: 6, colorHex: '#EAB308' },
{ slug: 'waiting_payment', nameRu: 'Ожидаем оплаты', isSystem: false, sortOrder: 7, colorHex: '#A78BFA' },
{ slug: 'partnership', nameRu: 'Партнерка', isSystem: false, sortOrder: 8, colorHex: '#EC4899' },
{ slug: 'paid', nameRu: 'Оплачено', isSystem: true, sortOrder: 9, colorHex: '#10B981' },
{ slug: 'closed', nameRu: 'Закрыто и не реализовано', isSystem: true, sortOrder: 10, colorHex: '#6B7280' },
{ slug: 'test_drive', nameRu: 'Тест драйв', isSystem: false, sortOrder: 11, colorHex: '#14B8A6' },
{ slug: 'hot', nameRu: 'Горячий', isSystem: false, sortOrder: 12, colorHex: '#EF4444' },
{ slug: 'replacement', nameRu: 'На замену', isSystem: false, sortOrder: 13, colorHex: '#F97316' },
{ slug: 'final_missed', nameRu: 'Конечный недозвон', isSystem: true, sortOrder: 14, colorHex: '#1F2937' },
{ slug: 'in_progress', nameRu: 'В работе', isSystem: true, sortOrder: 3, colorHex: '#06B6D4' },
{ slug: 'won', nameRu: 'Сделка', isSystem: true, sortOrder: 4, colorHex: '#10B981' },
{ slug: 'lost', nameRu: 'Не реализовано', isSystem: true, sortOrder: 5, colorHex: '#6B7280' },
];
+16 -28
View File
@@ -16,6 +16,12 @@ export interface MockDeal {
manager: { initials: string; name: string };
cost: number;
receivedMinutesAgo: number;
// Редизайн «Сделки» (2026-05-17). Опциональны — Канбан/MOCK_DEALS не трогаем.
signalType?: 'call' | 'site' | 'sms' | null;
city?: string | null;
comment?: string | null;
receivedAt?: string | null; // ISO — колонка «Поставлен»
nextReminderAt?: string | null; // ISO — колонка «Напоминание»
}
export const MOCK_DEALS: MockDeal[] = [
@@ -33,7 +39,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 2,
name: 'Дмитрий Кузнецов',
phone: '+7 (903) 412-58-90',
statusSlug: 'worked',
statusSlug: 'in_progress',
project: 'Окна Москва',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 2400,
@@ -43,7 +49,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 3,
name: 'Светлана Иванова',
phone: '+7 (925) 309-44-12',
statusSlug: 'negotiations',
statusSlug: 'in_progress',
project: 'Окна Москва',
manager: { initials: 'ИП', name: 'Иван П.' },
cost: 2100,
@@ -53,7 +59,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 4,
name: 'Марина Лебедева',
phone: '+7 (915) 778-90-32',
statusSlug: 'paid',
statusSlug: 'won',
project: 'Натяжные потолки',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 2350,
@@ -63,7 +69,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 5,
name: 'Алексей Петров',
phone: '+7 (905) 132-46-87',
statusSlug: 'missed',
statusSlug: 'in_progress',
project: 'Окна Москва',
manager: { initials: 'ИП', name: 'Иван П.' },
cost: 2400,
@@ -73,7 +79,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 6,
name: 'Екатерина Морозова',
phone: '+7 (926) 554-21-09',
statusSlug: 'waiting_payment',
statusSlug: 'in_progress',
project: 'Натяжные потолки',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 1950,
@@ -93,7 +99,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 8,
name: 'Тимур Алиев',
phone: '+7 (903) 765-09-21',
statusSlug: 'hot',
statusSlug: 'in_progress',
project: 'Натяжные потолки',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 1850,
@@ -103,7 +109,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 9,
name: 'Наталья Семёнова',
phone: '+7 (910) 244-67-83',
statusSlug: 'closed',
statusSlug: 'lost',
project: 'Окна Москва',
manager: { initials: 'ИП', name: 'Иван П.' },
cost: 2400,
@@ -113,7 +119,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 10,
name: 'Олег Григорьев',
phone: '+7 (909) 411-52-76',
statusSlug: 'partnership',
statusSlug: 'in_progress',
project: 'Натяжные потолки',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 1850,
@@ -123,7 +129,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 11,
name: 'Ирина Зайцева',
phone: '+7 (916) 671-98-04',
statusSlug: 'final_missed',
statusSlug: 'in_progress',
project: 'Окна Москва',
manager: { initials: 'ИП', name: 'Иван П.' },
cost: 2400,
@@ -133,7 +139,7 @@ export const MOCK_DEALS: MockDeal[] = [
id: 12,
name: 'Сергей Никитин',
phone: '+7 (925) 198-43-58',
statusSlug: 'paid',
statusSlug: 'won',
project: 'Натяжные потолки',
manager: { initials: 'ОР', name: 'Ольга Р.' },
cost: 1850,
@@ -141,24 +147,6 @@ export const MOCK_DEALS: MockDeal[] = [
},
];
/**
* Срезы-фильтры для chiprow в DealsView. Каждый срез массив slug'ов или
* предикат включения. На API-стороне уйдут как ?status_in=...
*/
export interface DealsTab {
id: 'all' | 'active' | 'waiting_payment' | 'closed' | 'invalid';
label: string;
slugs: LeadStatus['slug'][] | null; // null = все
}
export const DEALS_TABS: DealsTab[] = [
{ id: 'all', label: 'Все', slugs: null },
{ id: 'active', label: 'Активные', slugs: ['new', 'viewed', 'worked', 'negotiations', 'hot'] },
{ id: 'waiting_payment', label: 'Ждут оплату', slugs: ['waiting_payment'] },
{ id: 'closed', label: 'Закрытые', slugs: ['paid', 'closed'] },
{ id: 'invalid', label: 'Невалидные', slugs: ['missed', 'final_missed'] },
];
/**
* Доступные проекты и менеджеры для NewDealDialog. На API: GET /api/projects /
* GET /api/managers (фильтр по tenant_id из middleware).
@@ -13,11 +13,13 @@ export interface PillStyle {
export const STATUS_PILL_SLUGS = [
'new',
'viewed',
'in_progress',
'callback',
'quality',
'meeting_set',
'won',
'lost',
'refund',
'duplicate',
'junk',
@@ -32,11 +34,13 @@ type StatusPillSlug = (typeof STATUS_PILL_SLUGS)[number];
const STYLES: Record<StatusPillSlug, PillStyle> = {
new: { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' },
viewed: { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' },
in_progress: { bg: 'rgba(63,124,149,0.12)', color: '#2A5A6E' },
callback: { bg: 'rgba(217,164,65,0.18)', color: '#A07820' },
quality: { bg: 'rgba(46,139,87,0.15)', color: '#2E8B57' },
meeting_set: { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' },
won: { bg: 'rgba(46,139,87,0.22)', color: '#1F6940', fontWeight: 600 },
lost: { bg: 'rgba(107,99,86,0.18)', color: '#6B6356' },
refund: { bg: 'rgba(204,110,80,0.15)', color: '#B0563D' },
duplicate: { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' },
junk: { bg: 'rgba(184,58,58,0.10)', color: '#B83A3A' },
@@ -1,18 +0,0 @@
export interface FederalDistrict {
bit: number; // 1, 2, 4, ..., 128
label: string;
}
// 8 ФО РФ — соответствует schema `projects.region_mask BETWEEN 0 AND 255`.
// Используется в bulk-операциях по проектам (грубое выделение).
// Для тонкого pick'а subject-level см. constants/regions.ts.
export const FEDERAL_DISTRICTS: FederalDistrict[] = [
{ bit: 1, label: 'Центральный' },
{ bit: 2, label: 'Северо-Западный' },
{ bit: 4, label: 'Южный' },
{ bit: 8, label: 'Северо-Кавказский' },
{ bit: 16, label: 'Приволжский' },
{ bit: 32, label: 'Уральский' },
{ bit: 64, label: 'Сибирский' },
{ bit: 128, label: 'Дальневосточный' },
];
+3
View File
@@ -106,6 +106,9 @@ export const useProjectsStore = defineStore('projects', () => {
action: 'pause' | 'resume' | 'archive' | 'update_regions' | 'update_days' | 'update_limit';
add?: number;
remove?: number;
// Plan 6.5 — update_regions оперирует кодами субъектов (1..89), не bitmask ФО.
add_regions?: number[];
remove_regions?: number[];
delta?: number;
replace?: number;
}
@@ -0,0 +1,52 @@
/**
* Workaround для бага позиционирования Vuetify connected-location-strategy.
*
* Когда активатор `v-select`/`v-autocomplete` находится внутри
* `position: fixed`-контейнера (кастомный дровер, диалог), Vuetify включает
* ветку `activatorFixed` (`isFixedPosition()` true). Её `getIntrinsicSize()`
* вычитает `el.style.left` из измеренной геометрии оверлея; на переходном
* кадре, когда контент ещё отрисован в нулевой позиции, а инлайновый
* `style.left` уже не нулевой, `contentBox.x` становится отрицательным и
* стратегия аккумулирует смещение меню уезжает на кратное X активатора
* (за край экрана).
*
* Обычно гонку сглаживают пересчёты, размазанные по анимации открытия. Под
* `prefers-reduced-motion: reduce` (умолчание Windows Server) анимации нет
* один пересчёт на «плохом» кадре остаётся финальным.
*
* Фикс: дождаться, пока контент оверлея отрисован и геометрически стабилен,
* затем один раз послать `resize` Vuetify пересчитает позицию по уже
* устоявшейся геометрии и поставит меню корректно. Безопасно при motion ON
* (пересчёт по стабильной геометрии идемпотентен) и для не-fixed контейнеров.
*
* Привязывать к `@update:menu` нужного `v-autocomplete`/`v-select`.
*/
export function repositionMenuAfterOpen(open: boolean): void {
if (!open || typeof window === 'undefined') return;
let prevLeft = Number.NaN;
let stableFrames = 0;
let totalFrames = 0;
const tick = (): void => {
// Последний открытый overlay-menu (на случай вложенных оверлеев).
const menus = document.querySelectorAll<HTMLElement>('.v-overlay.v-menu .v-overlay__content');
const el = menus[menus.length - 1];
if (el && el.getBoundingClientRect().width > 0) {
const left = Math.round(el.getBoundingClientRect().left);
stableFrames = left === prevLeft ? stableFrames + 1 : 0;
prevLeft = left;
// 3 кадра без движения = геометрия устоялась → один чистый пересчёт.
if (stableFrames >= 3) {
window.dispatchEvent(new Event('resize'));
return;
}
}
// Предохранитель ~1.5 c: не зацикливаться, если оверлей не появился.
if (++totalFrames < 90) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* Канбан альтернативный вид сделок (по статусам). 14 колонок (lead_statuses).
* Канбан альтернативный вид сделок (по статусам). 5 колонок (lead_statuses).
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_kanban.html.
* DnD реализован через vuedraggable@4 (обёртка SortableJS) карточки можно
@@ -88,6 +88,7 @@
density="comfortable"
class="ld-input-quiet"
data-testid="regions-autocomplete"
@update:menu="repositionMenuAfterOpen"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
@@ -137,6 +138,7 @@
import { ref, reactive, watch } from 'vue';
import { apiClient, ensureCsrfCookie, extractErrorMessage } from '../../api/client';
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
import { repositionMenuAfterOpen } from '../../utils/menuRepositionFix';
import type { Project } from '../../stores/projectsStore';
import DevIndexBadge from '../../components/DevIndexBadge.vue';
+1 -1
View File
@@ -166,7 +166,7 @@ test('GET show: activity возвращает с actor_email из users LEFT JOI
'user_id' => $user->id,
'deal_id' => 999,
'event' => 'deal.status_changed',
'context' => json_encode(['from' => 'new', 'to' => 'worked']),
'context' => json_encode(['from' => 'new', 'to' => 'in_progress']),
'created_at' => Carbon::now(),
]);
DB::table('activity_log')->insert([
@@ -6,17 +6,17 @@ use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
it('accepts update_regions action with add/remove bitmask', function () {
it('accepts update_regions action with subject-code arrays', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p = Project::factory()->for($tenant)->create(['region_mask' => 1]);
$p = Project::factory()->for($tenant)->create(['regions' => [82]]);
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p->id],
'add' => 6, // биты 2+4 = Северо-Западный + Южный
'remove' => 1, // бит 1 = Центральный
'add_regions' => [83, 84], // Санкт-Петербург + Севастополь
'remove_regions' => [82], // Москва
])
->assertOk()
->assertJsonStructure(['updated', 'skipped', 'warnings']);
@@ -69,24 +69,39 @@ it('accepts empty scope.filter as valid scope (all projects)', function () {
->assertOk();
});
it('applies update_regions add and remove correctly', function () {
it('applies update_regions add_regions and remove_regions to the regions array', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p1 = Project::factory()->for($tenant)->create(['region_mask' => 3]); // 1+2
$p2 = Project::factory()->for($tenant)->create(['region_mask' => 5]); // 1+4
$p1 = Project::factory()->for($tenant)->create(['regions' => [82, 56]]); // Москва + Московская обл.
$p2 = Project::factory()->for($tenant)->create(['regions' => []]); // вся РФ
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p1->id, $p2->id],
'add' => 16, // 16 = Приволжский
'remove' => 1, // 1 = Центральный
'add_regions' => [83], // Санкт-Петербург
'remove_regions' => [56], // Московская область
])
->assertOk()
->assertJson(['updated' => 2, 'skipped' => [], 'warnings' => []]);
expect($p1->fresh()->region_mask)->toBe((3 | 16) & ~1); // = 18
expect($p2->fresh()->region_mask)->toBe((5 | 16) & ~1); // = 20
expect($p1->fresh()->regions)->toBe([82, 83]); // [82,56] {83} \ {56}, отсортировано
expect($p2->fresh()->regions)->toBe([83]); // [] {83} \ {56}
});
it('rejects update_regions with out-of-range subject code', function () {
$tenant = Tenant::factory()->create();
$user = User::factory()->for($tenant)->create();
$p = Project::factory()->for($tenant)->create();
$this->actingAs($user)
->postJson('/api/projects/bulk', [
'action' => 'update_regions',
'ids' => [$p->id],
'add_regions' => [90], // > 89 — невалидный код субъекта РФ
])
->assertStatus(422)
->assertJsonValidationErrors(['add_regions.0']);
});
it('applies update_days add and remove correctly', function () {
+6 -6
View File
@@ -71,7 +71,7 @@ it('leads_received считает только сделки окна, без del
// 3 живые сделки в окне 7d + 1 deleted + 1 is_test + 1 вне окна (8 дней назад)
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(2));
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(3));
makeDashboardDeal($tenant, $project, 'won', now()->subDays(3));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), deletedAt: now());
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1), isTest: true);
makeDashboardDeal($tenant, $project, 'new', now()->subDays(8));
@@ -81,14 +81,14 @@ it('leads_received считает только сделки окна, без del
->assertJsonPath('leads_received.value', 3);
});
it('conversion = доля статуса paid в окне', function () {
it('conversion = доля статуса won в окне', function () {
$tenant = Tenant::factory()->create();
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
// 1 paid из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
// 1 won из 4 → 25.0%; PHP json_encode кодирует 25.0 как 25 (без дроби)
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
->assertOk()
->assertJsonPath('conversion.value', 25);
@@ -111,11 +111,11 @@ it('funnel группирует живые сделки по статусу', fu
$project = Project::factory()->create(['tenant_id' => $tenant->id]);
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'new', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'paid', now()->subDays(1));
makeDashboardDeal($tenant, $project, 'won', now()->subDays(1));
$this->getJson("/api/dashboard/summary?tenant_id={$tenant->id}")
->assertOk()
->assertJsonPath('funnel.new', 2)
->assertJsonPath('funnel.paid', 1);
->assertJsonPath('funnel.won', 1);
});
it('activity возвращает 7 точек и 7 меток', function () {
-158
View File
@@ -10,7 +10,6 @@ use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
uses(DatabaseTransactions::class);
@@ -194,160 +193,3 @@ test('POST /api/deals manual БЕЗ supplier'."'а у проекта — без
->count();
expect($cost)->toBe(0);
});
test('POST /api/deals/export возвращает CSV с правильными headers + BOM', function () {
// Создаём 2 сделки через store endpoint (получаем реальные id).
$r1 = $this->postJson('/api/deals', [
'project_name' => 'X',
'phone' => '+7 (999) 111-11-11',
'contact_name' => 'Алиса',
])->json('deal');
$r2 = $this->postJson('/api/deals', [
'project_name' => 'X',
'phone' => '+7 (999) 222-22-22',
'contact_name' => 'Боб',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'ids' => [$r1['id'], $r2['id']],
]);
$r->assertStatus(200);
expect($r->headers->get('Content-Type'))->toContain('text/csv');
expect($r->headers->get('Content-Disposition'))->toContain('deals_export_');
// Sprint 3 Phase A (O-perf-05): export → StreamedResponse через OpenSpout,
// body читается через streamedContent() (см. TestResponse::streamedContent).
$body = $r->streamedContent();
// BOM первый символ
expect($body)->toStartWith("\u{FEFF}");
// Headers строка
expect($body)->toContain('ID;Имя;Телефон;Статус');
// Контент сделок
expect($body)->toContain('Алиса');
expect($body)->toContain('Боб');
expect($body)->toContain('+7 (999) 111-11-11');
});
test('POST /api/deals/export 422 без ids', function () {
$r = $this->postJson('/api/deals/export', []);
$r->assertStatus(422);
expect($r->json('errors'))->toHaveKey('ids');
});
test('POST /api/deals/export 401 без auth', function () {
auth()->logout();
$r = $this->postJson('/api/deals/export', [
'ids' => [1, 2, 3],
]);
$r->assertStatus(401);
});
test('POST /api/deals/export фильтрует только запрошенные ids (своего tenant\'а)', function () {
// Создаём 3 сделки одного tenant'а, экспортируем 1 → CSV только её.
$a = $this->postJson('/api/deals', [
'project_name' => 'X',
'phone' => '+7 (999) 111-11-11',
'contact_name' => 'Алиса',
])->json('deal');
$this->postJson('/api/deals', [
'project_name' => 'X',
'phone' => '+7 (999) 222-22-22',
'contact_name' => 'Боб',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'ids' => [$a['id']],
]);
$r->assertStatus(200);
$body = $r->streamedContent();
expect($body)->toContain('Алиса');
expect($body)->not->toContain('Боб');
});
// NB: полная RLS-изоляция (другие tenant'ы скрыты) тестируется отдельно
// через testing_rls_user (NOLOGIN role без BYPASSRLS) — см.
// `tests/Feature/RlsSmokeTest.php` v1.10. В этом тесте используется postgres
// superuser, который BYPASSRLS — RLS-проверка тут была бы false-positive.
test('POST /api/deals/export?format=xlsx возвращает binary с корректным content-type', function () {
$a = $this->postJson('/api/deals', [
'project_name' => 'X',
'phone' => '+7 (999) 111-11-11',
'contact_name' => 'Алиса',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'ids' => [$a['id']],
'format' => 'xlsx',
]);
$r->assertStatus(200);
expect($r->headers->get('Content-Type'))
->toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
expect($r->headers->get('Content-Disposition'))->toContain('.xlsx');
// XLSX = ZIP-archive, начинается с magic bytes "PK\x03\x04".
$body = $r->streamedContent();
expect(substr($body, 0, 4))->toBe("PK\x03\x04");
expect(strlen($body))->toBeGreaterThan(2000); // sanity: реальный xlsx > 2 KB
});
test('POST /api/deals/export?format=xlsx содержит данные сделки (после распаковки sheet1)', function () {
$a = $this->postJson('/api/deals', [
'project_name' => 'X',
'phone' => '+7 (999) 333-33-33',
'contact_name' => 'Кириллов',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'ids' => [$a['id']],
'format' => 'xlsx',
]);
$tmp = tempnam(sys_get_temp_dir(), 'xlsx_test_');
file_put_contents($tmp, $r->streamedContent());
$reader = IOFactory::createReader('Xlsx');
$book = $reader->load($tmp);
$sheet = $book->getActiveSheet();
expect($sheet->getTitle())->toBe('Сделки');
// Sprint 3 Phase A (O-perf-05): после миграции на OpenSpout streaming,
// styled-header cells пишутся как inline-string с RichText. Используем
// getFormattedValue() для plain-string сравнения header'ов; для data-cell'ов
// OpenSpout продолжает писать обычные shared-strings.
expect($sheet->getCell('A1')->getFormattedValue())->toBe('ID');
expect($sheet->getCell('B1')->getFormattedValue())->toBe('Имя');
expect($sheet->getStyle('A1')->getFont()->getBold())->toBeTrue();
// Row 2 — реальная сделка. OpenSpout пишет string-cell'ы как inline-string с
// RichText-обёрткой; для plain-string сравнения используем getFormattedValue().
// Numeric cell A2 (ID) — обычный numeric, ->getValue() работает.
expect($sheet->getCell('A2')->getValue())->toBe($a['id']);
expect($sheet->getCell('B2')->getFormattedValue())->toBe('Кириллов');
expect($sheet->getCell('C2')->getFormattedValue())->toBe('+7 (999) 333-33-33');
unlink($tmp);
});
test('POST /api/deals/export 422 на неизвестный format', function () {
$r = $this->postJson('/api/deals/export', [
'ids' => [1],
'format' => 'pdf',
]);
$r->assertStatus(422);
expect($r->json('errors'))->toHaveKey('format');
});
test('POST /api/deals/export по умолчанию (без format) возвращает CSV — backward-compat', function () {
$a = $this->postJson('/api/deals', [
'project_name' => 'X',
'phone' => '+7 (999) 444-44-44',
'contact_name' => 'Test',
])->json('deal');
$r = $this->postJson('/api/deals/export', [
'ids' => [$a['id']],
]);
$r->assertStatus(200);
expect($r->headers->get('Content-Type'))->toContain('text/csv');
expect($r->headers->get('Content-Disposition'))->toContain('.csv');
});
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use App\Models\Deal;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\DB;
/**
* Тесты POST /api/deals/export экспорт по диапазону дат поставки.
*
* Редизайн «Сделки» (2026-05-17, Task A5): вместо ids[] received_from/received_to.
* Конвенции: DatabaseTransactions + actingAs + SET app.current_tenant_id
* (аналогично DealIndexTest.php).
*/
uses(DatabaseTransactions::class);
beforeEach(function () {
$this->tenant = Tenant::factory()->create();
$this->user = User::factory()->for($this->tenant)->create();
$this->actingAs($this->user);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$this->project = Project::factory()->for($this->tenant)->create(['name' => 'Окна Москва']);
});
test('POST /api/deals/export требует auth', function () {
auth()->logout();
$this->postJson('/api/deals/export', ['format' => 'csv'])->assertStatus(401);
});
test('POST /api/deals/export csv возвращает сделки в диапазоне дат', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create([
'phone' => '+7 999 111-11-11', 'received_at' => '2026-05-15 10:00:00',
]);
Deal::factory()->for($this->tenant)->for($this->project)->create([
'phone' => '+7 999 222-22-22', 'received_at' => '2026-05-25 10:00:00',
]);
$r = $this->post('/api/deals/export', [
'received_from' => '2026-05-14', 'received_to' => '2026-05-16', 'format' => 'csv',
]);
$r->assertStatus(200);
$r->assertHeader('content-type', 'text/csv; charset=utf-8');
$body = $r->streamedContent();
expect($body)->toContain('+7 999 111-11-11');
expect($body)->not->toContain('+7 999 222-22-22');
});
test('POST /api/deals/export xlsx отдаёт spreadsheet content-type', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 10:00:00']);
$r = $this->post('/api/deals/export', ['format' => 'xlsx']);
$r->assertStatus(200);
$r->assertHeader('content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
});
test('POST /api/deals/export не экспортирует чужой tenant (RLS)', function () {
$other = Tenant::factory()->create();
DB::statement('SET app.current_tenant_id = '.$other->id);
$foreignProject = Project::factory()->for($other)->create();
Deal::factory()->for($other)->for($foreignProject)->create(['phone' => '+7 900 000-00-00']);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$r = $this->post('/api/deals/export', ['format' => 'csv']);
expect($r->streamedContent())->not->toContain('+7 900 000-00-00');
});
test('POST /api/deals/export 422 на неизвестный format', function () {
$this->postJson('/api/deals/export', ['format' => 'pdf'])->assertStatus(422);
});
test('POST /api/deals/export без format по умолчанию отдаёт CSV', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 10:00:00']);
$r = $this->post('/api/deals/export', []);
$r->assertStatus(200);
$r->assertHeader('content-type', 'text/csv; charset=utf-8');
});
+45 -6
View File
@@ -105,14 +105,14 @@ test('GET /api/deals сортирует по received_at DESC', function () {
test('GET /api/deals фильтрует по status_in[]', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'closed']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'lost']);
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=paid');
$r = $this->getJson('/api/deals?status_in[]=new&status_in[]=won');
expect($r->json('total'))->toBe(2);
$statuses = collect($r->json('deals'))->pluck('status')->sort()->values()->all();
expect($statuses)->toBe(['new', 'paid']);
expect($statuses)->toBe(['new', 'won']);
});
test('GET /api/deals фильтрует по project_id', function () {
@@ -292,7 +292,7 @@ test('GET /api/deals возвращает next_cursor когда есть ещё
test('GET /api/deals?count_only=1 возвращает только total без массива deals', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
$r = $this->getJson('/api/deals?count_only=1');
@@ -304,7 +304,7 @@ test('GET /api/deals?count_only=1 возвращает только total без
test('GET /api/deals?count_only=1 учитывает фильтры (status_in)', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
expect($this->getJson('/api/deals?count_only=1&status_in[]=new')->json('total'))->toBe(2);
});
@@ -318,3 +318,42 @@ test('GET /api/deals?count_only=1 изолирует чужой tenant (RLS)', f
expect($this->getJson('/api/deals?count_only=1')->json('total'))->toBe(1);
});
test('GET /api/deals фильтрует по received_from/received_to', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-10 12:00:00']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-15 12:00:00']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-20 12:00:00']);
$r = $this->getJson('/api/deals?received_from=2026-05-12&received_to=2026-05-16');
expect($r->json('total'))->toBe(1);
});
test('GET /api/deals received_to включает весь день (конец дня)', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['received_at' => '2026-05-16 23:30:00']);
expect($this->getJson('/api/deals?received_to=2026-05-16')->json('total'))->toBe(1);
});
test('GET /api/deals возвращает comment/city/project_signal_type/next_reminder_at', function () {
$this->project->update(['signal_type' => 'call', 'signal_identifier' => '79990001122']);
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
'comment' => 'перезвонить',
'city' => 'Казань',
]);
$r = $this->getJson('/api/deals');
expect($r->json('deals.0.comment'))->toBe('перезвонить');
expect($r->json('deals.0.city'))->toBe('Казань');
expect($r->json('deals.0.project_signal_type'))->toBe('call');
expect($r->json('deals.0'))->toHaveKey('next_reminder_at');
});
test('GET /api/deals возвращает 422 на невалидную received_from', function () {
$this->getJson('/api/deals?received_from=не-дата')->assertStatus(422);
});
test('GET /api/deals возвращает 422 на невалидную received_to', function () {
$this->getJson('/api/deals?received_to=garbage')->assertStatus(422);
});
+2 -2
View File
@@ -95,7 +95,7 @@ test('GET /api/deals/{id} возвращает activity events отсортир
'user_id' => $this->manager->id,
'deal_id' => $deal->id,
'event' => ActivityLog::EVENT_DEAL_STATUS_CHANGED,
'context' => ['from' => 'new', 'to' => 'paid', 'source' => 'manual'],
'context' => ['from' => 'new', 'to' => 'won', 'source' => 'manual'],
'created_at' => now()->subMinutes(5),
]);
@@ -106,7 +106,7 @@ test('GET /api/deals/{id} возвращает activity events отсортир
expect($events)->toHaveCount(2);
// ORDER BY created_at DESC — свежее (status_changed) сверху.
expect($events[0]['event'])->toBe('deal.status_changed');
expect($events[0]['context'])->toMatchArray(['from' => 'new', 'to' => 'paid']);
expect($events[0]['context'])->toMatchArray(['from' => 'new', 'to' => 'won']);
expect($events[0]['actor']['name'])->toBe('Иван П.');
expect($events[0]['actor']['initials'])->toBe('ИП');
+9 -9
View File
@@ -61,18 +61,18 @@ test('POST /api/deals/transition — обновляет статус и пише
$r = $this->postJson('/api/deals/transition', [
'ids' => $deals->pluck('id')->all(),
'status' => 'paid',
'status' => 'won',
]);
$r->assertStatus(200)->assertJson([
'updated' => 3,
'requested' => 3,
'status' => 'paid',
'status' => 'won',
]);
foreach ($deals as $d) {
$d->refresh();
expect($d->status)->toBe('paid');
expect($d->status)->toBe('won');
}
$activity = ActivityLog::where('tenant_id', $this->tenant->id)
@@ -81,17 +81,17 @@ test('POST /api/deals/transition — обновляет статус и пише
expect($activity)->toHaveCount(3);
expect($activity->first()->context)->toMatchArray([
'from' => 'new',
'to' => 'paid',
'to' => 'won',
'source' => 'bulk',
]);
});
test('POST /api/deals/transition — NO-OP не пишет ActivityLog', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'won']);
$r = $this->postJson('/api/deals/transition', [
'ids' => [$deal->id],
'status' => 'paid',
'status' => 'won',
]);
$r->assertStatus(200)->assertJson(['updated' => 0, 'requested' => 1]);
@@ -111,7 +111,7 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
// Передаём оба id — чужой не должен обновиться.
$r = $this->postJson('/api/deals/transition', [
'ids' => [$own->id, $foreign->id],
'status' => 'paid',
'status' => 'won',
]);
$r->assertStatus(200)->assertJson([
@@ -121,7 +121,7 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$own->refresh();
expect($own->status)->toBe('paid');
expect($own->status)->toBe('won');
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
$foreign->refresh();
@@ -131,6 +131,6 @@ test('POST /api/deals/transition — defense-in-depth не апдейтит чу
test('POST /api/deals/transition — 422 если ids пустой массив', function () {
$this->postJson('/api/deals/transition', [
'ids' => [],
'status' => 'paid',
'status' => 'won',
])->assertStatus(422);
});
+6 -6
View File
@@ -83,17 +83,17 @@ test('PATCH /api/deals/{id} обновляет status + пишет deal.status_c
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
$r = $this->patchJson('/api/deals/'.$deal->id, [
'status' => 'paid',
'status' => 'won',
]);
$r->assertStatus(200);
DB::statement('SET app.current_tenant_id = '.$this->tenant->id);
$deal->refresh();
expect($deal->status)->toBe('paid');
expect($deal->status)->toBe('won');
$log = ActivityLog::where('deal_id', $deal->id)->where('event', 'deal.status_changed')->first();
expect($log)->not->toBeNull();
expect($log->context)->toMatchArray(['from' => 'new', 'to' => 'paid', 'source' => 'manual']);
expect($log->context)->toMatchArray(['from' => 'new', 'to' => 'won', 'source' => 'manual']);
});
test('PATCH /api/deals/{id} 422 на неизвестный status slug', function () {
@@ -123,12 +123,12 @@ test('PATCH /api/deals/{id} 422 на manager_id чужого tenant\'а', functi
test('PATCH /api/deals/{id} NO-OP не пишет ActivityLog', function () {
$deal = Deal::factory()->for($this->tenant)->for($this->project)->create([
'status' => 'paid',
'status' => 'won',
'comment' => 'same',
]);
$r = $this->patchJson('/api/deals/'.$deal->id, [
'status' => 'paid', // не меняем
'status' => 'won', // не меняем
'comment' => 'same', // не меняем
]);
$r->assertStatus(200);
@@ -145,7 +145,7 @@ test('PATCH /api/deals/{id} комбинированно — comment + status о
$r = $this->patchJson('/api/deals/'.$deal->id, [
'comment' => 'Заметка',
'status' => 'worked',
'status' => 'in_progress',
]);
$r->assertStatus(200);
@@ -51,7 +51,7 @@ test('импортирует исторические лиды, создавая
->and($result->updated)->toBe(0);
$deal = Deal::query()->where('source_crm_id', 5001)->firstOrFail();
expect($deal->status)->toBe('negotiations')
expect($deal->status)->toBe('in_progress')
->and($deal->phone)->toBe('79161112233')
->and($deal->received_at->format('Y-m-d'))->toBe('2023-07-10');
});
@@ -89,7 +89,7 @@ test('повторный импорт того же файла не создаё
->and(Deal::query()->where('source_crm_id', 5003)->count())->toBe(1);
$deal = Deal::query()->where('source_crm_id', 5003)->firstOrFail();
expect($deal->status)->toBe('paid') // §6.5 стадия 3a: status перезаписан
expect($deal->status)->toBe('won') // §6.5 стадия 3a: status перезаписан
->and($deal->contact_name)->toBe('Пётр')
->and($deal->comment)->toBe('Обновлённый');
});
@@ -127,7 +127,7 @@ test('resolved-маппинг tenant-а применяется к ранее н
'tenant_id' => $this->tenant->id,
'status_ru' => 'Архив',
'occurrences' => 1,
'mapped_to_slug' => 'closed',
'mapped_to_slug' => 'lost',
'resolved_at' => now(),
]);
$rows = parseFixture(
@@ -135,7 +135,7 @@ test('resolved-маппинг tenant-а применяется к ранее н
);
$this->service->import($this->tenant->id, $this->user->id, importLog($this->tenant, $this->user), $rows);
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('closed');
expect(Deal::query()->where('source_crm_id', 5007)->firstOrFail()->status)->toBe('lost');
});
test('dry_run не пишет сделки, но считает проекцию', function (): void {
@@ -155,7 +155,7 @@ test('неизвестные статусы и resolved-маппинг изол
'tenant_id' => $otherTenant->id,
'status_ru' => 'Архив',
'occurrences' => 9,
'mapped_to_slug' => 'closed',
'mapped_to_slug' => 'lost',
'resolved_at' => now(),
]);
@@ -97,7 +97,7 @@ test('GET /api/imports/unknown-statuses возвращает незамапле
]);
ImportUnknownStatus::create([
'tenant_id' => $this->tenant->id, 'status_ru' => 'Спам', 'occurrences' => 1,
'mapped_to_slug' => 'closed', 'resolved_at' => now(),
'mapped_to_slug' => 'lost', 'resolved_at' => now(),
]);
$this->getJson('/api/imports/unknown-statuses')
@@ -113,10 +113,10 @@ test('POST /api/imports/unknown-statuses/resolve проставляет мапп
]);
$this->postJson('/api/imports/unknown-statuses/resolve', [
'mappings' => [['status_ru' => 'Архив', 'slug' => 'closed']],
'mappings' => [['status_ru' => 'Архив', 'slug' => 'lost']],
])->assertStatus(200);
expect($unknown->refresh()->mapped_to_slug)->toBe('closed')
expect($unknown->refresh()->mapped_to_slug)->toBe('lost')
->and($unknown->resolved_at)->not->toBeNull();
});
@@ -43,7 +43,7 @@ test('ImportUnknownStatus хранит маппинг и фильтруется
'tenant_id' => $this->tenant->id,
'status_ru' => 'Спам',
'occurrences' => 1,
'mapped_to_slug' => 'closed',
'mapped_to_slug' => 'lost',
'resolved_at' => now(),
]);
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
test('lead_statuses содержит ровно 5 статусов воронки', function () {
$slugs = DB::table('lead_statuses')->orderBy('sort_order')->pluck('slug')->all();
expect($slugs)->toBe(['new', 'viewed', 'in_progress', 'won', 'lost']);
});
test('новые статусы имеют корректные русские названия', function () {
$names = DB::table('lead_statuses')->pluck('name_ru', 'slug');
expect($names['new'])->toBe('Новая сделка');
expect($names['in_progress'])->toBe('В работе');
expect($names['won'])->toBe('Сделка');
expect($names['lost'])->toBe('Не реализовано');
});
test('старых slug-ов воронки в lead_statuses не осталось', function () {
$obsolete = DB::table('lead_statuses')
->whereIn('slug', ['worked', 'paid', 'closed', 'hot', 'negotiations'])
->count();
expect($obsolete)->toBe(0);
});
+5 -10
View File
@@ -8,9 +8,8 @@ use Illuminate\Support\Facades\DB;
/**
* Тесты GET /api/lead-statuses глобальный lookup статусов воронки.
*
* Таблица lead_statuses не tenant-aware, seeded в schema.sql:2130 (14 системных
* статусов: new/viewed/worked/base/missed/negotiations/waiting_payment/
* partnership/paid/closed/test_drive/hot/replacement/final_missed).
* Таблица lead_statuses не tenant-aware, seeded в schema.sql (5 системных
* статусов воронки: new/viewed/in_progress/won/lost).
*/
uses(DatabaseTransactions::class);
@@ -19,18 +18,14 @@ test('GET /api/lead-statuses возвращает 200 и не пустой сп
$r->assertStatus(200);
expect($r->json('lead_statuses'))->toBeArray();
expect(count($r->json('lead_statuses')))->toBeGreaterThanOrEqual(14);
expect(count($r->json('lead_statuses')))->toBeGreaterThanOrEqual(5);
});
test('GET /api/lead-statuses возвращает все 14 системных статусов из seed', function () {
test('GET /api/lead-statuses возвращает все 5 системных статусов из seed', function () {
$r = $this->getJson('/api/lead-statuses');
$slugs = collect($r->json('lead_statuses'))->pluck('slug')->all();
$expected = [
'new', 'viewed', 'worked', 'base', 'missed', 'negotiations',
'waiting_payment', 'partnership', 'paid', 'closed',
'test_drive', 'hot', 'replacement', 'final_missed',
];
$expected = ['new', 'viewed', 'in_progress', 'won', 'lost'];
foreach ($expected as $slug) {
expect($slugs)->toContain($slug);
}
@@ -55,12 +55,12 @@ test('slug = managers', function () {
expect((new ManagersSummaryProvider)->slug())->toBe('managers');
});
test('агрегирует сделки по менеджеру: total, paid, конверсия', function () {
test('агрегирует сделки по менеджеру: total, won, конверсия', function () {
$manager = User::factory()->create([
'tenant_id' => $this->tenant->id, 'first_name' => 'Иван', 'last_name' => 'Петров',
]);
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'paid']);
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'won']);
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'won']);
seedManagerDeal($this->tenant->id, $this->project->id, ['manager_id' => $manager->id, 'status' => 'new']);
$rows = (new ManagersSummaryProvider)->rows(managersJob($this->tenant->id));
@@ -350,7 +350,7 @@ test('POST /api/reports/jobs (sync queue): managers_summary → done с CSV', fu
'project_id' => $project->id,
'manager_id' => $manager->id,
'phone' => '+79990001122',
'status' => 'paid',
'status' => 'won',
'received_at' => $now,
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
@@ -383,7 +383,7 @@ test('POST /api/reports/jobs (sync queue): sources_summary → done с CSV', fun
'tenant_id' => $this->tenant->id,
'project_id' => $project->id,
'phone' => '+79990002233',
'status' => 'paid',
'status' => 'won',
'utm_source' => 'yandex',
'received_at' => $now,
'created_at' => Carbon::now(),
@@ -53,9 +53,9 @@ test('slug = sources', function () {
});
test('агрегирует сделки по utm_source', function () {
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'paid']);
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'won']);
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'yandex', 'status' => 'new']);
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'paid']);
seedSourceDeal($this->tenant->id, $this->project->id, ['utm_source' => 'vk', 'status' => 'won']);
$rows = (new SourcesSummaryProvider)->rows(sourcesJob($this->tenant->id));
@@ -105,7 +105,7 @@ function makeApiDetail(overrides: Partial<AdminTenantDetailResponse> = {}): Admi
event: 'deal.status_changed',
deal_id: 4470,
actor_email: 'ivan@okna-moscow.ru',
context: { from: 'viewed', to: 'worked' },
context: { from: 'viewed', to: 'in_progress' },
created_at: '2026-05-09T07:18:00+00:00',
},
],
@@ -221,7 +221,7 @@ describe('AdminTenantDetailView.vue (API integration)', () => {
expect(text).toContain('webhook.received');
expect(text).toContain('deal.status_changed');
expect(text).toContain('ivan@okna-moscow.ru'); // actor_email
expect(text).toContain('viewed → worked'); // summary из context
expect(text).toContain('viewed → in_progress'); // summary из context
});
it('кнопка «Войти как клиент» open impersonationDialog', async () => {
+4 -2
View File
@@ -87,14 +87,16 @@ describe('AppLayout.vue', () => {
expect(text).toContain('Команда');
});
it('содержит все 6 nav-пунктов (Менеджеры+Напоминания убраны по требованию заказчика)', async () => {
it('содержит 6 nav-пунктов (Импорт данных + Отчёты убраны по требованию заказчика)', async () => {
const wrapper = await mountAppLayout();
const text = wrapper.text();
['Проекты', 'Сделки', 'Канбан', 'Дашборд', 'Биллинг', 'Отчёты', 'Настройки'].forEach((label) =>
['Проекты', 'Сделки', 'Канбан', 'Дашборд', 'Биллинг', 'Настройки'].forEach((label) =>
expect(text).toContain(label),
);
expect(text).not.toContain('Менеджеры');
expect(text).not.toContain('Напоминания');
expect(text).not.toContain('Импорт данных');
expect(text).not.toContain('Отчёты');
});
it('показывает счётчики только у пунктов с count', async () => {
+1 -1
View File
@@ -31,7 +31,7 @@ function makeSummary(overrides: Partial<DashboardSummary> = {}): DashboardSummar
active_projects: { active: 8, limit: 10 },
balance: { amount_rub: '14250.00', runway_days: 4, runway_leads: 285 },
activity: { points: [3, 5, 2, 8, 6, 9, 4], labels: ['сб', 'вс', 'пн', 'вт', 'ср', 'чт', 'сегодня'], max: 10 },
funnel: { new: 18, paid: 45 },
funnel: { new: 18, won: 45 },
...overrides,
};
}
+32 -80
View File
@@ -1,95 +1,47 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createPinia, setActivePinia } from 'pinia';
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
beforeEach(() => {
setActivePinia(createPinia());
});
const vuetify = createVuetify();
const deal: MockDeal = {
id: 1, name: '+7 999', phone: '+7 999', statusSlug: 'new', project: 'Окна',
manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 5,
};
// DealDetailDrawer использует v-navigation-drawer, который требует layout-
// контекст от v-app/v-layout. В Vitest auto-import недоступен — stub'им
// v-navigation-drawer как passthrough div чтобы slot-content рендерился
// и был доступен для assertion.
describe('DealDetailDrawer.vue', () => {
const factory = (props: { open: boolean; deal: (typeof MOCK_DEALS)[number] | null }) =>
mount(DealDetailDrawer, {
props,
global: {
plugins: [createVuetify()],
stubs: {
VNavigationDrawer: {
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
function mountDrawer(props: Record<string, unknown>) {
const pinia = createPinia();
setActivePinia(pinia);
return mount(DealDetailDrawer, {
props: { open: true, deal, ...props },
global: {
plugins: [vuetify, pinia],
stubs: {
DealDetailBody: true,
VNavigationDrawer: {
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
});
},
});
}
const sampleDeal = MOCK_DEALS[0]; // Анна Соколова
it('не рендерит контент когда open=false', () => {
const wrapper = factory({ open: false, deal: sampleDeal });
expect(wrapper.find('.drawer-stub').exists()).toBe(false);
describe('DealDetailDrawer wrapper', () => {
it('inline=true рендерит <aside> (master-detail панель)', () => {
const w = mountDrawer({ inline: true });
expect(w.find('aside.deal-detail-inline').exists()).toBe(true);
});
it('не рендерит контент когда deal=null (даже при open=true)', () => {
const wrapper = factory({ open: true, deal: null });
// Drawer открыт, но deal нет — content внутри v-if не рендерится.
const stub = wrapper.find('.drawer-stub');
if (stub.exists()) {
// Нет hero/section элементов внутри.
expect(wrapper.find('.hero').exists()).toBe(false);
}
it('inline=false (по умолчанию) рендерит overlay v-navigation-drawer', () => {
const w = mountDrawer({});
expect(w.find('aside.deal-detail-inline').exists()).toBe(false);
});
it('рендерит hero с именем сделки и id', () => {
const wrapper = factory({ open: true, deal: sampleDeal });
const text = wrapper.text();
expect(text).toContain(sampleDeal.name);
expect(text).toContain(`#${sampleDeal.id}`);
});
it('рендерит phone как кликабельную ссылку tel:', () => {
const wrapper = factory({ open: true, deal: sampleDeal });
const phoneLink = wrapper.find('.phone-link');
expect(phoneLink.exists()).toBe(true);
expect(phoneLink.attributes('href')).toMatch(/^tel:\+/);
expect(phoneLink.text()).toBe(sampleDeal.phone);
});
it('рендерит status-chip с nameRu статуса сделки', () => {
const wrapper = factory({ open: true, deal: sampleDeal });
// sampleDeal.statusSlug='new' → 'Новые'.
expect(wrapper.text()).toContain('Новые');
});
it('рендерит секцию параметров с проектом, стоимостью, менеджером', () => {
const wrapper = factory({ open: true, deal: sampleDeal });
const text = wrapper.text();
expect(text).toContain('Параметры');
expect(text).toContain(sampleDeal.project);
expect(text).toContain(sampleDeal.manager.name);
expect(text).toMatch(/1\s+850\s*₽/); // sampleDeal.cost = 1850
});
it('рендерит timeline без событий (без tenantId events пуст — I3)', () => {
const wrapper = factory({ open: true, deal: sampleDeal });
const items = wrapper.findAll('.timeline-item');
expect(items).toHaveLength(0);
});
it('emit-ит update:open=false при close-кнопке', async () => {
const wrapper = factory({ open: true, deal: sampleDeal });
// Vuetify v-btn рендерит как button. close-btn — единственный с aria-label.
const closeBtn = wrapper.find('button[aria-label="Закрыть панель"]');
if (closeBtn.exists()) {
await closeBtn.trigger('click');
expect(wrapper.emitted('update:open')).toBeTruthy();
expect(wrapper.emitted('update:open')?.[0]).toEqual([false]);
}
it('inline-панель содержит DealDetailBody', () => {
const w = mountDrawer({ inline: true });
expect(w.findComponent({ name: 'DealDetailBody' }).exists()).toBe(true);
});
});
+22 -25
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createPinia, setActivePinia } from 'pinia';
import DealDetailDrawer from '../../resources/js/components/deals/DealDetailDrawer.vue';
import DealDetailBody from '../../resources/js/components/deals/DealDetailBody.vue';
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
import type { GetDealResponse, ApiDealEvent } from '../../resources/js/api/deals';
@@ -22,17 +22,11 @@ beforeEach(() => {
setActivePinia(createPinia());
});
const factory = (props: { open: boolean; tenantId?: number }) =>
mount(DealDetailDrawer, {
const factory = (props: { tenantId?: number }) =>
mount(DealDetailBody, {
props: { deal: MOCK_DEALS[0], ...props },
global: {
plugins: [createVuetify()],
stubs: {
VNavigationDrawer: {
template: '<div class="drawer-stub" v-if="modelValue"><slot /></div>',
props: ['modelValue'],
},
},
},
});
@@ -47,9 +41,9 @@ function makeApiEvent(overrides: Partial<ApiDealEvent> = {}): ApiDealEvent {
};
}
describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
describe('DealDetailBody ↔ GET /api/deals/{id} integration', () => {
it('БЕЗ tenantId — getDeal не вызывается, events пуст (I3)', async () => {
const wrapper = factory({ open: true });
const wrapper = factory({});
await flushPromises();
expect(dealsApi.getDeal).not.toHaveBeenCalled();
@@ -67,6 +61,9 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
phone: '+7 (999) 100-00-01',
contact_name: 'Anna',
comment: null,
city: null,
project_signal_type: null,
next_reminder_at: null,
status: 'new',
manager_id: null,
manager_name: null,
@@ -79,27 +76,27 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
makeApiEvent({
id: 2,
event: 'deal.status_changed',
context: { from: 'new', to: 'paid' },
context: { from: 'new', to: 'won' },
actor: { id: 1, name: 'Иван П.', initials: 'ИП' },
}),
],
};
vi.mocked(dealsApi.getDeal).mockResolvedValueOnce(apiResponse);
const wrapper = factory({ open: true, tenantId: 1 });
const wrapper = factory({ tenantId: 1 });
await flushPromises();
expect(dealsApi.getDeal).toHaveBeenCalledWith(MOCK_DEALS[0].id, 1);
const items = wrapper.findAll('.timeline-item');
expect(items).toHaveLength(2);
// status_changed event имеет detail "new → paid".
expect(wrapper.text()).toContain('new → paid');
// status_changed event имеет detail "new → won".
expect(wrapper.text()).toContain('new → won');
});
it('getDeal reject → eventsFetchError=true, alert виден, events пуст (I3)', async () => {
vi.mocked(dealsApi.getDeal).mockRejectedValueOnce(new Error('500'));
const wrapper = factory({ open: true, tenantId: 1 });
const wrapper = factory({ tenantId: 1 });
await flushPromises();
const vm = wrapper.vm as unknown as { eventsFetchError: boolean };
@@ -110,12 +107,6 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
expect(items).toHaveLength(0);
});
it('open=false → getDeal не вызывается', async () => {
factory({ open: false, tenantId: 1 });
await flushPromises();
expect(dealsApi.getDeal).not.toHaveBeenCalled();
});
it('saveComment вызывает updateDeal + toast success + reload events', async () => {
const apiResponse: GetDealResponse = {
deal: {
@@ -126,6 +117,9 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
phone: '+7 (999) 100-00-01',
contact_name: 'Anna',
comment: 'old',
city: null,
project_signal_type: null,
next_reminder_at: null,
status: 'new',
manager_id: null,
manager_name: null,
@@ -141,7 +135,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
comment: 'новая заметка',
});
const wrapper = factory({ open: true, tenantId: 1 });
const wrapper = factory({ tenantId: 1 });
await flushPromises();
const vm = wrapper.vm as unknown as {
@@ -177,6 +171,9 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
phone: '+7 (999)',
contact_name: null,
comment: null,
city: null,
project_signal_type: null,
next_reminder_at: null,
status: 'new',
manager_id: null,
manager_name: null,
@@ -188,7 +185,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
});
vi.mocked(dealsApi.updateDeal).mockRejectedValueOnce(new Error('500'));
const wrapper = factory({ open: true, tenantId: 1 });
const wrapper = factory({ tenantId: 1 });
await flushPromises();
const vm = wrapper.vm as unknown as {
@@ -206,7 +203,7 @@ describe('DealDetailDrawer ↔ GET /api/deals/{id} integration', () => {
});
it('comment-section не показывается без tenantId (read-only mode)', async () => {
const wrapper = factory({ open: true });
const wrapper = factory({});
await flushPromises();
expect(wrapper.find('[data-testid="comment-section"]').exists()).toBe(false);
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import DealsBulkBar from '../../resources/js/components/deals/DealsBulkBar.vue';
const vuetify = createVuetify();
const leadStatuses = [
{ slug: 'new', nameRu: 'Новые', isSystem: true, sortOrder: 1, colorHex: '#3B82F6' },
{ slug: 'viewed', nameRu: 'Просмотрено', isSystem: true, sortOrder: 2, colorHex: '#8B5CF6' },
];
describe('DealsBulkBar', () => {
it('скрыт при selectedCount=0', () => {
const w = mount(DealsBulkBar, {
props: { selectedCount: 0, statusMenuOpen: false, leadStatuses },
global: { plugins: [vuetify] },
});
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(false);
});
it('виден при selectedCount>0 и показывает количество', () => {
const w = mount(DealsBulkBar, {
props: { selectedCount: 3, statusMenuOpen: false, leadStatuses },
global: { plugins: [vuetify] },
});
const bar = w.find('[data-testid="bulk-bar"]');
expect(bar.exists()).toBe(true);
expect(bar.text()).toContain('3');
});
it('НЕ содержит кнопок Экспорт/Удалить', () => {
const w = mount(DealsBulkBar, {
props: { selectedCount: 2, statusMenuOpen: false, leadStatuses },
global: { plugins: [vuetify] },
});
expect(w.find('[data-testid="bulk-export-btn"]').exists()).toBe(false);
expect(w.find('[data-testid="bulk-delete-btn"]').exists()).toBe(false);
});
it('✕ эмитит clear-selected', async () => {
const w = mount(DealsBulkBar, {
props: { selectedCount: 2, statusMenuOpen: false, leadStatuses },
global: { plugins: [vuetify] },
});
await w.find('[data-testid="bulk-clear-btn"]').trigger('click');
expect(w.emitted('clear-selected')).toBeTruthy();
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import DealsFilters from '../../resources/js/components/deals/DealsFilters.vue';
const vuetify = createVuetify();
const baseProps = {
searchPhone: '',
filterStatus: null,
filterProject: null,
filterCity: null,
leadStatuses: [{ slug: 'new', nameRu: 'Новые', isSystem: true, sortOrder: 1, colorHex: '#000' }],
availableProjects: [{ id: 1, name: 'Окна' }],
availableCities: [] as string[],
};
describe('DealsFilters', () => {
it('рендерит поле поиска по телефону', () => {
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
expect(w.find('[data-testid="filter-search-phone"]').exists()).toBe(true);
});
it('эмитит update:searchPhone при вводе', async () => {
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
await w.find('[data-testid="filter-search-phone"] input').setValue('999');
expect(w.emitted('update:searchPhone')?.at(-1)).toEqual(['999']);
});
it('город-селект disabled при пустом availableCities', () => {
const w = mount(DealsFilters, { props: baseProps, global: { plugins: [vuetify] } });
expect(w.find('[data-testid="filter-city"]').classes()).toContain('v-input--disabled');
});
it('кнопка сброса видна когда есть активный фильтр', () => {
const w = mount(DealsFilters, {
props: { ...baseProps, filterStatus: 'new' },
global: { plugins: [vuetify] },
});
expect(w.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
});
});
+21 -424
View File
@@ -7,7 +7,6 @@ import DealsView from '../../resources/js/views/DealsView.vue';
import KanbanView from '../../resources/js/views/KanbanView.vue';
import { useAuthStore } from '../../resources/js/stores/auth';
import type { ApiDeal } from '../../resources/js/api/deals';
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
vi.mock('../../resources/js/api/deals', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/deals')>();
@@ -17,10 +16,6 @@ vi.mock('../../resources/js/api/deals', async (importOriginal) => {
listManagers: vi.fn().mockResolvedValue([]),
listProjects: vi.fn().mockResolvedValue([]),
transitionDeals: vi.fn(),
exportDeals: vi.fn(),
exportDealsXlsx: vi.fn(),
bulkDeleteDeals: vi.fn(),
bulkRestoreDeals: vi.fn(),
};
});
@@ -39,6 +34,10 @@ function makeApiDeal(overrides: Partial<ApiDeal> = {}): ApiDeal {
manager_name: 'Иван П.',
manager_initials: 'ИП',
received_at: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
comment: null,
city: null,
project_signal_type: null,
next_reminder_at: null,
...overrides,
};
}
@@ -73,7 +72,7 @@ const mountDealsView = async () => {
return mount(DealsView, {
global: {
plugins: [createVuetify(), router],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
stubs: { DealDetailDrawer: true },
},
});
};
@@ -100,11 +99,11 @@ describe('DealsView ↔ GET /api/deals integration', () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [
makeApiDeal({ id: 200, contact_name: 'Из API #1', status: 'paid' }),
makeApiDeal({ id: 200, contact_name: 'Из API #1', status: 'won' }),
makeApiDeal({ id: 201, contact_name: 'Из API #2', status: 'new' }),
],
total: 2,
limit: 200,
limit: 20,
offset: 0,
});
@@ -112,7 +111,7 @@ describe('DealsView ↔ GET /api/deals integration', () => {
await flushPromises();
expect(dealsApi.listDeals).toHaveBeenCalledTimes(1);
expect(dealsApi.listDeals).toHaveBeenCalledWith(expect.objectContaining({ tenantId: 1, limit: 200 }));
expect(dealsApi.listDeals).toHaveBeenCalledWith(expect.objectContaining({ tenantId: 1, limit: 20 }));
const vm = wrapper.vm as unknown as { dealsState: { id: number; name: string }[] };
expect(vm.dealsState).toHaveLength(2);
@@ -134,88 +133,12 @@ describe('DealsView ↔ GET /api/deals integration', () => {
expect(wrapper.find('[data-testid="fetch-error-alert"]').exists()).toBe(true);
});
it('toggleTrashMode переключает trashMode + listDeals вызывается с onlyDeleted=true', async () => {
setupAuth(1);
// Начальный fetch (нормальный режим)
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 600 })],
total: 1,
limit: 200,
offset: 0,
});
// После toggle в trash
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 700, contact_name: 'Удалённый' })],
total: 1,
limit: 200,
offset: 0,
});
const wrapper = await mountDealsView();
await flushPromises();
expect(dealsApi.listDeals).toHaveBeenCalledTimes(1);
expect(dealsApi.listDeals).toHaveBeenLastCalledWith(
expect.objectContaining({ tenantId: 1, onlyDeleted: false }),
);
const vm = wrapper.vm as unknown as {
trashMode: boolean;
toggleTrashMode: () => void;
dealsState: { id: number }[];
};
vm.toggleTrashMode();
await flushPromises();
expect(vm.trashMode).toBe(true);
expect(dealsApi.listDeals).toHaveBeenCalledTimes(2);
expect(dealsApi.listDeals).toHaveBeenLastCalledWith(
expect.objectContaining({ tenantId: 1, onlyDeleted: true }),
);
expect(vm.dealsState.find((d) => d.id === 700)).toBeDefined();
});
it('applyBulkRestoreFromTrash восстанавливает + убирает из dealsState', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 800 }), makeApiDeal({ id: 801 })],
total: 2,
limit: 200,
offset: 0,
});
vi.mocked(dealsApi.bulkRestoreDeals).mockResolvedValueOnce({
restored: 2,
requested: 2,
});
const wrapper = await mountDealsView();
await flushPromises();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkRestoreFromTrash: () => Promise<void>;
dealsState: { id: number }[];
deleteToastText: string;
};
vm.selected = [800, 801];
await flushPromises();
await vm.applyBulkRestoreFromTrash();
await flushPromises();
expect(dealsApi.bulkRestoreDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [800, 801] });
// Восстановленные убраны из текущего trash-списка.
expect(vm.dealsState.find((d) => d.id === 800)).toBeUndefined();
expect(vm.dealsState.find((d) => d.id === 801)).toBeUndefined();
expect(vm.deleteToastText).toContain('Восстановлено 2');
});
it('reload-btn повторно вызывает listDeals', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValue({
deals: [makeApiDeal({ id: 400 })],
total: 1,
limit: 200,
limit: 20,
offset: 0,
});
@@ -233,13 +156,13 @@ describe('DealsView ↔ GET /api/deals integration', () => {
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 500, status: 'new' }), makeApiDeal({ id: 501, status: 'new' })],
total: 2,
limit: 200,
limit: 20,
offset: 0,
});
vi.mocked(dealsApi.transitionDeals).mockResolvedValueOnce({
updated: 2,
requested: 2,
status: 'paid',
status: 'won',
});
const wrapper = await mountDealsView();
@@ -255,353 +178,27 @@ describe('DealsView ↔ GET /api/deals integration', () => {
vm.selected = [500, 501];
await flushPromises();
await vm.applyBulkStatus('paid');
await vm.applyBulkStatus('won');
await flushPromises();
// Optimistic local-update применился до завершения API-вызова.
expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('paid');
expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('paid');
expect(vm.dealsState.find((d) => d.id === 500)?.statusSlug).toBe('won');
expect(vm.dealsState.find((d) => d.id === 501)?.statusSlug).toBe('won');
expect(dealsApi.transitionDeals).toHaveBeenCalledWith({
tenant_id: 1,
ids: [500, 501],
status: 'paid',
status: 'won',
});
expect(vm.statusToastOpen).toBe(true);
expect(vm.statusToastText).toContain('Обновлено 2');
});
it('applyBulkStatus БЕЗ tenant_id — только локальный update, transitionDeals НЕ вызывается', async () => {
setupAuth(null);
const wrapper = await mountDealsView();
await flushPromises();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkStatus: (slug: string) => Promise<void>;
dealsState: { id: number; statusSlug: string }[];
};
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
await flushPromises();
vm.selected = [1];
await flushPromises();
await vm.applyBulkStatus('paid');
await flushPromises();
expect(dealsApi.transitionDeals).not.toHaveBeenCalled();
expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('paid');
});
it('applyBulkExport(xlsx) с tenant_id вызывает exportDealsXlsx и триггерит download', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 700 })],
total: 1,
limit: 200,
offset: 0,
});
const fakeBlob = new Blob(['fake xlsx'], { type: 'application/octet-stream' });
vi.mocked(dealsApi.exportDealsXlsx).mockResolvedValueOnce(fakeBlob);
const createUrlSpy = vi.fn(() => 'blob:xlsx');
const revokeSpy = vi.fn();
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
Object.defineProperty(URL, 'revokeObjectURL', { value: revokeSpy, configurable: true });
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const wrapper = await mountDealsView();
await flushPromises();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkExport: (format?: string) => Promise<void>;
exportToastText: string;
};
vm.selected = [700];
await flushPromises();
await vm.applyBulkExport(); // default = xlsx
await flushPromises();
expect(dealsApi.exportDealsXlsx).toHaveBeenCalledWith({
tenant_id: 1,
ids: [700],
});
expect(dealsApi.exportDeals).not.toHaveBeenCalled();
expect(createUrlSpy).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(vm.exportToastText).toContain('XLSX');
clickSpy.mockRestore();
});
it('applyBulkExport(csv) с tenant_id вызывает exportDeals (CSV branch)', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 701 })],
total: 1,
limit: 200,
offset: 0,
});
vi.mocked(dealsApi.exportDeals).mockResolvedValueOnce('id;...');
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:'), configurable: true });
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const wrapper = await mountDealsView();
await flushPromises();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkExport: (format?: string) => Promise<void>;
exportToastText: string;
};
vm.selected = [701];
await flushPromises();
await vm.applyBulkExport('csv');
await flushPromises();
expect(dealsApi.exportDeals).toHaveBeenCalledTimes(1);
expect(dealsApi.exportDealsXlsx).not.toHaveBeenCalled();
expect(vm.exportToastText).toContain('CSV');
clickSpy.mockRestore();
});
it('applyBulkExport(xlsx) reject → fallback на local CSV', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 702 })],
total: 1,
limit: 200,
offset: 0,
});
vi.mocked(dealsApi.exportDealsXlsx).mockRejectedValueOnce(new Error('500'));
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:'), configurable: true });
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const wrapper = await mountDealsView();
await flushPromises();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkExport: () => Promise<void>;
exportToastText: string;
};
vm.selected = [702];
await flushPromises();
await vm.applyBulkExport();
await flushPromises();
expect(vm.exportToastText).toContain('Backend недоступен');
// local CSV всё равно стриггерил download
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
it('applyBulkDelete с tenant_id вызывает bulkDeleteDeals + optimistic local-removal', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 800, status: 'new' }), makeApiDeal({ id: 801, status: 'new' })],
total: 2,
limit: 200,
offset: 0,
});
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({
deleted: 2,
requested: 2,
});
const wrapper = await mountDealsView();
await flushPromises();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkDelete: () => Promise<void>;
dealsState: { id: number }[];
deleteToastOpen: boolean;
deleteToastText: string;
};
vm.selected = [800, 801];
await flushPromises();
await vm.applyBulkDelete();
await flushPromises();
// Optimistic — обе сделки убраны из state.
expect(vm.dealsState.find((d) => d.id === 800)).toBeUndefined();
expect(vm.dealsState.find((d) => d.id === 801)).toBeUndefined();
expect(dealsApi.bulkDeleteDeals).toHaveBeenCalledWith({
tenant_id: 1,
ids: [800, 801],
});
expect(vm.deleteToastOpen).toBe(true);
expect(vm.deleteToastText).toContain('Удалено 2');
});
it('applyBulkDelete без tenant_id — только локально, bulkDeleteDeals НЕ вызывается', async () => {
setupAuth(null);
const wrapper = await mountDealsView();
await flushPromises();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkDelete: () => Promise<void>;
dealsState: { id: number }[];
};
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
await flushPromises();
const before = vm.dealsState.length;
vm.selected = [1, 2];
await flushPromises();
await vm.applyBulkDelete();
await flushPromises();
expect(dealsApi.bulkDeleteDeals).not.toHaveBeenCalled();
expect(vm.dealsState.length).toBe(before - 2);
});
it('applyBulkDelete reject → warning toast, локальный update остаётся (не откатываем)', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 900 })],
total: 1,
limit: 200,
offset: 0,
});
vi.mocked(dealsApi.bulkDeleteDeals).mockRejectedValueOnce(new Error('500'));
const wrapper = await mountDealsView();
await flushPromises();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkDelete: () => Promise<void>;
dealsState: { id: number }[];
deleteToastText: string;
};
vm.selected = [900];
await flushPromises();
await vm.applyBulkDelete();
await flushPromises();
expect(vm.dealsState.find((d) => d.id === 900)).toBeUndefined(); // optimistic
expect(vm.deleteToastText).toContain('Не удалось');
});
it('bulk-delete + undo восстанавливает сделки + вызывает bulkRestoreDeals', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 1000, contact_name: 'A' }), makeApiDeal({ id: 1001, contact_name: 'B' })],
total: 2,
limit: 200,
offset: 0,
});
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({
deleted: 2,
requested: 2,
});
vi.mocked(dealsApi.bulkRestoreDeals).mockResolvedValueOnce({
restored: 2,
requested: 2,
});
const wrapper = await mountDealsView();
await flushPromises();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkDelete: () => Promise<void>;
undoBulkDelete: () => Promise<void>;
dealsState: { id: number }[];
lastDeletedSnapshot: { id: number }[];
deleteToastText: string;
};
// Удаляем
vm.selected = [1000, 1001];
await flushPromises();
await vm.applyBulkDelete();
await flushPromises();
expect(vm.dealsState.find((d) => d.id === 1000)).toBeUndefined();
expect(vm.lastDeletedSnapshot).toHaveLength(2);
// Undo
await vm.undoBulkDelete();
await flushPromises();
expect(dealsApi.bulkRestoreDeals).toHaveBeenCalledWith({ tenant_id: 1, ids: [1000, 1001] });
expect(vm.dealsState.find((d) => d.id === 1000)).toBeDefined();
expect(vm.dealsState.find((d) => d.id === 1001)).toBeDefined();
expect(vm.lastDeletedSnapshot).toHaveLength(0); // cleared after undo
expect(vm.deleteToastText).toContain('Восстановлено 2');
});
it('undoBulkDelete без tenant_id — только локально, bulkRestoreDeals НЕ вызывается', async () => {
setupAuth(null);
const wrapper = await mountDealsView();
await flushPromises();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkDelete: () => Promise<void>;
undoBulkDelete: () => Promise<void>;
dealsState: { id: number }[];
lastDeletedSnapshot: { id: number }[];
};
// Засеваем dealsState mock-фикстурой (после I3 init пустой)
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
await flushPromises();
const sample = vm.dealsState[0];
vm.selected = [sample.id];
await flushPromises();
await vm.applyBulkDelete();
await flushPromises();
expect(vm.dealsState.find((d) => d.id === sample.id)).toBeUndefined();
await vm.undoBulkDelete();
await flushPromises();
expect(dealsApi.bulkRestoreDeals).not.toHaveBeenCalled();
expect(vm.dealsState.find((d) => d.id === sample.id)).toBeDefined();
});
it('undoBulkDelete reject → warning toast, локальное восстановление остаётся', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 1100 })],
total: 1,
limit: 200,
offset: 0,
});
vi.mocked(dealsApi.bulkDeleteDeals).mockResolvedValueOnce({ deleted: 1, requested: 1 });
vi.mocked(dealsApi.bulkRestoreDeals).mockRejectedValueOnce(new Error('500'));
const wrapper = await mountDealsView();
await flushPromises();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkDelete: () => Promise<void>;
undoBulkDelete: () => Promise<void>;
dealsState: { id: number }[];
deleteToastText: string;
};
vm.selected = [1100];
await flushPromises();
await vm.applyBulkDelete();
await flushPromises();
await vm.undoBulkDelete();
await flushPromises();
expect(vm.dealsState.find((d) => d.id === 1100)).toBeDefined(); // optimistic
expect(vm.deleteToastText).toContain('Не удалось восстановить');
});
it('applyBulkStatus с reject → toast с warning, локальный update остаётся (не откатываем)', async () => {
setupAuth(1);
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [makeApiDeal({ id: 600, status: 'new' })],
total: 1,
limit: 200,
limit: 20,
offset: 0,
});
vi.mocked(dealsApi.transitionDeals).mockRejectedValueOnce(new Error('500'));
@@ -617,10 +214,10 @@ describe('DealsView ↔ GET /api/deals integration', () => {
};
vm.selected = [600];
await flushPromises();
await vm.applyBulkStatus('paid');
await vm.applyBulkStatus('won');
await flushPromises();
expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('paid');
expect(vm.dealsState.find((d) => d.id === 600)?.statusSlug).toBe('won');
expect(vm.statusToastText).toContain('Не удалось');
});
});
@@ -638,8 +235,8 @@ describe('KanbanView ↔ GET /api/deals integration', () => {
vi.mocked(dealsApi.listDeals).mockResolvedValueOnce({
deals: [
makeApiDeal({ id: 300, status: 'new' }),
makeApiDeal({ id: 301, status: 'paid' }),
makeApiDeal({ id: 302, status: 'paid' }),
makeApiDeal({ id: 301, status: 'won' }),
makeApiDeal({ id: 302, status: 'won' }),
],
total: 3,
limit: 500,
@@ -655,7 +252,7 @@ describe('KanbanView ↔ GET /api/deals integration', () => {
fetchError: boolean;
};
expect(vm.dealsByStatus.new.map((d) => d.id)).toEqual([300]);
expect(vm.dealsByStatus.paid.map((d) => d.id).sort()).toEqual([301, 302]);
expect(vm.dealsByStatus.won.map((d) => d.id).sort()).toEqual([301, 302]);
expect(vm.totalDeals).toBe(3);
expect(vm.fetchError).toBe(false);
});
+37 -34
View File
@@ -1,7 +1,6 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import DealsTable from '../../resources/js/components/deals/DealsTable.vue';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
@@ -9,51 +8,55 @@ const vuetify = createVuetify();
const sampleDeals: MockDeal[] = [
{
id: 1,
name: 'Иванов И.',
phone: '+7 (916) 100-00-01',
statusSlug: 'new',
project: 'B1 site',
manager: { initials: 'AD', name: 'Admin' },
cost: 1000,
receivedMinutesAgo: 5,
id: 1, name: '+7 (916) 100-00-01', phone: '+7 (916) 100-00-01', statusSlug: 'new',
project: 'Окна', manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 5,
signalType: 'call', city: 'Москва', comment: 'звонил', receivedAt: '2026-05-15T09:00:00+00:00',
nextReminderAt: '2026-05-18T07:00:00+00:00',
},
{
id: 2,
name: 'Петров П.',
phone: '+7 (916) 100-00-02',
statusSlug: 'new',
project: 'B1 call',
manager: { initials: 'AD', name: 'Admin' },
cost: 1500,
receivedMinutesAgo: 30,
id: 2, name: '+7 (916) 100-00-02', phone: '+7 (916) 100-00-02', statusSlug: 'new',
project: 'Двери', manager: { initials: 'AD', name: 'Admin' }, cost: 0, receivedMinutesAgo: 30,
signalType: 'site', city: null, comment: null, receivedAt: '2026-05-14T09:00:00+00:00',
nextReminderAt: null,
},
];
describe('DealsTable a11y (Q.DEFER.004 sub-A)', () => {
it('select-all header checkbox has aria-label', () => {
const wrapper = mount(DealsTable, {
describe('DealsTable', () => {
it('рендерит колонки реестра лидов', () => {
const w = mount(DealsTable, {
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
global: { plugins: [vuetify] },
});
const headerCheckbox = wrapper.find(
'th .v-selection-control input[type="checkbox"][aria-label="Выбрать все сделки"]',
);
expect(headerCheckbox.exists()).toBe(true);
const headers = w.findAll('thead th').map((h) => h.text());
['Телефон', 'Источник', 'Город', 'Статус', 'Напоминание', 'Комментарий', 'Поставлен'].forEach((label) => {
expect(headers.some((h) => h.includes(label))).toBe(true);
});
});
it('each row checkbox has aria-label referencing deal name', () => {
const wrapper = mount(DealsTable, {
it('город без значения рендерится как «—»', () => {
const w = mount(DealsTable, {
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
global: { plugins: [vuetify] },
});
const rowCheckbox1 = wrapper.find(
'tbody tr:nth-of-type(1) input[type="checkbox"][aria-label="Выбрать сделку «Иванов И.»"]',
);
const rowCheckbox2 = wrapper.find(
'tbody tr:nth-of-type(2) input[type="checkbox"][aria-label="Выбрать сделку «Петров П.»"]',
);
expect(rowCheckbox1.exists()).toBe(true);
expect(rowCheckbox2.exists()).toBe(true);
expect(w.text()).toContain('—');
});
it('select-all чекбокс имеет aria-label', () => {
const w = mount(DealsTable, {
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
global: { plugins: [vuetify] },
});
expect(
w.find('th .v-selection-control input[type="checkbox"][aria-label="Выбрать все сделки"]').exists(),
).toBe(true);
});
it('клик по строке эмитит row-click с deal', async () => {
const w = mount(DealsTable, {
props: { deals: sampleDeals, selectedIds: [], statusBySlug: new Map() },
global: { plugins: [vuetify] },
});
await w.find('tbody tr').trigger('click');
expect(w.emitted('row-click')?.[0]?.[0]).toMatchObject({ id: 1 });
});
});
+102 -394
View File
@@ -1,447 +1,155 @@
import { describe, it, test, expect, vi, afterEach } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import { createPinia, setActivePinia } from 'pinia';
import DealsView from '../../resources/js/views/DealsView.vue';
import { MOCK_DEALS, type MockDeal } from '../../resources/js/composables/mockDeals';
import * as dealsApi from '../../resources/js/api/deals';
import { useAuthStore } from '../../resources/js/stores/auth';
import type { AuthUser } from '../../resources/js/api/auth';
import type { MockDeal } from '../../resources/js/composables/mockDeals';
// Smoke-тесты DealsView с mock-данными.
/** Засевает dealsState фикстурой MOCK_DEALS (имитирует успешный API-ответ). */
function seedDealsState(wrapper: ReturnType<typeof mount>) {
const vm = wrapper.vm as unknown as { dealsState: MockDeal[] };
vm.dealsState.push(...MOCK_DEALS.map((d) => ({ ...d, manager: { ...d.manager } })));
function apiDeal(id: number, over: Partial<dealsApi.ApiDeal> = {}): dealsApi.ApiDeal {
return {
id, tenant_id: 42, project_id: 1, project_name: 'Окна', phone: `+7 916 000-00-0${id}`,
contact_name: null, status: 'new', manager_id: null, manager_name: null,
manager_initials: null, received_at: '2026-05-15T09:00:00+00:00',
comment: null, city: null, project_signal_type: 'call', next_reminder_at: null,
...over,
};
}
const mountDeals = async () => {
async function mountDeals(deals: dealsApi.ApiDeal[] = [apiDeal(1), apiDeal(2)], total = 2) {
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
const dealsSpy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals, total, limit: 20, offset: 0 });
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([
{ id: 1, name: 'Окна', tag: null, type: 'supplier' },
]);
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/deals', component: DealsView }],
});
await router.push('/deals');
await router.isReady();
// DealsView содержит DealDetailDrawer (v-navigation-drawer), который требует
// injected layout от v-app — оборачиваем компонент в v-app для теста.
// DealsView содержит DealDetailDrawer (v-navigation-drawer), который требует
// layout-injection от v-app. В Vitest vite-plugin-vuetify auto-import не
// работает, layout-context недоступен. Stub'им сам Drawer (тестируется
// отдельно в DealDetailDrawer.spec.ts).
const wrapper = mount(DealsView, {
global: {
plugins: [createVuetify(), router],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
},
global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } },
});
await flushPromises();
seedDealsState(wrapper);
await flushPromises();
// Reset call history so subsequent vi.spyOn calls in tests start from count=0.
dealsSpy.mockClear();
return wrapper;
};
}
/** Audit C8/F3: монтирует DealsView по произвольному пути (с query-параметрами). */
const mountDealsViewAt = async (path: string) => {
setActivePinia(createPinia());
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/deals', component: DealsView }],
});
await router.push(path);
await router.isReady();
const wrapper = mount(DealsView, {
global: {
plugins: [createVuetify(), router],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
},
});
await flushPromises();
seedDealsState(wrapper);
await flushPromises();
return wrapper;
};
describe('DealsView.vue', () => {
it('монтируется и содержит заголовок «Сделки»', async () => {
const wrapper = await mountDeals();
expect(wrapper.find('h1').text()).toBe('Сделки');
describe('DealsView.vue — реестр лидов', () => {
it('заголовок «Сделки»', async () => {
expect((await mountDeals()).find('h1').text()).toBe('Сделки');
});
it('содержит page-stats с числами всего/в работе/ждут оплату', async () => {
const wrapper = await mountDeals();
const text = wrapper.text();
expect(text).toContain('новых лида с утра');
expect(text).toContain('всего');
expect(text).toContain('в работе');
expect(text).toContain('ждут оплату');
it('панель экспорта: поля дат + кнопки Excel/CSV', async () => {
const w = await mountDeals();
expect(w.find('[data-testid="export-from"]').exists()).toBe(true);
expect(w.find('[data-testid="export-to"]').exists()).toBe(true);
expect(w.find('[data-testid="export-xlsx-btn"]').exists()).toBe(true);
expect(w.find('[data-testid="export-csv-btn"]').exists()).toBe(true);
});
it('содержит ровно 5 chiprow-tabs', async () => {
const wrapper = await mountDeals();
const text = wrapper.text();
['Все', 'Активные', 'Ждут оплату', 'Закрытые', 'Невалидные'].forEach((label) => expect(text).toContain(label));
it('селектор «Показывать по» с вариантами 10/20/50', async () => {
const w = await mountDeals();
const toggle = w.find('[data-testid="perpage-toggle"]');
expect(toggle.exists()).toBe(true);
['10', '20', '50'].forEach((n) => expect(toggle.text()).toContain(n));
});
it('по умолчанию активен таб «Активные», показывает только active-сделки', async () => {
const wrapper = await mountDeals();
await flushPromises();
const activeStatuses = ['new', 'viewed', 'worked', 'negotiations', 'hot'];
const expectedCount = MOCK_DEALS.filter((d) => activeStatuses.includes(d.statusSlug)).length;
const rows = wrapper.findAll('tbody tr');
expect(rows).toHaveLength(expectedCount);
it('НЕТ кнопки «Новая сделка» и режима «Корзина»', async () => {
const w = await mountDeals();
// «Новая сделка» присутствует как статус-пилюля в таблице (slug `new` —
// редизайн воронки 2026-05-17), поэтому проверяем отсутствие именно
// КНОПКИ создания сделки: ручное создание убрано в реестре лидов.
const buttons = w.findAll('button');
expect(buttons.some((b) => b.text().includes('Новая сделка'))).toBe(false);
expect(w.text()).not.toContain('Корзина');
});
it('содержит кнопки Экспорт и Новая сделка', async () => {
const wrapper = await mountDeals();
const text = wrapper.text();
expect(text).toContain('Экспорт');
expect(text).toContain('Новая сделка');
it('загружает сделки в dealsState через API', async () => {
const w = await mountDeals([apiDeal(1), apiDeal(2), apiDeal(3)], 3);
const vm = w.vm as unknown as { dealsState: MockDeal[]; total: number };
expect(vm.dealsState.length).toBe(3);
expect(vm.total).toBe(3);
});
it('таблица содержит колонки Лид/Статус/Проект/Менеджер/Стоимость/Время', async () => {
const wrapper = await mountDeals();
const headers = wrapper.findAll('thead th').map((h) => h.text());
['Лид', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Время'].forEach((label) => {
expect(headers.some((h) => h.includes(label))).toBe(true);
});
it('openPanel выбирает сделку, повторный клик закрывает', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
dealsState: MockDeal[]; panelOpen: boolean; selectedDeal: MockDeal | null;
openPanel: (d: MockDeal) => void;
};
vm.openPanel(vm.dealsState[0]);
expect(vm.panelOpen).toBe(true);
expect(vm.selectedDeal?.id).toBe(1);
vm.openPanel(vm.dealsState[0]);
expect(vm.panelOpen).toBe(false);
});
it('форматирует стоимость как «N ₽» с разделителем тысяч', async () => {
const wrapper = await mountDeals();
const text = wrapper.text();
// Intl.NumberFormat('ru-RU') использует non-breaking space (U+00A0) или
// narrow nbsp (U+202F) как разделитель тысяч, не ASCII-пробел. Явные
// \u-escape'ы — иначе ESLint ругается no-irregular-whitespace.
expect(text).toMatch(/2\s+400\s*₽/);
});
it('форматирует «время с момента» как «N мин назад» для свежих сделок', async () => {
const wrapper = await mountDeals();
const text = wrapper.text();
expect(text).toContain('7 мин назад');
});
it('bulk-bar скрыт когда selected пустой; виден когда selected не пустой', async () => {
const wrapper = await mountDeals();
await flushPromises();
// По умолчанию ничего не выбрано
expect(wrapper.find('[data-testid="bulk-bar"]').exists()).toBe(false);
// Симулируем выбор через v-model: selected
const vm = wrapper.vm as unknown as { selected: number[] };
it('bulk-bar появляется при выборе и applyBulkStatus меняет статус', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as {
selected: number[]; dealsState: MockDeal[]; applyBulkStatus: (s: string) => Promise<void>;
};
vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({ updated: 2, requested: 2, status: 'viewed' });
vm.selected = [1, 2];
await flushPromises();
const bar = wrapper.find('[data-testid="bulk-bar"]');
expect(bar.exists()).toBe(true);
expect(bar.text()).toContain('Выбрано');
expect(bar.text()).toContain('2');
expect(w.find('[data-testid="bulk-bar"]').exists()).toBe(true);
await vm.applyBulkStatus('viewed');
expect(vm.dealsState.find((d) => d.id === 1)?.statusSlug).toBe('viewed');
});
it('bulk-status: применение нового статуса меняет statusSlug у выбранных сделок и закрывает меню', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
selected: number[];
dealsState: Array<{ id: number; statusSlug: string }>;
applyBulkStatus: (slug: string) => void;
};
vm.selected = [1, 2];
await flushPromises();
// До применения — id=1 'new', id=2 'worked' (из MOCK_DEALS)
const before1 = vm.dealsState.find((d) => d.id === 1)!.statusSlug;
expect(before1).toBe('new');
vm.applyBulkStatus('paid');
await flushPromises();
expect(vm.dealsState.find((d) => d.id === 1)!.statusSlug).toBe('paid');
expect(vm.dealsState.find((d) => d.id === 2)!.statusSlug).toBe('paid');
});
it('bulk-delete: confirm удаляет выбранные сделки и сбрасывает selected', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
selected: number[];
dealsState: Array<{ id: number }>;
applyBulkDelete: () => void;
};
const before = vm.dealsState.length;
vm.selected = [1, 3];
await flushPromises();
vm.applyBulkDelete();
await flushPromises();
expect(vm.dealsState.length).toBe(before - 2);
expect(vm.dealsState.find((d) => d.id === 1)).toBeUndefined();
expect(vm.dealsState.find((d) => d.id === 3)).toBeUndefined();
expect(vm.selected).toEqual([]);
});
it('bulk-export: показывает toast с количеством выбранных сделок + триггерит CSV-download', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkExport: () => void;
exportToastOpen: boolean;
exportToastText: string;
};
// Шпион на createObjectURL — в jsdom он бывает не определён, заменим.
const createUrlSpy = vi.fn(() => 'blob:mock');
const revokeUrlSpy = vi.fn();
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
Object.defineProperty(URL, 'revokeObjectURL', { value: revokeUrlSpy, configurable: true });
// Подменяем click() на якоре чтобы не словить navigation в jsdom.
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
vm.selected = [1, 2, 3, 4];
await flushPromises();
vm.applyBulkExport();
expect(createUrlSpy).toHaveBeenCalledTimes(1);
expect(clickSpy).toHaveBeenCalledTimes(1);
it('exportByRange xlsx вызывает exportDealsByRange', async () => {
const w = await mountDeals();
const spy = vi.spyOn(dealsApi, 'exportDealsByRange').mockResolvedValue(new Blob());
Object.defineProperty(URL, 'createObjectURL', { value: vi.fn(() => 'blob:m'), configurable: true });
Object.defineProperty(URL, 'revokeObjectURL', { value: vi.fn(), configurable: true });
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
const vm = w.vm as unknown as { exportByRange: (f: string) => Promise<void>; exportToastOpen: boolean };
await vm.exportByRange('xlsx');
expect(spy).toHaveBeenCalledOnce();
expect(vm.exportToastOpen).toBe(true);
expect(vm.exportToastText).toContain('4');
expect(vm.exportToastText).toContain('CSV');
clickSpy.mockRestore();
});
it('bulk-export: пустой selected → toast «Нет выбранных» без CSV', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
selected: number[];
applyBulkExport: () => void;
exportToastOpen: boolean;
exportToastText: string;
};
const createUrlSpy = vi.fn(() => 'blob:mock');
Object.defineProperty(URL, 'createObjectURL', { value: createUrlSpy, configurable: true });
vm.selected = [];
vm.applyBulkExport();
expect(createUrlSpy).not.toHaveBeenCalled();
expect(vm.exportToastText).toContain('Нет выбранных');
it('смена фильтра вызывает loadDeals ровно один раз (без двойного fetch)', async () => {
const w = await mountDeals();
const vm = w.vm as unknown as { filterStatus: string | null; page: number };
// Установим spy до смены page, чтобы перехватить все вызовы
const spy = vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({ deals: [], total: 0, limit: 20, offset: 0 });
// Переходим на страницу 3 — это вызовет watch(page) → loadDeals один раз
vm.page = 3;
await flushPromises();
spy.mockClear(); // сбрасываем счётчик: интересует только смена фильтра
// Смена фильтра при page=3: A10 fix должен лишь сбросить page→1 (без прямого loadDeals),
// затем watch(page) делает ровно один fetch
vm.filterStatus = 'viewed';
await flushPromises();
expect(spy).toHaveBeenCalledTimes(1);
});
it('кнопка «Новая сделка» открывает NewDealDialog (newDealOpen=true)', async () => {
const wrapper = await mountDeals();
await flushPromises();
const vm = wrapper.vm as unknown as { newDealOpen: boolean };
expect(vm.newDealOpen).toBe(false);
await wrapper.find('[data-testid="new-deal-btn"]').trigger('click');
await flushPromises();
expect(vm.newDealOpen).toBe(true);
});
it('onDealCreated добавляет новую сделку в начало dealsState', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
dealsState: Array<{ id: number; name: string; statusSlug: string }>;
onDealCreated: (deal: Record<string, unknown>) => void;
};
const before = vm.dealsState.length;
// Передаём полную форму deal — table-cell ожидает manager.name/phone/cost.
vm.onDealCreated({
id: 999,
name: 'Новый клиент',
phone: '+7 (999) 000-00-00',
statusSlug: 'new',
project: 'Окна Москва',
manager: { initials: 'Н', name: 'Новый М.' },
cost: 1000,
receivedMinutesAgo: 0,
});
await flushPromises();
expect(vm.dealsState.length).toBe(before + 1);
expect(vm.dealsState[0].id).toBe(999);
expect(vm.dealsState[0].name).toBe('Новый клиент');
});
it('smart-filter projects: оставляет только сделки выбранного проекта', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as { activeTab: string; filterProjects: string[] };
vm.activeTab = 'all';
vm.filterProjects = ['Окна Москва'];
await flushPromises();
const rows = wrapper.findAll('tbody tr');
// Минимум одна строка, и все содержат «Окна Москва»
expect(rows.length).toBeGreaterThan(0);
rows.forEach((row) => expect(row.text()).toContain('Окна Москва'));
});
it('smart-filter managers: оставляет только сделки выбранного менеджера', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as { activeTab: string; filterManagers: string[] };
vm.activeTab = 'all';
vm.filterManagers = ['Иван П.'];
await flushPromises();
const rows = wrapper.findAll('tbody tr');
expect(rows.length).toBeGreaterThan(0);
rows.forEach((row) => expect(row.text()).toContain('Иван П.'));
});
it('clearFilters сбрасывает projects+managers фильтры, кнопка появляется по условию', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as {
filterProjects: string[];
filterManagers: string[];
clearFilters: () => void;
};
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(false);
vm.filterProjects = ['Окна Москва'];
await flushPromises();
expect(wrapper.find('[data-testid="clear-filters-btn"]').exists()).toBe(true);
vm.clearFilters();
await flushPromises();
expect(vm.filterProjects).toEqual([]);
expect(vm.filterManagers).toEqual([]);
});
it('bulk-clear: иконка ✕ сбрасывает selected', async () => {
const wrapper = await mountDeals();
const vm = wrapper.vm as unknown as { selected: number[] };
vm.selected = [1, 2];
await flushPromises();
const clearBtn = wrapper.find('[data-testid="bulk-clear-btn"]');
expect(clearBtn.exists()).toBe(true);
await clearBtn.trigger('click');
await flushPromises();
expect(vm.selected).toEqual([]);
});
// Audit C8/F3: deep-link /deals?openId=
it('route.query.openId открывает drawer соответствующей сделки', async () => {
const openId = MOCK_DEALS[0].id;
// Мокаем API чтобы loadDeals заполнил state до вызова openDealFromQuery в onMounted.
vi.spyOn(dealsApi, 'listDeals').mockResolvedValue({
deals: MOCK_DEALS.map((d) => ({
id: d.id,
name: d.name,
phone: d.phone,
status: d.statusSlug,
project_name: d.project,
manager_name: d.manager.name,
cost: d.cost,
created_at: new Date(Date.now() - d.receivedMinutesAgo * 60000).toISOString(),
deleted_at: null,
})),
total: MOCK_DEALS.length,
} as never);
it('loadDeals reject → dealsState пустой + fetchError', async () => {
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/deals', component: DealsView }],
});
await router.push(`/deals?openId=${openId}`);
auth.user = { id: 1, tenant_id: 42, email: 't@t.com' } as AuthUser;
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([]);
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
await router.push('/deals');
await router.isReady();
const wrapper = mount(DealsView, {
global: {
plugins: [createVuetify(), router],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
},
const w = mount(DealsView, {
global: { plugins: [createVuetify(), router], stubs: { DealDetailDrawer: true, DealsFilters: true } },
});
await flushPromises();
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
expect(vm.drawerOpen).toBe(true);
expect(vm.selectedDeal?.id).toBe(openId);
const vm = w.vm as unknown as { dealsState: MockDeal[]; fetchError: boolean };
expect(vm.dealsState.length).toBe(0);
expect(vm.fetchError).toBe(true);
});
it('openId не найден среди сделок — drawer не открывается, без ошибки', async () => {
const wrapper = await mountDealsViewAt('/deals?openId=99999999');
await flushPromises();
const vm = wrapper.vm as unknown as { drawerOpen: boolean };
expect(vm.drawerOpen).toBe(false);
});
it('навигация на /deals?openId= в смонтированном view открывает drawer (watch)', async () => {
const openId = MOCK_DEALS[0].id;
const wrapper = await mountDealsViewAt('/deals');
await flushPromises();
const vm = wrapper.vm as unknown as { drawerOpen: boolean; selectedDeal: { id: number } | null };
expect(vm.drawerOpen).toBe(false);
await wrapper.vm.$router.push(`/deals?openId=${openId}`);
await flushPromises();
expect(vm.drawerOpen).toBe(true);
expect(vm.selectedDeal?.id).toBe(openId);
});
});
test('C3: exportAllFiltered вызывает backend-экспорт со всеми отфильтрованными id', async () => {
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
const wrapper = await mountDeals();
await flushPromises();
// Установить auth.user с tenant_id чтобы exportDealIds пошёл в backend
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
// activeTab по умолчанию 'active' — установить 'all' чтобы filteredDeals === dealsState
const vm = wrapper.vm as unknown as {
activeTab: string;
dealsState: Array<{ id: number }>;
exportAllFiltered: () => Promise<void>;
exportToastOpen: boolean;
};
vm.activeTab = 'all';
await flushPromises();
await vm.exportAllFiltered();
expect(xlsxSpy).toHaveBeenCalledTimes(1);
const callArg = xlsxSpy.mock.calls[0][0];
expect(callArg.ids).toEqual(vm.dealsState.map((d) => d.id));
expect(vm.exportToastOpen).toBe(true);
});
test('C3: exportAllFiltered на пустом списке показывает toast и не зовёт backend', async () => {
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
const wrapper = await mountDeals();
await flushPromises();
const vm = wrapper.vm as unknown as {
activeTab: string;
dealsState: Array<{ id: number }>;
exportAllFiltered: () => Promise<void>;
exportToastOpen: boolean;
exportToastText: string;
};
// Очистить список и поставить tab='all' чтобы filteredDeals тоже пустой
vm.activeTab = 'all';
vm.dealsState.splice(0, vm.dealsState.length);
await flushPromises();
await vm.exportAllFiltered();
expect(xlsxSpy).not.toHaveBeenCalled();
expect(vm.exportToastText).toBe('Список пуст — нечего экспортировать.');
});
// I3 regression: API reject → dealsState пустой + fetchError=true (нет mock-fallback)
// Faithful-паттерн: auth + mock ДО mount, onMounted сам вызывает loadDeals.
test('I3: loadDeals reject оставляет dealsState пустым и выставляет fetchError', async () => {
vi.spyOn(dealsApi, 'listDeals').mockRejectedValue(new Error('500'));
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/deals', component: DealsView }],
});
await router.push('/deals');
await router.isReady();
const wrapper = mount(DealsView, {
global: {
plugins: [createVuetify(), router],
stubs: { DealDetailDrawer: true, NewDealDialog: true },
},
});
await flushPromises();
const vm = wrapper.vm as unknown as {
dealsState: MockDeal[];
fetchError: boolean;
};
expect(vm.dealsState.length).toBe(0);
expect(vm.fetchError).toBe(true);
});
afterEach(() => vi.restoreAllMocks());
@@ -1,111 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import { createMemoryHistory, createRouter } from 'vue-router';
import DealsView from '../../resources/js/views/DealsView.vue';
function setup() {
setActivePinia(createPinia());
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
router.push('/deals');
return mount(DealsView, {
global: {
plugins: [router, createVuetify()],
stubs: { RouterLink: true, VDataTable: true },
},
});
}
describe('DealsView — redesigned', () => {
beforeEach(() => localStorage.clear());
it('renders filterbar with at least 3 FilterChips', async () => {
const w = setup();
await flushPromises();
const chips = w.findAll('.ld-filter-chip');
expect(chips.length).toBeGreaterThanOrEqual(3);
});
it('renders DensityToggle in filterbar', async () => {
const w = setup();
await flushPromises();
expect(w.find('.ld-density-toggle').exists()).toBe(true);
});
it('row uses StatusPill component for status column', async () => {
const w = setup();
await flushPromises();
// After data load — at least one ld-status-pill should be present
// (если stub VDataTable — test проверяет наличие компонента в template, не в render)
expect(w.html()).toMatch(/ld-status-pill|StatusPill/);
});
it('applies ld-hover-lift utility class to table container or row wrapper', async () => {
const w = setup();
await flushPromises();
expect(w.html()).toMatch(/ld-hover-lift|hover-lift/);
});
it('applies ld-stagger-row class to deal rows (motion #2)', async () => {
const w = setup();
await flushPromises();
expect(w.html()).toMatch(/ld-stagger-row/);
});
});
describe('FilterChip popovers (Sprint 1 C2)', () => {
function setupWithRouter() {
setActivePinia(createPinia());
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/deals', component: DealsView }] });
router.push('/deals');
return mount(DealsView, {
global: {
plugins: [router, createVuetify()],
stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
},
});
}
it('clicking Project chip toggles projectMenuOpen ref to true', async () => {
const wrapper = setupWithRouter();
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.projectMenuOpen).toBe(false);
// Trigger via direct ref assignment (v-menu activator manages this ref).
// watch(projectMenuOpen) will seed projectMenuDraft on open=true.
vm.projectMenuOpen = true;
await wrapper.vm.$nextTick();
expect(vm.projectMenuOpen).toBe(true);
});
it('clicking Manager chip toggles managerMenuOpen ref to true', async () => {
const wrapper = setupWithRouter();
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.managerMenuOpen).toBe(false);
// Trigger via direct ref assignment (v-menu activator manages this ref).
// watch(managerMenuOpen) will seed managerMenuDraft on open=true.
vm.managerMenuOpen = true;
await wrapper.vm.$nextTick();
expect(vm.managerMenuOpen).toBe(true);
});
it('applying project selection updates filterProjects and closes menu', async () => {
const wrapper = setupWithRouter();
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
// Open menu (watch seeds draft from filterProjects), then override draft manually.
vm.projectMenuOpen = true;
await wrapper.vm.$nextTick();
vm.projectMenuDraft = ['demo-project-1', 'demo-project-2'];
vm.applyProjectFilter();
await wrapper.vm.$nextTick();
expect(vm.filterProjects).toEqual(['demo-project-1', 'demo-project-2']);
expect(vm.projectMenuOpen).toBe(false);
});
});
+8 -8
View File
@@ -16,22 +16,22 @@ describe('FunnelChart.vue', () => {
expect(wrapper.text()).toContain('Воронка');
});
it('содержит ровно 14 сегментов в bar (по числу lead_statuses)', () => {
it('содержит ровно 5 сегментов в bar (по числу lead_statuses)', () => {
const wrapper = factory();
const segs = wrapper.findAll('.funnel-seg');
expect(segs).toHaveLength(14);
expect(segs).toHaveLength(5);
});
it('содержит ровно 14 list-items', () => {
it('содержит ровно 5 list-items', () => {
const wrapper = factory();
const items = wrapper.findAll('.funnel-list-item');
expect(items).toHaveLength(14);
expect(items).toHaveLength(5);
});
it('использует правильные slug-имена из schema (НЕ из BRANDBOOK)', () => {
const wrapper = factory();
const text = wrapper.text();
// Проверка что все 14 имён из lead_statuses присутствуют.
// Проверка что все 5 имён из lead_statuses присутствуют.
LEAD_STATUSES.forEach((s) => {
expect(text).toContain(s.nameRu);
});
@@ -41,10 +41,10 @@ describe('FunnelChart.vue', () => {
expect(text).not.toContain('Спам');
});
it('сортирует список по убыванию count (paid 45 — первый)', () => {
it('сортирует список по убыванию count (in_progress 96 — первый)', () => {
const wrapper = factory();
const names = wrapper.findAll('.funnel-list-item .name').map((n) => n.text());
expect(names[0]).toBe('Оплачено'); // count=45 — самый большой в DEFAULT_COUNTS.
expect(names[0]).toBe('В работе'); // count=96 — самый большой в DEFAULT_COUNTS.
});
it('применяет colorHex из lead_statuses к dots и сегментам', () => {
@@ -55,7 +55,7 @@ describe('FunnelChart.vue', () => {
});
it('считает total как сумму counts', () => {
const wrapper = factory({ counts: { new: 10, paid: 20 } });
const wrapper = factory({ counts: { new: 10, won: 20 } });
const text = wrapper.text();
// total = 10 + 20 = 30 (остальные слаги с counts={} → 0).
expect(text).toContain('30 лидов');
+18 -18
View File
@@ -28,11 +28,11 @@ describe('KanbanView.vue', () => {
expect(wrapper.find('h1').text()).toBe('Канбан');
});
it('рендерит ровно 14 KanbanColumn (по числу lead_statuses)', () => {
it('рендерит ровно 5 KanbanColumn (по числу lead_statuses)', () => {
const wrapper = factory();
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
expect(cols).toHaveLength(LEAD_STATUSES.length);
expect(cols).toHaveLength(14);
expect(cols).toHaveLength(5);
});
it('каждая колонка получает соответствующий статус', () => {
@@ -46,7 +46,7 @@ describe('KanbanView.vue', () => {
it('содержит page-stats с числом статусов и сделок', () => {
const wrapper = factory();
const text = wrapper.text();
expect(text).toContain('14');
expect(text).toContain('5');
expect(text).toContain('статусов');
expect(text).toContain('сделок');
});
@@ -110,17 +110,17 @@ describe('KanbanView.vue', () => {
const cols = wrapper.findAllComponents({ name: 'KanbanColumn' });
// Берём сделку из первой колонки (new) и эмулируем «added» в paid-колонке.
const newCol = cols[0]; // new — sortOrder=1
const paidCol = cols.find((c) => c.props('status').slug === 'paid')!;
const wonCol = cols.find((c) => c.props('status').slug === 'won')!;
const dealToMove = (newCol.props('deals') as { id: number; statusSlug: string }[])[0];
// Эмуляция события vuedraggable@change → KanbanView.onColumnChange.
await paidCol.vm.$emit('change', {
await wonCol.vm.$emit('change', {
added: { element: dealToMove, newIndex: 0 },
});
await wrapper.vm.$nextTick();
// statusSlug сделки должен переключиться на 'paid'.
expect(dealToMove.statusSlug).toBe('paid');
// statusSlug сделки должен переключиться на 'won'.
expect(dealToMove.statusSlug).toBe('won');
});
});
@@ -160,7 +160,7 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
updated: 1,
requested: 1,
status: 'hot',
status: 'in_progress',
});
const wrapper = mount(KanbanView, {
global: {
@@ -174,14 +174,14 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
const deal = { id: 42, statusSlug: 'new' as const, name: 'X', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
expect(transitionSpy).toHaveBeenCalledWith({
tenant_id: 7,
ids: [42],
status: 'hot',
status: 'in_progress',
});
expect(deal.statusSlug).toBe('hot');
expect(deal.statusSlug).toBe('in_progress');
});
it('onColumnChange reverts statusSlug + opens toast when API rejects', async () => {
@@ -200,15 +200,15 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
// Имитируем vuedraggable mutation: карточка уже в target column до вызова onColumnChange.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
if (!vm.dealsByStatus.hot) vm.dealsByStatus.hot = [];
vm.dealsByStatus.hot.push(deal);
if (!vm.dealsByStatus.in_progress) vm.dealsByStatus.in_progress = [];
vm.dealsByStatus.in_progress.push(deal);
await vm.onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
await vm.onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
// statusSlug rolled back
expect(deal.statusSlug).toBe('new');
// Card removed from target column (array-revert branch coverage)
expect(vm.dealsByStatus.hot.findIndex((d: { id: number }) => d.id === 43)).toBe(-1);
expect(vm.dealsByStatus.in_progress.findIndex((d: { id: number }) => d.id === 43)).toBe(-1);
// Card restored to source column
expect(vm.dealsByStatus.new.findIndex((d: { id: number }) => d.id === 43)).toBeGreaterThanOrEqual(0);
// Toast shown
@@ -218,7 +218,7 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
it('onColumnChange skips API call if no auth.user.tenant_id', async () => {
const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
updated: 1, requested: 1, status: 'hot',
updated: 1, requested: 1, status: 'in_progress',
});
const wrapper = mount(KanbanView, {
global: {
@@ -232,10 +232,10 @@ describe('KanbanView DnD persist (Sprint 1 C4)', () => {
const deal = { id: 44, statusSlug: 'new' as const, name: 'Z', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).onColumnChange('hot', { added: { element: deal, newIndex: 0 } });
await (wrapper.vm as any).onColumnChange('in_progress', { added: { element: deal, newIndex: 0 } });
// Без auth — только optimistic local change, API не зовётся
expect(transitionSpy).not.toHaveBeenCalled();
expect(deal.statusSlug).toBe('hot');
expect(deal.statusSlug).toBe('in_progress');
});
});
+2 -2
View File
@@ -125,10 +125,10 @@ describe('NewDealDialog.vue', () => {
});
it('presetStatus → statusSlug дефолтит на пресет (для KanbanView)', async () => {
const wrapper = factory({ modelValue: true, presetStatus: 'paid' });
const wrapper = factory({ modelValue: true, presetStatus: 'won' });
await flushPromises();
const vm = wrapper.vm as unknown as { statusSlug: string };
expect(vm.statusSlug).toBe('paid');
expect(vm.statusSlug).toBe('won');
});
it('без tenantId — submit НЕ вызывает API (local-only mode)', async () => {
+22 -14
View File
@@ -15,15 +15,18 @@ const mountDialog = (count = 5) =>
},
});
interface DialogVm {
addRegions: number[];
removeRegions: number[];
}
describe('RegionsBulkDialog', () => {
beforeEach(() => setActivePinia(createPinia()));
it('renders 8 federal-district chips for Add and Remove', () => {
it('renders subject-level Add and Remove selectors (not federal districts)', () => {
const wrapper = mountDialog();
const addChips = wrapper.findAll('[data-testid^="region-add-"]');
const removeChips = wrapper.findAll('[data-testid^="region-remove-"]');
expect(addChips.length).toBe(8);
expect(removeChips.length).toBe(8);
expect(wrapper.find('[data-testid="region-add-select"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="region-remove-select"]').exists()).toBe(true);
});
it('shows count from prop', () => {
@@ -31,20 +34,25 @@ describe('RegionsBulkDialog', () => {
expect(wrapper.text()).toContain('7');
});
it('emits apply with computed bitmasks', async () => {
it('emits apply with selected subject codes', async () => {
const wrapper = mountDialog();
// Toggle Центральный (bit 1) in Add
await wrapper.find('[data-testid="region-add-1"]').trigger('click');
// Toggle Сибирский (bit 64) in Remove
await wrapper.find('[data-testid="region-remove-64"]').trigger('click');
(wrapper.vm as unknown as DialogVm).addRegions = [82, 83];
(wrapper.vm as unknown as DialogVm).removeRegions = [56];
await wrapper.vm.$nextTick();
await wrapper.find('[data-testid="apply"]').trigger('click');
expect(wrapper.emitted('apply')?.[0]).toEqual([{ add: 1, remove: 64 }]);
expect(wrapper.emitted('apply')?.[0]).toEqual([{ add_regions: [82, 83], remove_regions: [56] }]);
});
it('apply button disabled when both add and remove are 0', () => {
it('apply button disabled when nothing selected', () => {
const wrapper = mountDialog();
const btn = wrapper.find('[data-testid="apply"]');
expect(btn.attributes('disabled')).toBeDefined();
expect(wrapper.find('[data-testid="apply"]').attributes('disabled')).toBeDefined();
});
it('apply button enabled once a subject is picked', async () => {
const wrapper = mountDialog();
(wrapper.vm as unknown as DialogVm).addRegions = [82];
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="apply"]').attributes('disabled')).toBeUndefined();
});
});
@@ -66,15 +66,15 @@ describe('UnknownStatusesDialog', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
vm.selection['Архив'] = 'closed';
vm.selection['Спам'] = 'closed';
vm.selection['Архив'] = 'lost';
vm.selection['Спам'] = 'lost';
await flushPromises();
await vm.save();
await flushPromises();
expect(spy).toHaveBeenCalledWith([
{ status_ru: 'Архив', slug: 'closed' },
{ status_ru: 'Спам', slug: 'closed' },
{ status_ru: 'Архив', slug: 'lost' },
{ status_ru: 'Спам', slug: 'lost' },
]);
expect(wrapper.emitted('resolved')).toBeTruthy();
wrapper.unmount();
@@ -221,7 +221,7 @@ describe('adminTenantDetailMapper', () => {
event: 'deal.status_changed',
deal_id: 200,
actor_email: 'user@test.io',
context: { from: 'new', to: 'worked' },
context: { from: 'new', to: 'in_progress' },
created_at: '2026-01-01T00:00:00Z',
},
],
@@ -229,7 +229,7 @@ describe('adminTenantDetailMapper', () => {
);
expect(ui.activity[0]!.actor).toBe('system');
expect(ui.activity[1]!.actor).toBe('user@test.io');
expect(ui.activity[1]!.summary).toBe('Сделка #200: new → worked');
expect(ui.activity[1]!.summary).toBe('Сделка #200: new → in_progress');
});
it('metrics: leadsThisMonth/Week/avgLeadCost/runwayDays', () => {
+30
View File
@@ -15,6 +15,10 @@ describe('mapApiDeal', () => {
manager_name: 'Иван П.',
manager_initials: 'ИП',
received_at: '2026-05-09T10:00:00Z',
comment: null,
city: null,
project_signal_type: null,
next_reminder_at: null,
};
it('маппит обязательные поля 1:1', () => {
@@ -65,4 +69,30 @@ describe('mapApiDeal', () => {
const m = mapApiDeal({ ...baseApi, received_at: null }, new Date('2026-05-09T10:30:00Z'));
expect(m.receivedMinutesAgo).toBe(0);
});
it('mapApiDeal переносит city/comment/signalType/receivedAt/nextReminderAt', () => {
const iso = '2026-05-15T09:00:00+00:00';
const result = mapApiDeal({
id: 7,
tenant_id: 1,
project_id: 2,
project_name: 'Окна',
phone: '+7 999 000-00-00',
contact_name: null,
status: 'new',
manager_id: null,
manager_name: null,
manager_initials: null,
received_at: iso,
comment: 'звонил клиент',
city: 'Москва',
project_signal_type: 'call',
next_reminder_at: iso,
});
expect(result.city).toBe('Москва');
expect(result.comment).toBe('звонил клиент');
expect(result.signalType).toBe('call');
expect(result.receivedAt).toBe(iso);
expect(result.nextReminderAt).toBe(iso);
});
});
+3 -3
View File
@@ -22,11 +22,11 @@ describe('useLeadStatusesStore', () => {
expect(store.fetchError).toBe(false);
});
it('findBySlug возвращает статус из snapshot до load', () => {
it('findBySlug возвращает статус из snapshot до load (won)', () => {
const store = useLeadStatusesStore();
const found = store.findBySlug('paid');
const found = store.findBySlug('won');
expect(found).not.toBeNull();
expect(found!.nameRu).toBe('Оплачено');
expect(found!.nameRu).toBe('Сделка');
});
it('findBySlug возвращает null для неизвестного slug', () => {
@@ -0,0 +1,56 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { repositionMenuAfterOpen } from '../../resources/js/utils/menuRepositionFix';
/**
* Unit-тесты воркэраунда Vuetify location-strategy (см. menuRepositionFix.ts).
* Реальный баг гонка позиционирования в браузере под prefers-reduced-motion
* в jsdom не воспроизводится (нет layout); он покрыт Playwright-пробой. Здесь
* проверяется контракт утилиты: при стабилизации overlay-меню шлётся один resize.
*/
function makeStableMenu(left: number): HTMLElement {
const overlay = document.createElement('div');
overlay.className = 'v-overlay v-menu';
const content = document.createElement('div');
content.className = 'v-overlay__content';
content.getBoundingClientRect = () =>
({ width: 400, height: 300, left, top: 50, right: left + 400, bottom: 350, x: left, y: 50, toJSON() {} }) as DOMRect;
overlay.appendChild(content);
document.body.appendChild(overlay);
return overlay;
}
const wait = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
describe('repositionMenuAfterOpen', () => {
afterEach(() => {
document.querySelectorAll('.v-overlay').forEach((el) => el.remove());
});
it('does nothing when menu is closing (open=false)', async () => {
const spy = vi.fn();
window.addEventListener('resize', spy);
repositionMenuAfterOpen(false);
await wait(200);
window.removeEventListener('resize', spy);
expect(spy).not.toHaveBeenCalled();
});
it('dispatches a single resize once the overlay content is geometrically stable', async () => {
makeStableMenu(120);
const spy = vi.fn();
window.addEventListener('resize', spy);
repositionMenuAfterOpen(true);
await wait(400);
window.removeEventListener('resize', spy);
expect(spy).toHaveBeenCalled();
});
it('does not dispatch resize or throw when no overlay is present', async () => {
const spy = vi.fn();
window.addEventListener('resize', spy);
expect(() => repositionMenuAfterOpen(true)).not.toThrow();
await wait(300);
window.removeEventListener('resize', spy);
expect(spy).not.toHaveBeenCalled();
});
});
@@ -32,12 +32,12 @@ describe('projectsStore.bulkUpdate', () => {
store.filters.status = 'active';
store.filters.search = 'окна';
await store.bulkUpdate({ action: 'update_regions', add: 6, remove: 1 });
await store.bulkUpdate({ action: 'update_regions', add_regions: [3, 5], remove_regions: [1] });
expect(axios.post).toHaveBeenCalledWith('/api/projects/bulk', {
action: 'update_regions',
add: 6,
remove: 1,
add_regions: [3, 5],
remove_regions: [1],
scope: { filter: { signal_type: 'sms', status: 'active', search: 'окна' } },
});
});
+6 -2
View File
@@ -2,16 +2,18 @@ import { describe, it, expect } from 'vitest';
import { useStatusPill, STATUS_PILL_SLUGS } from '../../resources/js/composables/useStatusPill';
describe('useStatusPill', () => {
it('exposes exactly 14 known slugs', () => {
expect(STATUS_PILL_SLUGS).toHaveLength(14);
it('exposes exactly 16 known slugs', () => {
expect(STATUS_PILL_SLUGS).toHaveLength(16);
expect(STATUS_PILL_SLUGS).toEqual(
expect.arrayContaining([
'new',
'viewed',
'in_progress',
'callback',
'quality',
'meeting_set',
'won',
'lost',
'refund',
'duplicate',
'junk',
@@ -26,11 +28,13 @@ describe('useStatusPill', () => {
it.each([
['new', { bg: 'rgba(15,110,86,0.12)', color: '#0F6E56' }],
['viewed', { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' }],
['in_progress', { bg: 'rgba(63,124,149,0.12)', color: '#2A5A6E' }],
['callback', { bg: 'rgba(217,164,65,0.18)', color: '#A07820' }],
['quality', { bg: 'rgba(46,139,87,0.15)', color: '#2E8B57' }],
['meeting_set', { bg: 'rgba(122,91,163,0.15)', color: '#7A5BA3' }],
['won', { bg: 'rgba(46,139,87,0.22)', color: '#1F6940', fontWeight: 600 }],
['lost', { bg: 'rgba(107,99,86,0.18)', color: '#6B6356' }],
['refund', { bg: 'rgba(204,110,80,0.15)', color: '#B0563D' }],
['duplicate', { bg: 'rgba(1,32,25,0.08)', color: '#3A3A3A' }],
['junk', { bg: 'rgba(184,58,58,0.10)', color: '#B83A3A' }],
@@ -4,20 +4,33 @@ declare(strict_types=1);
use App\Services\Import\StatusRuToSlugMapper;
test('маппит все 14 канонических статусов §6.4', function (): void {
$mapper = new StatusRuToSlugMapper;
test('старые русские статусы поставщика мапятся в 5 новых slug-ов', function (): void {
$m = new StatusRuToSlugMapper;
expect($mapper->toSlug('Новые'))->toBe('new')
->and($mapper->toSlug('Оплачено'))->toBe('paid')
->and($mapper->toSlug('Конечный недозвон'))->toBe('final_missed')
->and($mapper->map())->toHaveCount(14);
expect($m->toSlug('Новые'))->toBe('new')
->and($m->toSlug('Просмотрено'))->toBe('viewed')
->and($m->toSlug('Проработан'))->toBe('in_progress')
->and($m->toSlug('Переговоры'))->toBe('in_progress')
->and($m->toSlug('Конечный недозвон'))->toBe('in_progress')
->and($m->toSlug('Оплачено'))->toBe('won')
->and($m->toSlug('Закрыто и не реализовано'))->toBe('lost');
});
test('новые русские названия 5-статусной воронки мапятся', function (): void {
$m = new StatusRuToSlugMapper;
expect($m->toSlug('Новая сделка'))->toBe('new')
->and($m->toSlug('В работе'))->toBe('in_progress')
->and($m->toSlug('Сделка'))->toBe('won')
->and($m->toSlug('Не реализовано'))->toBe('lost')
->and($m->map())->toHaveCount(18); // 5 новых + 13 старых RU-названий
});
test('тримит пробелы вокруг значения', function (): void {
expect((new StatusRuToSlugMapper)->toSlug(' Переговоры '))->toBe('negotiations');
expect((new StatusRuToSlugMapper)->toSlug(' Переговоры '))->toBe('in_progress');
});
test('возвращает null для неизвестного статуса', function (): void {
expect((new StatusRuToSlugMapper)->toSlug('Архив'))->toBeNull()
expect((new StatusRuToSlugMapper)->toSlug('Абракадабра'))->toBeNull()
->and((new StatusRuToSlugMapper)->toSlug(''))->toBeNull();
});
+27
View File
@@ -1351,6 +1351,11 @@ RAG
venv
Helicone
Langfuse
sickn
antigravity
sqlite
воркфлоу
эксцепшн
# SG #40 Security Guidance correction (2026-05-17)
резолва
@@ -1380,3 +1385,25 @@ ivotoby
ребейз
ребейзнута
ребейзом
# Deals page redesign — spec/plan (2026-05-17)
гейта
дровер
канбаном
коммитить
# C10 business-process tooling integration — spec + plan (2026-05-17)
RACI
DMN
czlonkowski
# C10 process-modeling skill — BPMN/process vocabulary (2026-05-17)
гейтвеи
скилу
# C10 process-analysis skill — discovery/analysis vocabulary (2026-05-17)
пином
джобы
# C10 normative sync vocabulary (2026-05-17)
линтуются
+2 -1
View File
@@ -24,7 +24,8 @@
"*.svg",
"**/*.sql",
".claude/skills/mermaid/**",
".claude/skills/ccpm/**"
".claude/skills/ccpm/**",
".claude/skills/data-scientist/**"
],
"ignoreRegExpList": [
"Email",
+8 -2
View File
@@ -1,11 +1,17 @@
# CHANGELOG schema.sql — Лидерра
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать одну запись в обратном хронологическом порядке (v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Назначение:** консолидированный журнал изменений `schema.sql`. Содержит двадцать две записи в обратном хронологическом порядке (v8.23 → v8.22 → v8.21 → v8.20 → v8.19 → v8.18 → v8.17 → v8.16 → v8.15 → v8.14 → v8.13 → v8.12 → v8.11 → v8.10 → v8.9 → v8.8 → v8.7 → v8.6 → v8.5 → v8.4 → v8.3 → v8.2), как принято в keep-a-changelog.
**Файл схемы:** `schema.sql` (текущая версия — v8.22, консолидированная — разворачивает БД с нуля).
**Файл схемы:** `schema.sql` (текущая версия — v8.23, консолидированная — разворачивает БД с нуля).
**История записей:**
## v8.23 — 2026-05-17 — Редизайн «Сделки» (воронка статусов 14 → 5)
**Изменения:**
Воронка статусов 14 → 5: seed `lead_statuses` (`new`/`viewed`/`in_progress`/`won`/`lost`). Инкрементальная миграция `2026_05_17_120000_deals_funnel_14_to_5_statuses.php` ремапит `deals.status`, `tenant_status_overrides.status_slug`, `import_unknown_statuses.mapped_to_slug`. Редизайн страницы «Сделки», спека `docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md`. **Структурных изменений нет** — только seed `lead_statuses` (14 → 5 строк); schema baseline без изменений (64 базовых таблиц / 12 партиций / 119 индексов / 40 RLS / 5 функций / 13 триггеров).
## v8.22 — 2026-05-17 — Plan 6 (C9 — Subject-level regions)
**Изменения:**
+8 -17
View File
@@ -1,6 +1,6 @@
-- =============================================================================
-- schema.sql — единая схема БД для SaaS-аналога crm.bp-gr.ru («Лидерра»)
-- Версия: v8.22 (17.05.2026 — Plan 6 (C9): projects.regions INT[] subject-level filtering + GIN-индекс idx_projects_regions)
-- Версия: v8.23 (17.05.2026 — Редизайн «Сделки»: seed lead_statuses 14→5 (new/viewed/in_progress/won/lost))
-- Метрики: 64 базовые таблицы (62 regular + 2 partitioned parents: deals + supplier_lead_costs) + 12 партиций / 119 индексов / 40 RLS-политик / 5 функций / 13 триггеров
-- Базовая версия: v8.20 (11.05.2026 — Plan 5 frontend projects UI: projects.archived_at TIMESTAMPTZ NULL для soft archive flow; tenants.limits JSONB NOT NULL DEFAULT '{}' для per-tenant project/user лимитов)
-- Базовая версия: v8.19 (11.05.2026 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log)
@@ -316,7 +316,7 @@ CREATE TABLE tariff_plans (
-- -----------------------------------------------------------------------------
-- lead_statuses — справочник статусов воронки (раздел 7.3, 8.1)
-- 14 статусов: 6 системных + 8 настраиваемых
-- 5 статусов воронки (все системные)
-- -----------------------------------------------------------------------------
CREATE TABLE lead_statuses (
slug VARCHAR(50) PRIMARY KEY,
@@ -2574,22 +2574,13 @@ CREATE INDEX idx_admin_audit_pending ON saas_admin_audit_log(approved_at) WHERE
-- 11. ЗАПОЛНЕНИЕ СПРАВОЧНИКОВ
-- =============================================================================
-- 14 статусов воронки (раздел 7.3, 8.1)
-- 5 статусов воронки (редизайн «Сделки» 2026-05-17 — было 14)
INSERT INTO lead_statuses (slug, name_ru, is_system, sort_order, color_hex) VALUES
('new', 'Новые', TRUE, 1, '#3B82F6'),
('viewed', 'Просмотрено', TRUE, 2, '#8B5CF6'),
('worked', 'Проработан', TRUE, 3, '#06B6D4'),
('base', 'База', FALSE, 4, '#64748B'),
('missed', 'Недозвон', FALSE, 5, '#F59E0B'),
('negotiations', 'Переговоры', FALSE, 6, '#EAB308'),
('waiting_payment', 'Ожидаем оплаты', FALSE, 7, '#A78BFA'),
('partnership', 'Партнерка', FALSE, 8, '#EC4899'),
('paid', 'Оплачено', TRUE, 9, '#10B981'),
('closed', 'Закрыто и не реализовано', TRUE, 10, '#6B7280'),
('test_drive', 'Тест драйв', FALSE,11, '#14B8A6'),
('hot', 'Горячий', FALSE,12, '#EF4444'),
('replacement', 'На замену', FALSE,13, '#F97316'),
('final_missed', 'Конечный недозвон', TRUE, 14, '#1F2937');
('new', 'Новая сделка', TRUE, 1, '#3B82F6'),
('viewed', 'Просмотрено', TRUE, 2, '#8B5CF6'),
('in_progress', 'В работе', TRUE, 3, '#06B6D4'),
('won', 'Сделка', TRUE, 4, '#10B981'),
('lost', 'Не реализовано', TRUE, 5, '#6B7280');
-- НОВОЕ в v8.2: каталог поставщиков B1/B2/B3
+12 -1
View File
@@ -1,8 +1,12 @@
# Plugin Stack Rules — Superpowers + Frontend Design (v3.9)
# Plugin Stack Rules — Superpowers + Frontend Design (v3.11)
**Дата:** 17.05.2026
**Назначение:** свод правил совместного использования плагинов Claude Code в проекте Лидерра — paired-stack ядро `obra/superpowers` (14 skills) + `anthropics/frontend-design`, плюс расширенный пул UI-инструментов `ui-ux-pro-max` (skill, marketplace `nextlevelbuilder/ui-ux-pro-max-skill`) и `21st.dev Magic MCP` (MCP-сервер `magic`), плюс инфраструктурный `claude-md-management` (skills, marketplace `anthropics/claude-plugins-official`), плюс **debug-runtime MCP** `@sentry/mcp-server` + `@modelcontextprotocol/server-redis` (v2.1+, R10.1 Блок 3).
**v3.11** — C10 business-process: R10.1 Блок 1 +1 строка **operations** (`operations@knowledge-work-plugins` v1.2.0, Anthropic Verified, 9 скилов, marketplace-плагин) + Блок 1 note (v3.11) — **process-modeling** + **process-analysis** (self-authored project-скилы `.claude/skills/`) + Блок 3 +1 строка **n8n-mcp** (DEFERRED — workflow-движок n8n, у портала нет n8n). Новая 11-я off-phase подкатегория **business-process** (Tooling #51-54, раздел C10 карты) — не UI → вне R6.0/R6.1/R14, как architecture-tooling/audit-security/ml-ai-tooling. Содержательных изменений R0–R14: 0. Связано: Tooling v2.12, Pravila v1.25, CLAUDE.md v2.12; план `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`.
**v3.10** — A11 ml-ai-tooling: R10.1 Блок 3 +1 строка **Jupyter MCP** (DEFERRED — требует Python ML-окружения; ml-ai-tooling, off-phase, раздел A11 карты) + Блок 1 note (v3.10) — **promptfoo** (npm devDependency `promptfoo`, CLI-eval LLM-промптов) + **Data Scientist skill** (вендоренный сторонний скил `.claude/skills/data-scientist/`). Десятая off-phase подкатегория ml-ai-tooling. Не UI → вне R6/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.10, Pravila v1.24, CLAUDE.md v2.10; план `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`.
**v3.9** — A3 integration-tooling: R10.1 Блок 3 +1 строка **openapi-mcp-server** (категория integration-tooling, off-phase, раздел A3 карты, stdio MCP, server `openapi` в `.mcp.json`, Tooling §4.22 #47). Не UI → вне R6/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.9, Pravila v1.23, CLAUDE.md v2.9; план `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`.
**v3.7** — A6-расширение deptrac: R10.1 Блок 1 +note «Блок 1 — note (v3.7)» — **deptrac** (`deptrac/deptrac` v4.6.1, Composer dev-dependency, **не** marketplace-плагин и **не** в `enabledPlugins` — регистрируется нотой, как mermaid-skill/CCPM). Категория **architecture-tooling** (Tooling #43, раздел A6 карты) — 4-й инструмент подкатегории; не UI → вне R6.0/R6.1/R14. deptrac врезан как lefthook pre-commit job 10. Содержательных изменений R0–R14: 0. Связано: Tooling v2.7, Pravila v1.21, CLAUDE.md v2.7; план `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`.
@@ -411,6 +415,7 @@ Stack — **головной**. Все плагины вне stack'а — **ин
| **CCPM** *(vendored standalone skill, `/pm` flow, 14 bash-скриптов)* | `automazeio/ccpm` (вендорен в `.claude/skills/ccpm/`) | PRD→эпик→GitHub-issue→код с полной трассируемостью. GitHub-issue-backed модель (ADR-004). PRD/epic store в `.claude/prds/`/`.claude/epics/`. Категория: **project-management** (Tooling #41, вне UI-пула). Bus-factor mitigation — вендорен (community-проект). 0 хуков | при авторинге PRD/epic и создании GitHub-issue из CCPM flow. Не UI → вне R6.0/R6.1/R14 |
| **product-management** *(6 команд `/write-spec`, `/roadmap-update` и др.)* | `anthropics/knowledge-work-plugins` (plugin `product-management@knowledge-work-plugins`, Anthropic Verified) | product-strategy церемонии (problem→spec, roadmap, stakeholder updates, research synthesis, competitive analysis, metrics review). Категория: **project-management** (Tooling #42). 0 хуков | при product-strategy work: написание спеки, обновление роадмапа, анализ конкурентов. Не UI → вне R6.0/R6.1/R14 |
| **Design plugin** *(Design Critique / Accessibility Audit / UX Writing / Research Synthesis)* | `anthropics/knowledge-work-plugins` (Anthropic Verified) | дизайн-критика и UX — ревью макетов, дизайн-уровневый a11y-аудит, UX-копирайт, research synthesis. Категория: **design-tooling** (Tooling #46, вне UI-пула) | при дизайн-критике макета, UX-анализе, написании микрокопирайта — pre-code (ADR-006). Не подменяет FD #30 (генерация) и `requesting-code-review`. Не UI → вне R6.0/R6.1/R14 |
| **operations** *(9 skills: `process-doc` / `process-optimization` / `change-request` / `capacity-plan` / `compliance-tracking` / `risk-assessment` / `runbook` / `status-report` / `vendor-review`)* | `anthropics/knowledge-work-plugins` (plugin `operations@knowledge-work-plugins` v1.2.0, Anthropic Verified) | бизнес-процессы — документирование процесса, оптимизация, change-management, capacity-планирование. Категория: **business-process** (Tooling #51, вне UI-пула). 0 lifecycle-хуков | при работе с бизнес-процессом — документирование/оптимизация/change-request/capacity. Не UI → вне R6.0/R6.1/R14 |
**Блок 1 — note (v3.3):** **mermaid-skill** (Tooling #37, генератор C4/architecture-диаграмм) — вендоренный сторонний скил в `.claude/skills/mermaid/` (`WH-2099/mermaid-skill`, MIT), **не** через marketplace и **не** в `enabledPlugins`. Пассивная утилита (генерация Mermaid-исходника), не решатель — формально вне типологии трёх блоков; регистрируется здесь для полноты. Категория **architecture-tooling**, вне R6/R14.
@@ -418,6 +423,10 @@ Stack — **головной**. Все плагины вне stack'а — **ин
**Блок 1 — note (v3.7):** **deptrac** (Tooling #43, архитектурный fitness-гейт) — Composer dev-dependency (`deptrac/deptrac` v4.6.1, BSD-3), **не** marketplace-плагин и **не** в `enabledPlugins`; врезан как lefthook pre-commit job 10 (`deptrac analyse` на staged `app/**/*.php`). CLI-инструмент статического анализа направления зависимостей между слоями — не решатель; формально вне типологии трёх блоков, регистрируется здесь для полноты. Категория **architecture-tooling** (как adr-kit/architecture-patterns), вне R6.0/R6.1/R14.
**Блок 1 — note (v3.10):** **promptfoo** (Tooling #48, ml-ai-tooling) — npm devDependency (`promptfoo`, MIT) в корневом `package.json`, **не** marketplace-плагин и **не** в `enabledPlugins`; CLI-инструмент eval LLM-промптов, запуск `npx promptfoo` вручную/CI (платные LLM-вызовы — никогда в хук, ML1). **Data Scientist skill** (Tooling #49, ml-ai-tooling) — аналогично mermaid-skill/CCPM: вендоренный сторонний скил в `.claude/skills/data-scientist/` (`sickn33/antigravity-awesome-skills`, код MIT / контент CC BY 4.0), **не** через marketplace. Оба формально вне типологии трёх блоков, регистрируются здесь для полноты. Категория **ml-ai-tooling** (раздел A11 карты), вне R6.0/R6.1/R14.
**Блок 1 — note (v3.11):** **process-modeling** (Tooling #52) + **process-analysis** (Tooling #53) — self-authored project-скилы в `.claude/skills/process-modeling/` и `.claude/skills/process-analysis/`, **не** вендоренные сторонние и **не** через marketplace; написаны проектом (паттерн project-скилов `audit-portal`/`regression`). В отличие от вендоренных mermaid-skill/CCPM/Data Scientist — **линтуются** lefthook'ом (cspell+markdownlint), **не** в `cspell.json` `ignorePaths` / `.markdownlintignore` (конфликт-аудит LINT1). Категория **business-process** (раздел C10 карты), вне R6.0/R6.1/R14.
**Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие.
#### Блок 2: Built-in skills Claude Code (всегда доступны через `Skill` tool по `/имя`)
@@ -450,6 +459,8 @@ Stack — **головной**. Все плагины вне stack'а — **ин
| **Universal Icons MCP** *(`universal-icons` сервер, tools `search_icons`/`get_icon`/`health_check`)* | `.mcp.json` (`npx -y mcp-universal-icons`, MIT) | поиск/вставка SVG-иконок — 10 коллекций включая Lucide (брендовый icon-set). Категория: **design-tooling** (Tooling #45) | при поиске иконки для Vue-компонента. НЕ запрашивать jsx/Tailwind-формат (R6.0). Материал, не решатель (R10.2). Вне R14 pipeline |
| **Figma MCP** *(remote `https://mcp.figma.com/mcp`)***DEFERRED** | `.mcp.json` (HTTP-транспорт, OAuth) — не установлен, precondition: Figma-аккаунт | извлечение дизайн-токенов/variables из Figma-источника (`get_variable_defs`). **Extract-only** (ADR-006) — code-gen не используется. Категория: **design-tooling** (Tooling #44) | DEFERRED (FM2 — у проекта нет Figma-файла). При появлении Figma-аккаунта. Extract-only — FD #30 остаётся UI-решателем. Вне R6.0/R6.1/R14 |
| **openapi-mcp-server** *(`openapi` сервер, tools `mcp__openapi__*`)* | `.mcp.json` (stdio MCP, env `OPENAPI_SPEC_URL` или локальный файл) | **integration-tooling MCP** — OpenAPI/Swagger-спецификации интеграций (inspect, introspect внешних API). Категория: **integration-tooling** (Tooling §4.22 #47). Раздел A3 карты «Программирование — интеграции (API, вебхуки)». Off-phase | при работе с внешними API-интеграциями (introspection спецификаций). **READ-ONLY introspection** — не мутировать внешние API из Claude. Не trigger'ит R6.0/R6.1 фильтры и не входит в R14 pipeline UI-генераторов. Вне R6/R14 |
| **Jupyter MCP** *(`jupyter` сервер)***DEFERRED** | `.mcp.json` (stdio MCP) — не установлен, precondition: Python ML-окружение | **ml-ai-tooling MCP** — исполняемые ноутбуки (классический ML: обучение моделей). Категория: **ml-ai-tooling** (Tooling §4.25 #50). Раздел A11 карты «ML / AI-разработка». Off-phase | DEFERRED — на native-Windows машине нет Python ML-рантайма и нет модели для обучения. Зарегистрирован как pending-слот (как Figma MCP); устанавливается отдельной severable-задачей при появлении конкретной модели. Вне R6/R14 |
| **n8n-mcp** *(`n8n` сервер)***DEFERRED** | `.mcp.json` (stdio MCP) — не установлен, precondition: принятие n8n в стек портала | **business-process MCP** — workflow-движок платформы n8n (построение/запуск автоматизированных workflow). Категория: **business-process** (Tooling §4.29 #54). Раздел C10 карты «Бизнес-процессы (общее)». Off-phase | DEFERRED — стек Лидерры не содержит n8n (движок процессов = очередь Laravel + события/джобы); принятие n8n как инфраструктуры — отдельное архитектурное решение (свой ADR), не выбор инструмента (N8N1). Зарегистрирован как pending-слот (как Figma MCP / Jupyter MCP); устанавливается отдельной severable-задачей. Вне R6/R14 |
**Отмена:** через удаление из `~/.claude.json` или `.mcp.json`. Live-override через `/команду` для MCP не предусмотрен — MCP-серверы не имеют slash-интерфейса.
+11 -1
View File
@@ -1,10 +1,14 @@
# Правила работы Claude в проекте «Лидерра»
**Версия:** v1.23 (17.05.2026)
**Версия:** v1.25 (17.05.2026)
**Дата:** 17.05.2026
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
**Что изменилось в v1.25 относительно v1.24:** §13.2 +абзац «Off-phase business-process» — формализованы инструменты раздела C10 карты «Бизнес-процессы (общее)» (#51 operations — marketplace-плагин 9 скилов; #52 process-modeling, #53 process-analysis — self-authored project-скилы; #54 n8n-mcp — DEFERRED, у портала нет n8n) как одиннадцатая off-phase подкатегория. Границы — ADR-008. Связано: Tooling v2.12 / PSR_v1 v3.11 / CLAUDE.md v2.12. План `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`.
**Что изменилось в v1.24 относительно v1.23:** §13.2 +абзац «Off-phase ml-ai-tooling» — формализованы инструменты раздела A11 карты «ML / AI-разработка» (#48 promptfoo, #49 Data Scientist skill, #50 Jupyter MCP DEFERRED) как десятая off-phase подкатегория; promptfoo делает платные LLM-вызовы — только вручную/CI, никогда в хук (ML1). Границы — ADR-007. Связано: Tooling v2.10 / PSR_v1 v3.10 / CLAUDE.md v2.10. План `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`.
**Что изменилось в v1.23 относительно v1.22:** §13.2 +абзац «Off-phase integration-tooling» — формализованы инструменты раздела A3 карты «Программирование — интеграции (API, вебхуки)» (#47 openapi-mcp-server, api-docs agent) как девятая off-phase подкатегория; READ-ONLY introspection. Связано: Tooling v2.9 / PSR_v1 v3.9 / CLAUDE.md v2.9. План `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`.
**Что изменилось в v1.22 относительно v1.21:** §13.2 +абзац «Off-phase design-tooling» — формализованы 3 инструмента раздела A4 карты «Дизайн (UI/UX, графика, бренд)» (#44 Figma MCP DEFERRED, #45 Universal Icons MCP, #46 Design plugin) как восьмая off-phase подкатегория; §13.2 PSR_v1 cross-ref синхронизирован → v3.8+. Связано: Tooling v2.8 / PSR_v1 v3.8 / CLAUDE.md v2.8. План `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`.
@@ -576,6 +580,8 @@ P0 = блокер старта спринта или регуляторного
| **v1.21** | **17.05.2026** | A6-расширение deptrac: §13.2 абзац «Off-phase architecture-tooling» расширен — формализован 4-й инструмент раздела A6 (#43 deptrac, Composer dev-dependency `deptrac/deptrac` v4.6.1 BSD-3; архитектурный fitness-гейт направления зависимостей / границ слоёв, врезан в lefthook pre-commit job 10, конфиг `app/deptrac.yaml` 13 слоёв, первый прогон 0 нарушений → baseline не нужен, red-green доказан). Категория architecture-tooling без изменений. Связано: Tooling v2.6→v2.7 (§4.18 + §0 счётчик 42→43), PSR_v1 v3.6→v3.7 (R10.1 Блок 1 note), CLAUDE.md v2.6→v2.7 (§3.3 +#43). Через manual Edit (Pravila/PSR_v1/Tooling) + `/claude-md-management:claude-md-improver` (CLAUDE.md per §5 п.10). План `docs/superpowers/plans/2026-05-17-deptrac-architecture-fitness-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
| **v1.22** | **17.05.2026** | A4 design-tooling: §13.2 +абзац «Off-phase design-tooling» — формализованы 3 инструмента раздела A4 карты «Дизайн (UI/UX, графика, бренд)» (#44 Figma MCP / #45 Universal Icons MCP / #46 Design plugin) как восьмая off-phase подкатегория, отдельная от UI-пула / infrastructure / debug-runtime / orchestration / architecture-tooling / audit-security / project-management; не UI → вне R6.0/R6.1/R14. §13.2 PSR_v1 cross-ref v3.3+ → v3.8+ (текст застрял на v3.3+ — changelog v1.18-v1.20 заявлял bump'ы, но §13.2 не обновлялся; теперь синхронизирован). Связано: Tooling v2.8 / PSR_v1 v3.8 / CLAUDE.md v2.8. План `docs/superpowers/plans/2026-05-17-a4-design-tooling-integration.md`. |
| **v1.23** | **17.05.2026** | A3 integration-tooling: §13.2 +абзац «Off-phase integration-tooling» — формализованы инструменты раздела A3 карты «Программирование — интеграции (API, вебхуки)» (#47 `openapi-mcp-server`, Tooling §4.22; `api-docs` agent, claude-flow, без Tooling-номера) как девятая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. READ-ONLY introspection. Регулируются PSR_v1 R10.1 Блок 3. Связано: Tooling v2.9 / PSR_v1 v3.9 / CLAUDE.md v2.9. План `docs/superpowers/plans/2026-05-17-a3-integration-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
| **v1.24** | **17.05.2026** | A11 ml-ai-tooling: §13.2 +абзац «Off-phase ml-ai-tooling» — формализованы инструменты раздела A11 карты «ML / AI-разработка» (#48 promptfoo — npm devDependency, CLI-eval LLM-промптов; #49 Data Scientist skill — вендоренный сторонний скил; #50 Jupyter MCP — DEFERRED, требует Python ML-окружения) как десятая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. promptfoo делает платные LLM-вызовы — только вручную/CI, никогда в хук (ML1). Границы — ADR-007. Связано: Tooling v2.10 / PSR_v1 v3.10 / CLAUDE.md v2.10. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн). План `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
| **v1.25** | **17.05.2026** | C10 business-process: §13.2 +абзац «Off-phase business-process» — формализованы инструменты раздела C10 карты «Бизнес-процессы (общее)» (#51 operations — marketplace-плагин 9 скилов; #52 process-modeling — self-authored BPMN-скил; #53 process-analysis — self-authored discovery-скил; #54 n8n-mcp — DEFERRED, workflow-движок, у портала нет n8n) как одиннадцатая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. Границы — ADR-008. Связано: Tooling v2.12 / PSR_v1 v3.11 / CLAUDE.md v2.12. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн — как v1.24). План `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
---
@@ -720,6 +726,10 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
**Off-phase integration-tooling (A3):** Инструменты раздела A3 карты «Программирование — интеграции (API, вебхуки)» — #47 `openapi-mcp-server` (Tooling §4.22; введён A3-интеграцией 17.05.2026) и `api-docs` agent (claude-flow, узел карты A3 без отдельного Tooling-номера). Off-phase, не UI → вне R6/R14 PSR_v1. READ-ONLY introspection. Регулируются PSR_v1 R10.1 Блок 3.
**Off-phase ml-ai-tooling (A11, v1.24, 17.05.2026):** Инструменты раздела A11 карты «ML / AI-разработка» — #48 `promptfoo` (Tooling §4.23; npm devDependency, CLI-eval LLM-промптов, MIT), #49 `Data Scientist skill` (Tooling §4.24; вендоренный сторонний скил в `.claude/skills/data-scientist/`, классический ML-воркфлоу; код MIT / контент CC BY 4.0), #50 `Jupyter MCP` (Tooling §4.25; **DEFERRED** — требует Python ML-окружения, на native-Windows машине не ставится; зарегистрирован как pending-слот, как Figma MCP #44). Плюс reuse-слой — claude-api skill (PSR_v1 R10.1 Блок 2), context7 MCP, Sentry MCP — без новых номеров. Десятая off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. promptfoo делает платные LLM-вызовы — запуск только вручную/CI, никогда в хук (конфликт-аудит ML1). Границы — ADR-007. Регулируются PSR_v1 R10.1 (Блок 1 — promptfoo dev-dep + Data Scientist skill вендорен; Блок 3 — Jupyter MCP). Установлены 17.05.2026 на ветке `worktree-a11-ml-ai-tooling`; план `docs/superpowers/plans/2026-05-17-a11-ml-ai-tooling-integration.md`.
**Off-phase business-process (C10, v1.25, 17.05.2026):** Инструменты раздела C10 карты «Бизнес-процессы (общее)» — #51 `operations` (Tooling §4.26; marketplace-плагин `operations@knowledge-work-plugins` v1.2.0, Anthropic Verified, 9 скилов — документирование/оптимизация/change-management/capacity бизнес-процессов; 0 lifecycle-хуков), #52 `process-modeling` (Tooling §4.27; self-authored project-скил `.claude/skills/process-modeling/` — BPMN 2.0 моделирование to-be, рендер делегируется скилу `mermaid`), #53 `process-analysis` (Tooling §4.28; self-authored project-скил `.claude/skills/process-analysis/` — as-is discovery из кода Laravel, узкие места, трассировка, метрики), #54 `n8n-mcp` (Tooling §4.29; **DEFERRED** — workflow-движок платформы n8n; стек Лидерры не содержит n8n: движок процессов = очередь Laravel + события/джобы; принятие n8n = отдельное архитектурное решение; зарегистрирован как pending-слот, как Figma MCP #44 / Jupyter MCP #50). Плюс 5 reuse-кросс-ссылок (mermaid #37, architecture-patterns #38, CCPM #41, product-management #42, superpowers writing-plans) — surface в C10 через `NODE_SECTION_SECONDARY`, без новых номеров. **Одиннадцатая** off-phase подкатегория. Off-phase, не UI → вне R6.0/R6.1/R14 PSR_v1. self-authored скилы process-modeling/process-analysis **линтуются** (cspell+markdownlint), **не** в ignorePaths — в отличие от вендоренных mermaid-skill/CCPM/Data Scientist (конфликт-аудит LINT1). Границы — ADR-008. Регулируются PSR_v1 R10.1 (Блок 1 — operations + note self-authored скилы; Блок 3 — n8n-mcp). Установлены 17.05.2026 на ветке `worktree-c10-business-process-tooling`; план `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`.
### 13.3. Скоуп
| Тип задачи | Кто отвечает |
+103 -5
View File
File diff suppressed because one or more lines are too long
@@ -2,6 +2,7 @@
- **Status:** Accepted
- **Date:** 2026-05-17
- **Amended:** 2026-05-17 — Decision item 4 added (Universal Icons icon-path boundary).
- **Deciders:** Дмитрий
## Context
@@ -29,13 +30,27 @@ Figma account yet); the boundary still applies the moment it is connected.
Phase-8 review stays with the PSR_v1 R5 aspect-split (FD owns the UI/UX aspect)
plus the Superpowers review skills. The Design plugin does not replace
`superpowers:requesting-code-review`.
4. **Universal Icons MCP raw-SVG is for non-Lucide collections only** (amendment
2026-05-17). Lucide is the project's branded icon set (CTO-19), rendered via the
`lucide-vue-next` component package plus the custom Vuetify `IconSet` mapping in
`app/resources/js/plugins/vuetify.ts` (103-entry map). For any Lucide icon that
component path is canonical. Universal Icons MCP `get_icon` raw-SVG output is
used only for collections `lucide-vue-next` does not provide (Heroicons, Tabler,
Phosphor, etc.), and the SVG is wrapped into a Vue component — never inlined to
bypass the icon system. ADR-006 originally regulated #45 only against 21st
`logo_search`; this item closes the previously unregulated #45
`lucide-vue-next` boundary.
## Consequences
- A Figma MCP code-generation call is a process violation (CLAUDE.md §5 п.6).
- Universal Icons (#45) covers UI icons; 21st `logo_search` covers brand logos —
distinct, both retained.
- These boundaries are mirrored as PSR_v1 R10.1 rows + R6/R10/R14 notes.
- Pulling a Lucide icon as raw SVG via Universal Icons MCP, instead of
`lucide-vue-next`, is a process violation (CLAUDE.md §5 п.6 — two tools on one
task).
- These boundaries are mirrored as PSR_v1 R10.1 rows + R6/R10/R14 notes; the
Decision-4 icon-path boundary is mirrored in CLAUDE.md §3.3 #45 and Tooling §4.20.
## Enforcement
+39
View File
@@ -0,0 +1,39 @@
# ADR-007: ML / AI tooling (A11)
- **Status:** Accepted
- **Date:** 2026-05-17
- **Deciders:** Дмитрий
## Context
The `A11 «ML / AI-разработка»` map section had zero tooling. Лидерра ships no
ML/AI code; `calc_lead_score` is a deterministic SQL function. A toolset is needed
for the day AI features (LLM-backed) or a scoring model are scoped.
## Decision
A11 adopts a six-position toolset in two subcategories:
- **LLM integration** — the claude-api skill (build), promptfoo (test prompts),
Sentry MCP (observe). All reuse or new-light.
- **Classical ML** — a vendored Data Scientist skill (workflow knowledge). The
executable part, **Jupyter MCP**, is **deferred**: it needs a Python ML runtime
the deliberately-minimal native-Windows machine lacks, and there is no model to
train. Jupyter MCP is a reserved registry slot, installed by a separate task
when a concrete model is scoped.
- promptfoo runs manually / CI only — never in a hook (paid LLM calls).
- A11 tools are non-UI → the `ml-ai-tooling` off-phase category.
## Consequences
- Positive: A11 populated; AI features have a build+test+observe toolchain.
- Risk: the Data Scientist skill is third-party (CC BY 4.0 content) — mitigated by
vendoring with attribution into `.claude/skills/data-scientist/`.
- Cost: promptfoo is a heavy devDependency (~1090 transitive packages, one native
module). Accepted — it is dev-only tooling, not shipped to the app.
- Deferred: no Python runtime until a model is scoped — accepted, this is the
decision.
## Enforcement
None — A11 tools are advisory; verified by use and code review.
@@ -0,0 +1,44 @@
# ADR-008: Business-process tooling (C10)
- **Status:** Accepted
- **Date:** 2026-05-17
- **Deciders:** Дмитрий
## Context
The `C10 «Бизнес-процессы (общее)»` map section had zero tooling. C10 is the
catch-all of bucket C — its work (modeling, automation, analysis of business
processes) partly overlaps already-populated sections (C9 PM, E2 orchestration,
A6 diagrams). A toolset is needed without duplicating those.
## Decision
C10 adopts a hybrid toolset (Approach 3):
- **operations plugin** (`operations@knowledge-work-plugins`, Anthropic) —
process documentation, change management, capacity planning.
- **process-modeling skill** — self-authored vendored skill: BPMN 2.0, process
maps, RACI, state-machines. Renders via the mermaid skill.
- **process-analysis skill** — self-authored vendored skill: as-is discovery,
bottlenecks, traceability, BP metrics.
- **Five reuse cross-references** (mermaid, architecture-patterns, CCPM,
product-management, writing-plans) surfaced via `NODE_SECTION_SECONDARY` — no
re-tagging of their home sections.
- **n8n-mcp** (workflow engine) is **deferred**: the portal stack has no n8n
(the process engine is the Laravel queue); adopting n8n is an architecture
decision with its own ADR. n8n-mcp is a reserved registry slot.
- C10 tools are non-UI → the `business-process` off-phase category, outside the
PSR_v1 UI-pool.
## Consequences
- Positive: C10 populated; modeling + automation + analysis covered with zero
duplication of C9/E2/A6 tools.
- Risk: the two skills are self-authored — owned by the project, no upstream
dependency (this is the mitigation, not a risk).
- Deferred: no workflow engine until n8n is adopted as infrastructure — accepted,
this is the decision.
## Enforcement
None — C10 tools are advisory; verified by use and code review.
+101 -11
View File
@@ -228,10 +228,10 @@ function pos(ring, angleDeg) {
const NODES = [
// ── ПРАВИЛА (4) ── центр + первое кольцо ───────
{ id: 'pravila', label: 'Pravila v1.16', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
{ id: 'claude_md', label: 'CLAUDE.md v2.2', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
{ id: 'psr_v1', label: 'PSR_v1 v3.2', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
{ id: 'tooling', label: 'Tooling v2.2', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
{ id: 'pravila', label: 'Pravila v1.24', group: 'rules', size: 38, ring: 0, ...pos(0, 0) },
{ id: 'claude_md', label: 'CLAUDE.md v2.10', group: 'rules', size: 34, ring: 1, ...pos(1, 30) },
{ id: 'psr_v1', label: 'PSR_v1 v3.10', group: 'rules', size: 32, ring: 1, ...pos(1, 150) },
{ id: 'tooling', label: 'Tooling v2.10', group: 'rules', size: 30, ring: 1, ...pos(1, 270) },
// ── ПЛАГИНЫ (13) ── второе кольцо ──────────────
{ id: 'superpowers', label: 'Superpowers v5.1', group: 'plugins', size: 30, ring: 2, ...pos(2, 45) },
@@ -282,6 +282,14 @@ const NODES = [
{ id: 'sk_audit_portal', label: 'audit-portal', group: 'skills_proj', size: 20, ring: 3, ...pos(3, 325) },
// C9 project-management-tooling (17.05.2026) — вендоренный скил раздела «Управление проектами»
{ id: 'ccpm', label: 'CCPM\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 335) },
// A11 ml-ai-tooling (17.05.2026) — скилы и CLI раздела «ML / AI-разработка»
{ id: 'claude_api', label: 'claude-api\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 345) },
{ id: 'data_scientist', label: 'Data Scientist\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 355) },
{ id: 'promptfoo', label: 'promptfoo', group: 'plugins', size: 20, ring: 2, ...pos(2, 365) },
// C10 business-process (17.05.2026) — плагин и скилы раздела «Бизнес-процессы (общее)»
{ id: 'ops_plugin', label: 'operations\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 385) },
{ id: 'process_modeling', label: 'process-modeling\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 367) },
{ id: 'process_analysis', label: 'process-analysis\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 377) },
// ── ХУКИ (12) — S+infra + E (economy/skill) ───
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
@@ -542,6 +550,16 @@ const EDGES = [
E('tooling', 'mcp_openapi', '§4.22 #47 — реестр'),
E('ag_apidocs', 'mcp_openapi', 'спека → MCP-ресурс'),
// ── A11 ML-AI-TOOLING 17.05.2026 — связи новых узлов ──
E('psr_v1', 'promptfoo', 'R10.1 блок 1:\nml-ai-tooling'),
E('tooling', 'claude_api', 'reuse — built-in skill\n(PSR_v1 R10.1 блок 2)'),
E('tooling', 'data_scientist', '§4.24 #49 — реестр'),
// ── C10 BUSINESS-PROCESS 17.05.2026 — связи новых узлов ──
E('psr_v1', 'ops_plugin', 'R10.1 блок 1:\nbusiness-process'),
E('tooling', 'process_modeling', '§4.27 #52 — реестр'),
E('tooling', 'process_analysis', '§4.28 #53 — реестр'),
// ══════════════════════════════════════════════════
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
@@ -632,7 +650,7 @@ const NODE_DETAILS = {
'Править можно только через скил `/claude-md-management:claude-md-improver` или `:revise-claude-md` (правило §5 п.10). Прямые Edit/Write блокируются хуком предупреждения.',
[{ name: 'Pravila', cond: 'всегда подчинён (уровень 2a)' }],
[
{ name: 'Tooling v2.2', cond: 'ссылается как на реестр инструментов' },
{ name: 'Tooling v2.10', cond: 'ссылается как на реестр инструментов' },
{ name: 'плагин claude-md-management', cond: 'правило §5 п.10 — единственный канал правок' }
],
[
@@ -655,7 +673,7 @@ const NODE_DETAILS = {
[{ name: 'CLAUDE.md', desc: 'CLAUDE.md §5 п.10 требует править только через скил claude-md-management, а PSR_v1 это ограничение не повторяет — риск прямых Edit', type: 'GREEN' }]
),
tooling: nd(
'Реестр 55 позиций — 35 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
'Реестр 70 позиций — 50 формализованных инструментов + 20 ruflo-плагинов; §4.10 — ruflo как advisory/automation-подсистема. Когда что использовать, команды установки, конфликты.',
'При выборе инструмента для фазы (нулевая документация / первая backend / вторая frontend / третья перед запуском в боевую среду), при добавлении нового инструмента, при обновлении версий.',
'При прямом конфликте с CLAUDE.md побеждает CLAUDE.md (оперативная карта уровня 2a). Любая правка требует синхронизации с CLAUDE.md §3.',
[
@@ -851,6 +869,58 @@ const NODE_DETAILS = {
[{ name: 'docs/api/', cond: 'источник OpenAPI-спеки' }]
),
// ── A11 ML-AI-TOOLING (17.05.2026) ──────────────
claude_api: nd(
'Скил сборки AI-фич на Anthropic SDK (prompt-кэш). Reuse — раздел A11 опирается также на context7 MCP (доки) и Sentry MCP (LLM-наблюдаемость).',
'При разработке AI-фич на Anthropic API / Claude SDK — скил задаёт паттерны prompt-кэша, batch-запросов, tool use.',
'Reuse-узел раздела A11 (ml-ai-tooling). claude-api — встроенный скил Claude Code, не нумерованная Tooling-позиция; регистрация — Tooling «built-in skills» + PSR_v1 R10.1 блок 2. В A11 — reuse-слой (CLAUDE.md §6). Не UI → вне фильтров R6.0/R6.1/R14.',
[{ name: 'Tooling', cond: 'built-in skill — PSR_v1 R10.1 блок 2 (reuse)' }],
[],
[{ name: 'context7 MCP', cond: 'документация Anthropic SDK' }, { name: 'Sentry MCP', cond: 'LLM-наблюдаемость (off-phase reuse)' }]
),
promptfoo: nd(
'npm-CLI eval LLM-промптов: ассерты, регрессия, red-team. Запуск вручную/CI — не в хуках (платные LLM-вызовы).',
'При разработке и проверке AI-промптов — запуск test-suite promptfoo вручную или в CI. Никогда в pre-commit хук (ML1: платные вызовы).',
'Правило PSR_v1 R10.1 блок 1 note (ml-ai-tooling, off-phase). npm devDependency, тяжёлый (~1090 пакетов). Не UI → вне фильтров R6.0/R6.1/R14. Tooling §4.23 #48, CLAUDE.md §3.3 #48.',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1: ml-ai-tooling' }],
[{ name: 'ML1', cond: 'никогда в хук/pre-commit — платные LLM-вызовы' }],
[]
),
data_scientist: nd(
'Vendored-скил: классический ML-воркфлоу — выбор алгоритма, feature engineering, оценка модели.',
'При ML-задаче (выбор алгоритма, feature engineering, валидация модели) — knowledge-only playbook без генерации кода.',
'Вендорен в .claude/skills/data-scientist/ (ML3 — lefthook markdownlint+cspell исключают через job-exclude). Knowledge-only, не решатель. Не UI → вне фильтров R6.0/R6.1/R14. Tooling §4.24 #49, CLAUDE.md §3.3 #49.',
[{ name: 'Tooling', cond: '§4.24 #49 — реестр' }],
[],
[]
),
// ── C10 BUSINESS-PROCESS (17.05.2026) ────────────
ops_plugin: nd(
'Плагин Anthropic operations — 9 скилов бизнес-процессов: документирование, оптимизация, change-management, capacity-планирование.',
'При работе с бизнес-процессом — документировать процесс, спланировать change-request, рассчитать capacity. Marketplace-плагин, 0 lifecycle-хуков.',
'Правило PSR_v1 R10.1 блок 1 (business-process, off-phase). Marketplace `operations@knowledge-work-plugins` v1.2.0, тот же marketplace что #42/#46. Не UI → вне фильтров R6.0/R6.1/R14. Tooling §4.26 #51, CLAUDE.md §3.3 #51.',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1: business-process' }],
[{ name: 'OPS1', cond: 'process-doc → Mermaid-исходник; рендер за mermaid' }, { name: 'OPS5', cond: 'generic ↔ self-authored stack-grounded скилы' }],
[{ name: 'mermaid', cond: 'рендер диаграмм процесса' }]
),
process_modeling: nd(
'Self-authored скил: моделирование to-be бизнес-процесса — BPMN 2.0, карты процессов, RACI, state-машины.',
'При проектировании бизнес-процесса — выбрать артефакт (BPMN/swimlane/journey/RACI), построить модель. Рендер делегируется скилу mermaid.',
'Свой project-скил в .claude/skills/process-modeling/ (не вендоренный → линтуется, конфликт-аудит LINT1). Не UI → вне фильтров R6.0/R6.1/R14. Tooling §4.27 #52, CLAUDE.md §3.3 #52.',
[{ name: 'Tooling', cond: '§4.27 #52 — реестр' }],
[{ name: 'BPMN1', cond: 'нотация process-modeling ≠ mermaid рендер' }],
[{ name: 'mermaid', cond: 'рендер BPMN/диаграмм' }, { name: 'process-analysis', cond: 'as-is ↔ to-be пара' }]
),
process_analysis: nd(
'Self-authored скил: анализ as-is бизнес-процесса — discovery из кода Laravel, узкие места, трассировка, метрики.',
'При вскрытии существующего процесса — реконструировать из routes/jobs/audit-логов, найти узкие места, посчитать KPI.',
'Свой project-скил в .claude/skills/process-analysis/ (не вендоренный → линтуется, LINT1). Не UI → вне фильтров R6.0/R6.1/R14. Tooling §4.28 #53, CLAUDE.md §3.3 #53.',
[{ name: 'Tooling', cond: '§4.28 #53 — реестр' }],
[{ name: 'PA1', cond: 'процессные узкие места ≠ runtime (perf-analyzer)' }],
[{ name: 'process-modeling', cond: 'as-is ↔ to-be пара' }]
),
// ── СКИЛЫ SUPERPOWERS ────────────────────────────
sk_brainstorm: nd(
'Продумывает задачу вместе с заказчиком, формулирует варианты A/B/C и согласует дизайн до написания кода.',
@@ -1794,10 +1864,10 @@ const META_WINDOW = '0916.05.2026'; // окно подсчёта испо
// usesSrc: 'скил' | 'агент' | 'MCP' | 'хук' | 'memory-чтение' | 'коммиты' | 'инспекция' | '—'
const NODE_META = {
// ── ПРАВИЛА (4) — узлы-правила, напрямую не вызываются ──
pravila: { since: '06.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
claude_md: { since: '06.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
psr_v1: { since: '09.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
tooling: { since: '06.05.2026', changed: '16.05.2026', uses: null, usesSrc: '—' },
pravila: { since: '06.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
claude_md: { since: '06.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
psr_v1: { since: '09.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
tooling: { since: '06.05.2026', changed: '17.05.2026', uses: null, usesSrc: '—' },
// ── ПЛАГИНЫ (5) ──
superpowers: { since: '09.05.2026', changed: '—', uses: null, usesSrc: '—' },
@@ -1948,6 +2018,16 @@ const NODE_META = {
// ── A3 INTEGRATION-TOOLING (17.05.2026) ──
ag_apidocs: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '—' },
mcp_openapi: { since: '17.05.2026', changed: '—', uses: null, usesSrc: '—' },
// ── A11 ML-AI-TOOLING (17.05.2026) ──
claude_api: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
promptfoo: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'CLI' },
data_scientist: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
// ── C10 BUSINESS-PROCESS (17.05.2026) ──
ops_plugin: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'плагин' },
process_modeling: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
process_analysis: { since: '17.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
};
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
@@ -2030,7 +2110,7 @@ const SECTIONS = [
{ id: 'E7', bucket: 'E', label: 'Исследования' },
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
];
// Узел -> раздел. Покрывает все 118 узлов карты.
// Узел -> раздел. Покрывает все 124 узлов карты.
const NODE_SECTION = {
// правила (4)
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
@@ -2082,6 +2162,10 @@ const NODE_SECTION = {
mcp_figma: 'A4', mcp_icons: 'A4', design_plugin: 'A4',
// A3 integration-tooling 17.05.2026 — раздел «Программирование — интеграции» наполнен
ag_apidocs: 'A3', mcp_openapi: 'A3',
// A11 ml-ai-tooling 17.05.2026 — раздел «ML / AI-разработка» наполнен
claude_api: 'A11', promptfoo: 'A11', data_scientist: 'A11',
// C10 business-process 17.05.2026 — раздел «Бизнес-процессы (общее)» наполнен
ops_plugin: 'C10', process_modeling: 'C10', process_analysis: 'C10',
};
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
@@ -2092,6 +2176,12 @@ const NODE_SECTION_SECONDARY = {
ag_pest: ['A3'],
mcp_semgrep: ['A3'],
mcp_sentry: ['A3'],
// C10 business-process 17.05.2026 — кросс-реф reuse-инструментов раздела «Бизнес-процессы»
mermaid_skill: ['C10'],
arch_patterns: ['C10'],
ccpm: ['C10'],
product_mgmt: ['C10'],
sk_wplans: ['C10'],
};
// Производные индексы для рендера панели и Паспорта.
const SECTION_BY_ID = new Map(SECTIONS.map(s => [s.id, s]));
+48
View File
@@ -0,0 +1,48 @@
# docs/ml — ML / AI playbook (map section A11)
Home of the `A11 «ML / AI-разработка»` section. Defines the tooling Лидерра uses
to build and test ML/AI capability. The portal currently ships no ML/AI code —
this section is the toolset, ready for when AI features are scoped.
## Toolset
| Tool | Role | Status |
|---|---|---|
| **claude-api skill** | Build AI features on the Anthropic SDK (lead qualification, call summaries, email drafts) with prompt caching. | reuse — already available |
| **context7 MCP** | Up-to-date docs for AI/ML libraries and SDKs. | reuse — already installed |
| **Sentry MCP** | Debug AI features in production via Sentry AI/LLM monitoring (read-only). | reuse — Tooling #34, pending the Sentry deployment (Б-1) |
| **promptfoo** | Test suite for LLM prompts/agents: assertions, regression, LLM-graded eval, red-team. | installed — `npx promptfoo` |
| **Data Scientist skill** | Classical-ML workflow: business objective → ML task, algorithm selection, feature engineering, evaluation. | installed — vendored skill |
| **Jupyter MCP** | Executable notebooks for real model training. | **deferred** — see below |
## Boundaries (which tool for which job)
- **Building an AI feature** (a prompt-backed endpoint) → the **claude-api skill**.
- **Testing / regression-checking an LLM prompt****promptfoo** (`docs/ml/promptfoo-example/`).
- **A classical-ML modelling question** (which algorithm, how to evaluate) → the
**Data Scientist skill** (`.claude/skills/data-scientist/`).
- **Executing a notebook / training a model****Jupyter MCP***deferred*.
- promptfoo's **red-team** tests *prompts*; the D3 Trail of Bits / Semgrep tools do
SAST of *code*. Different objects — not a duplication.
## promptfoo — running an eval
promptfoo makes **paid** Anthropic API calls. It runs **manually or in CI only**
never in a git hook, never in pre-commit, never automatically.
- API key: `ANTHROPIC_API_KEY` env var (PowerShell User scope — the Sentry
`SENTRY_AUTH_TOKEN` pattern). Never commit a key.
- Run the seed example: `npm run eval:llm` (or
`npx promptfoo eval -c docs/ml/promptfoo-example/promptfooconfig.yaml`).
- Footprint note: promptfoo is a large devDependency (~1090 transitive packages,
one native module — `better-sqlite3` — which `prebuild-install` fetches as a
prebuilt binary; no local C++ toolchain is required when the prebuild download
succeeds). It is dev-tooling only — not shipped to the Laravel app.
## Jupyter MCP — why deferred
Jupyter MCP executes notebooks; it needs a Python ML environment (pandas /
scikit-learn / Jupyter). The machine is native Windows, deliberately runtime-minimal
(no Docker), and there is no model to train yet. Jupyter MCP is a **reserved slot**:
registered in the Tooling registry as *pending*, installed by a separate severable
task when a concrete ML model is scoped. See the A11 plan's "Deferred Task".
+20
View File
@@ -0,0 +1,20 @@
# promptfoo example — lead-qualification eval
A worked promptfoo eval: a HOT/WARM/COLD lead-classification prompt with three
assertion cases. Demonstrates the A11 prompt-testing workflow.
## Run
```bash
# from the repo root; ANTHROPIC_API_KEY must be set (PowerShell User scope)
npm run eval:llm
```
This makes **paid** Anthropic API calls. Run it manually or in CI only — never
in a git hook or pre-commit (A11 rule ML1). See `docs/ml/README.md`.
## Adapt
Copy `promptfooconfig.yaml` next to a real prompt when an AI feature is built.
Swap the model, add `tests`, use richer assertions (`contains`, `llm-rubric`,
cost/latency thresholds). Full reference: <https://promptfoo.dev/docs/>.
@@ -0,0 +1,31 @@
# yaml-language-server: $schema=https://promptfoo.dev/config-schema.json
# Seed example — A11. Lead-qualification prompt eval.
# Run manually: npm run eval:llm (needs ANTHROPIC_API_KEY — never in CI/hooks)
description: "Лидерра — lead-qualification prompt eval (example)"
prompts:
- |
Классифицируй обращение лида как HOT, WARM или COLD.
Ответь РОВНО одним словом — HOT, WARM или COLD.
Обращение: {{message}}
providers:
- id: anthropic:messages:claude-haiku-4-5-20251001
tests:
- vars:
message: "Нужно срочно, бюджет согласован, готовы подписать договор сегодня."
assert:
- type: equals
value: HOT
- vars:
message: "Интересно, расскажите подробнее про условия и сроки."
assert:
- type: equals
value: WARM
- vars:
message: "Просто смотрю что есть на рынке, ничего конкретного."
assert:
- type: equals
value: COLD
+43
View File
@@ -0,0 +1,43 @@
# docs/process — business-process playbook (map section C10)
Home of the `C10 «Бизнес-процессы (общее)»` section. Defines the tooling Лидерра
uses to model, automate and analyze business processes.
## Toolset
| Tool | Role | Status |
|---|---|---|
| **operations plugin** | 9 skills — `process-doc`, `process-optimization`, `change-request`, `capacity-plan`, `compliance-tracking`, `risk-assessment`, `runbook`, `status-report`, `vendor-review`. | installed — `operations@knowledge-work-plugins` v1.2.0 |
| **process-modeling skill** | Model a to-be process — BPMN 2.0, process maps, customer-journey / value-stream, RACI, state-machines. | installed — self-authored skill |
| **process-analysis skill** | Analyze an as-is process — discovery from code/audit-logs, bottlenecks, traceability, BP metrics. | installed — self-authored skill |
| **n8n-mcp** | Workflow-automation engine. | **deferred** — see below |
| mermaid · architecture-patterns · CCPM · product-management · writing-plans | Reuse — diagram render · DDD process boundaries · requirements→process traceability · process specs/metrics · process decomposition. | reuse — cross-referenced into C10 |
## Boundaries (which tool for which job)
- **Documenting a process / a change to it / capacity / generic optimization**
the **operations** plugin skills (`process-doc`, `change-request`,
`capacity-plan`, `process-optimization`, …).
- **Designing a to-be process model** (formal BPMN 2.0, swimlane, state-machine) →
the **process-modeling** skill — which delegates *rendering* to the **mermaid**
skill (operations and process-modeling emit Mermaid source; mermaid renders —
OPS1/BPMN1).
- **Reverse-engineering an as-is process from the Лидерра codebase** → the
**process-analysis** skill (discovery from `routes/`, `app/Jobs`, `audit_*`
tables — what the generic `process-optimization` skill cannot do; OPS5).
- The operations `capacity-plan` skill plans **cross-functional business-process**
capacity; dev-team sprint capacity is product-management `/sprint-planning` (OPS2).
- The operations `change-request` skill = a **business-process** change; an
architecture decision is an ADR (adr-kit); a normative-doc edit is
claude-md-management (OPS3).
- process-analysis finds **business-process** bottlenecks; **code/runtime**
performance is `perf-analyzer` / `analysis:bottleneck-detect` (PA1).
## n8n-mcp — why deferred
n8n-mcp builds workflows for the n8n platform. The Лидерра stack has **no n8n**
the process engine is the Laravel queue (Redis) + events/jobs. Adopting n8n as
portal infrastructure is an architecture decision, not a tooling pick. n8n-mcp is a
**reserved slot**: registered in the Tooling registry as *pending*, installed by a
separate severable task when (and if) n8n is adopted via its own ADR. See the C10
plan's "Deferred Task".
@@ -0,0 +1,38 @@
# Worked example — the deal-lifecycle process
A worked `process-modeling` artifact: the lead→deal lifecycle as a BPMN-style
swimlane. Demonstrates the C10 modeling workflow. Status slugs are illustrative —
the source of truth for the funnel is `db/schema.sql`.
```mermaid
flowchart TD
subgraph Поставщик
A((Лид поступил)) --> B[Лид в проекте-канале]
end
subgraph Менеджер
B --> C{Лид валиден?}
C -->|нет| D((Отклонён))
C -->|да| E[Создать сделку — статус new]
E --> F[Квалификация]
F --> G{Квалифицирован?}
G -->|нет| H((Потерян — lost))
G -->|да| I[Работа по сделке — in_progress]
end
subgraph Система
I --> J{Итог сделки}
J -->|успех| K((Выиграна — won))
J -->|провал| H
K --> L[Списание по тарифу — LedgerService]
L --> M((Завершено))
end
```
## How this was built
1. **process-analysis** (discovery mode) reconstructed the as-is flow from
`routes/*.php`, the deal controllers, and the status enum in `db/schema.sql`.
2. **process-modeling** chose the swimlane artifact (three roles, branching) and
wrote the BPMN structure.
3. The **mermaid** skill rendered the source above.
4. **operations** `process-doc` skill would wrap this into a full process
document when one is needed.
@@ -0,0 +1,884 @@
# C10 Business-Process Tooling Integration Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Populate the empty `C10 «Бизнес-процессы (общее)»` map section with a conflict-minimal business-process toolset — install the **operations** marketplace plugin, author **two self-authored vendored skills** (`process-modeling`, `process-analysis`), surface **five reuse cross-references**, and register **n8n-mcp as a deferred reserved slot** — so C10 becomes a working playbook covering modeling + automation + analysis.
**Architecture:** C10 is an **empty** functional section — `NODE_SECTION` in `docs/automation-graph.html` tags zero nodes `C10`. Approach 3 (hybrid + vendoring, chosen 2026-05-17): one **marketplace plugin** (`operations@knowledge-work-plugins`, Anthropic — the same marketplace as the already-integrated #42 product-management / #46 design), **two self-authored vendored skills** into `.claude/skills/process-modeling/` and `.claude/skills/process-analysis/` (no plugin, no marketplace, no hooks — the project-skill pattern of `audit-portal`/`regression`), and **five reuse cross-references** of already-installed tools surfaced in C10 via `NODE_SECTION_SECONDARY` (the A3 XREF pattern). **n8n-mcp** (workflow engine) is **deferred** — the portal stack has no n8n (the process engine is the Laravel queue); adopting n8n is an architecture decision, not a tooling pick. n8n-mcp is registered now as a reserved slot, installed later by a separate severable task. All C10 tools are non-UI → a new **business-process** off-phase category, outside the PSR_v1 UI-pool. C10 artifacts live in `docs/process/`.
**Tech Stack:** the `operations` plugin (`anthropics/knowledge-work-plugins` marketplace, Anthropic Verified); two self-authored Claude Code skills (markdown — `SKILL.md` + `references/`); n8n-mcp (`czlonkowski/n8n-mcp`, MIT — deferred, not installed); the already-installed mermaid / architecture-patterns / CCPM / product-management / superpowers (reuse cross-refs); project normative docs; `docs/automation-graph.html` (vis.js).
**Sequencing (2026-05-17):** the worktree branch `worktree-c10-business-process-tooling` was created from `origin/main` (`008c8a3`, A11 landed) and already holds the brainstorming spec commit (`cd56efb`). C10's Tooling numbers are runtime-resolved (NUM1) — never hard-coded before reading the live counter. Push pattern: `git push origin worktree-c10-business-process-tooling:main`.
---
## Tool Identity (verified 2026-05-17 via WebSearch / WebFetch)
| # | Tool | Install mode | Source / License | Hooks? |
|---|---|---|---|---|
| 1 | **operations plugin** — 9 business-process skills: `process-doc`, `process-optimization`, `change-request`, `capacity-plan`, `compliance-tracking`, `risk-assessment`, `runbook`, `status-report`, `vendor-review`. | Claude Code **marketplace plugin**`operations@knowledge-work-plugins` v1.2.0; marketplace `anthropics/knowledge-work-plugins` (already added — #42/#46 installed from it) | GitHub `anthropics/knowledge-work-plugins`, Anthropic Verified | **None** — skills-only plugin, no `hooks/` dir, no `commands/` dir (verified on install 2026-05-17, Task 2) |
| 2 | **process-modeling skill** — BPMN 2.0 notation, process maps, customer-journey / value-stream maps, RACI matrices, state-machine modeling. Renders via the `mermaid` skill. | **Self-authored** standalone skill — created in `.claude/skills/process-modeling/` (no plugin, no marketplace) | Project-authored — content in this plan (Task 3) | None — self-authored, no `hooks` block |
| 3 | **process-analysis skill** — process discovery (reverse-engineer the as-is process from Laravel code + audit-log tables), bottleneck analysis, requirement→process traceability, business-process KPI/metrics. | **Self-authored** standalone skill — created in `.claude/skills/process-analysis/` | Project-authored — content in this plan (Task 4) | None — self-authored, no `hooks` block |
| — | **n8n-mcp** (`czlonkowski/n8n-mcp`) — workflow-automation MCP server for the n8n platform. | **NOT installed** — deferred reserved slot (see "Deferred Task"). | GitHub `czlonkowski/n8n-mcp`, **MIT** | n/a — not installed |
| — | **mermaid** / **architecture-patterns** / **CCPM** / **product-management** / **superpowers writing-plans** | **Reuse** — already installed; surfaced in C10 via `NODE_SECTION_SECONDARY` | A6 #37/#38, C9 #41/#42, superpowers | n/a |
**Verification status:** operations — plugin **name `operations`** confirmed via the live `anthropics/knowledge-work-plugins` `.claude-plugin/marketplace.json` (WebFetch 2026-05-17); plugin-id `operations@knowledge-work-plugins`. **Installed 2026-05-17 (Task 2): v1.2.0, scope user.** It ships **9 skills** (`process-doc`, `process-optimization`, `change-request`, `capacity-plan`, `compliance-tracking`, `risk-assessment`, `runbook`, `status-report`, `vendor-review`) — **not** `/ops:*` slash-commands — and **no lifecycle hooks** (no `hooks/` / `commands/` dir). n8n-mcp — repo `czlonkowski/n8n-mcp`, MIT, Claude Code compatible, confirmed via WebSearch; **not installed by this plan**.
**Deferred (with reason — no task in this plan):**
- **n8n-mcp** — a workflow engine for the n8n platform. The Лидерра stack has **no n8n**: business-process automation runs through the Laravel queue (Redis) + events/jobs. Adopting n8n as portal infrastructure is an **architecture decision** (its own ADR), not a tooling pick. Registered in the Tooling registry as a **pending** slot (Task 7); installed later by the severable task in "Deferred Task" below.
**Dropped (with reason — no task, no slot):**
- **A community BPMN MCP / mcpmarket BPMN skill** — Decision 2 (2026-05-17) chose a self-authored vendored skill; community BPMN skills have unverified provenance (the FM2 risk). `process-modeling` covers the niche.
- **A dedicated DMN / business-rules tool** — the portal's business rules (`calc_lead_score`, `PricingTierResolver`, status transitions) are simple; decision flows are covered by `process-modeling` (gateways) + mermaid. A separate tool would be a §5 п.6 duplication.
- **A process-mining tool** — heavy, no verified Claude plugin; the `process-analysis` skill's discovery mode covers light process reconstruction from audit logs.
---
## Design Decisions & Conflict Audit
Pattern follows the AK1CC1 / ML1NUM1 audits used for the A6 / C9 / D3 / A11 plans. Verified against the live `knowledge-work-plugins` marketplace, project `.claude/settings.json`, `~/.claude/settings.json`, `.mcp.json`, `lefthook.yml`, `cspell.json`, `.markdownlintignore`, and the A11 plan.
| # | Tool | Sev | Conflict | Resolution (locked) |
|---|---|---|---|---|
| OPS1 | operations | 🟢 | The operations `process-doc` skill produces a flowchart; the `mermaid` skill (A6) renders diagrams — overlapping render. | operations emits **Mermaid source**; the `mermaid` skill stays the render-SoT. operations owns the *document* + methodology, mermaid owns *rendering*. Stated in `docs/process/README.md` (Task 5) + the Tooling entry (Task 7). |
| OPS2 | operations | 🟢 | The operations `capacity-plan` skill overlaps product-management `/sprint-planning` (C9) and the C8 HR domain. | Boundary by scope: operations `capacity-plan` = cross-functional business-process capacity; `/sprint-planning` = dev-team sprints; C8 = HR. Boundary documented in `docs/process/README.md` (Task 5). |
| OPS3 | operations | 🟢 | The operations `change-request` skill overlaps adr-kit (A6) and claude-md-management. | Distinct objects: `change-request` = business-process change; ADR = architecture decision; claude-md-management = normative-doc edit. Documented in `docs/process/README.md` (Task 5). |
| OPS4 | operations | 🟢 | knowledge-work-plugins are built primarily for "Claude Cowork" — the plugin must verifiably activate in **Claude Code**. | **Resolved on install (Task 2):** operations v1.2.0 installed (scope user), present in `~/.claude/settings.json` `enabledPlugins`, ships 9 skills auto-discovered by Claude Code, **0 lifecycle hooks**. Same marketplace as the working #42/#46. |
| OPS5 | operations vs the 2 self-authored skills | 🟢 | operations ships `process-doc` (overlaps `process-modeling`) and `process-optimization` (overlaps `process-analysis`). | Boundary: operations skills are **generic, stack-agnostic** business-process methodology. The self-authored skills cover what operations structurally cannot: **`process-modeling`** = formal BPMN 2.0 / state-machine notation rendered via `mermaid`, grounded in `db/schema.sql` (the 14-status funnel); **`process-analysis`** = as-is discovery grounded in the Лидерра codebase (`routes/`, `app/Jobs`, `audit_*` tables) — operations cannot read the repo. Each self-authored `SKILL.md` "Границы" names the operations skills explicitly; generic optimization methodology is delegated to operations `process-optimization`. |
| BPMN1 | process-modeling | 🟢 | Overlap with the `mermaid` skill. | `mermaid` renders flowchart/state; it does **not** carry BPMN 2.0 semantics (pools/lanes/gateways/events methodology). `process-modeling` owns BPMN methodology and **delegates rendering** to `mermaid`. Stated in the skill's `SKILL.md` "Границы" section (Task 3). |
| LINT1 | process-modeling, process-analysis | 🟡 | Self-authored skills `.claude/skills/process-{modeling,analysis}/**/*.md` are caught by the cspell + markdownlint pre-commit jobs. | **Decision:** these are **self-authored project skills** — they are **linted** like `audit-portal` / `regression` (NOT lint-ignored like the vendored `mermaid`/`ccpm`/`data-scientist`). Skill content is written lint-clean; new technical vocabulary (`BPMN`, `swimlane`, `gateway`, `RACI`, `throughput`, …) goes to `cspell-words.txt`. Task 1 Step 5 confirms project skills are not in `cspell.json` `ignorePaths` / `.markdownlintignore`; if they unexpectedly are → follow suit and record. |
| PA1 | process-analysis | 🟢 | Overlap with `perf-analyzer` / `analysis:bottleneck-detect` and product-management `/metrics-review`. | Different objects: `process-analysis` = *business-process* discovery/optimization; `perf-analyzer` / `bottleneck-detect` = *code/runtime* performance; `/metrics-review` = *product* metrics. Boundary in the skill's "Границы" section (Task 4) + `docs/process/README.md` (Task 5). |
| N8N1 | n8n-mcp | 🟡 | The portal stack has no n8n; the process engine is the Laravel queue. Adopting n8n = architecture decision, not a tooling pick. | **DEFERRED** (Decision 1, 2026-05-17) — registered as a reserved **pending** slot (Task 7); `.mcp.json` untouched in core scope. Activation gated on a dedicated ADR (see "Deferred Task"). |
| XREF1 | reuse layer | 🟢 | Re-tagging `mermaid_skill`/`arch_patterns`/`ccpm`/`product_mgmt`/`sk_wplans` to C10 would empty their home sections — `NODE_SECTION` is 1-node→1-section. | Reuse nodes **stay** in their home sections; C10 surfaces them via `NODE_SECTION_SECONDARY` (the A3 precedent — `mcp_boost`/`context7`/etc. `→ ['A3']`). C10 gets its own 3 new nodes in `NODE_SECTION`. |
| CAT1 | all | 🟢 | C10 is non-UI tooling. | New off-phase category **business-process** (a new off-phase subcategory — exact ordinal confirmed against the live registry in Task 7; A11 ml-ai-tooling is the 10th, so business-process is the 11th), outside the PSR_v1 UI-pool → no R6.0/R6.1 stack-filter, no R14 pipeline — same treatment as architecture-tooling (A6) / audit-security (D3) / project-management (C9) / ml-ai-tooling (A11). |
| BUS1 | all | 🟡 | Bus-factor — operations is Anthropic; the two skills are self-authored; n8n-mcp is community. | operations = Anthropic Verified, stable. `process-modeling` / `process-analysis` = **self-authored** → no upstream dependency at all. n8n-mcp = community (czlonkowski, ~16.6k★, MIT) but **deferred** + version-pinned at install. Noted in the Tooling entries (Task 7). |
| NUM1 | normative sync | 🟡 | The A6/D3/C9/A3/A4/A11 epics each bumped the Tooling counter. C10 must not collide. | Task 1 Step 6 + Task 7 Step 1 read the **live** `docs/Tooling_v8_3.md` Прил. Н §0 counter and assign C10's numbers sequentially. Expected (post-A11): operations `#51`, process-modeling `#52`, process-analysis `#53`, n8n-mcp `#54` (pending) — **verify against the live counter, never hard-code before reading it**. |
**Severable scope.** Core C10 = Tasks 1-9 (operations + the 2 skills + reuse cross-refs + `docs/process/` + normative + map + finish) — already populates and closes the section across modeling + automation + analysis. The only deferred piece, n8n-mcp, is a *future* task outside this plan (see "Deferred Task"). C10 adds **no lefthook job** and **no `.mcp.json` change** in the core scope — fewer conflicts by design (the C9/A11 shape).
---
## File Structure
| File | Created / Modified | Responsibility |
|---|---|---|
| `docs/process/` | Create dir | C10 home — the business-process playbook |
| `docs/process/README.md` | Create | The BP convention: tool boundaries (operations = *document/change/capacity* · process-modeling = *model* · process-analysis = *discover & optimize* · n8n-mcp = *deferred engine*); the reuse-layer cross-ref map; the OPS1/OPS2/OPS3/PA1 boundaries |
| `docs/process/examples/deal-lifecycle-process.md` | Create | One worked example — the deal-lifecycle process modeled as a BPMN-style swimlane (the seed artifact, the A11 `promptfoo-example` pattern) |
| `.claude/skills/process-modeling/SKILL.md` | Create (self-authored) | The process-modeling skill — frontmatter + methodology |
| `.claude/skills/process-modeling/references/bpmn.md` | Create (self-authored) | BPMN 2.0 element reference + the BPMN→mermaid rendering map |
| `.claude/skills/process-analysis/SKILL.md` | Create (self-authored) | The process-analysis skill — frontmatter + methodology |
| `.claude/skills/process-analysis/references/discovery.md` | Create (self-authored) | The Лидерра as-is discovery map: which code/tables hold which process facts |
| `docs/adr/ADR-008-business-process-tooling.md` | Create | Seed ADR documenting the C10 tooling decision + the n8n defer |
| `cspell-words.txt` | Modify | New business-process vocabulary (BPMN, swimlane, RACI, throughput, …) |
| `docs/Tooling_v8_3.md` | Modify | Прил. Н — new business-process subsections §4.26-4.29 + §0 counter bump |
| `docs/Plugin_stack_rules_v1.md` | Modify | R10.1 — new business-process rows (operations = Block 1 marketplace plugin; the 2 skills = note; n8n-mcp = Block 3 MCP, DEFERRED) |
| `docs/Pravila_raboty_Claude_v1_1.md` | Modify | §13.2 — business-process category note |
| `CLAUDE.md` | Modify (**via claude-md-management only**, §5 п.10) | §3 title count, §1 row 2b count, new §3.3 business-process rows, §6 + §9 entries |
| `docs/CHANGELOG_claude_md.md` | Modify | CLAUDE.md version-bump entry |
| `docs/automation-graph.html` | Modify | 3 new C10 nodes → `NODE_SECTION` C10; 5 cross-refs → `NODE_SECTION_SECONDARY`; header metrics |
| `.mcp.json` | **NOT modified in core scope** | n8n-mcp deferred — `.mcp.json` touched only by the conditional task |
| `cspell.json`, `.markdownlintignore` | **NOT modified** (per LINT1) | Self-authored skills are linted, not ignored — no `ignorePaths` change |
---
## Task 1: Pre-flight — baseline, branch, snapshot, fact-check
**Files:** none modified (read-only)
- [ ] **Step 1: Confirm tree state and branch**
```bash
cd "c:/моя/проекты/портал crm/Документация/.claude/worktrees/c10-business-process-tooling"
git status --short
git rev-parse --short HEAD
git branch --show-current
```
Expected: branch `worktree-c10-business-process-tooling`, HEAD at the spec commit `cd56efb` (or later). Record the HEAD SHA as the regression baseline.
- [ ] **Step 2: Snapshot the hook chain**
Read `.claude/settings.json`, `.claude/settings.local.json` (if present), and `~/.claude/settings.json`. Record every hook on `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, `PreCompact`, `PostCompact`, `Stop`. This is the OPS4 baseline — Task 2 compares against it to confirm the operations plugin added no lifecycle hooks.
Expected (`~/.claude/settings.json`): SessionStart economy-self-check; PreToolUse skill-marker/skill-check/economy-state-guard/CLAUDE.md-warn/security-guidance; UserPromptSubmit economy-mode; PostCompact economy-postcompact; Stop economy-verifier. Project `.claude/settings.json`: ruflo-recall + ruflo-queen (UserPromptSubmit), markdownlint-fix + schema-CHANGELOG-reminder (PostToolUse).
- [ ] **Step 3: Baseline regression**
```
/regression quick
```
Expected: GREEN. Record the current Pest / Vitest counts from the last green run (memory `project_state.md`). C10 touches no `app/` code → the final run (Task 9) must match.
- [ ] **Step 4: Fact-check the operations plugin + n8n-mcp**
operations — confirm the marketplace is registered and the plugin is installable:
```bash
claude plugin marketplace list
```
Expected: `knowledge-work-plugins` (`anthropics/knowledge-work-plugins`) is listed (it was added for #42 product-management / #46 design). If absent → `claude plugin marketplace add anthropics/knowledge-work-plugins`. Record the marketplace status. Confirm the plugin id is `operations@knowledge-work-plugins`.
n8n-mcp — open `https://github.com/czlonkowski/n8n-mcp` and confirm: MIT license, the npm/install form, Claude Code compatibility. This is **documentation only** — n8n-mcp is NOT installed by this plan (N8N1).
- [ ] **Step 5: LINT1 — inspect how project skills are linted**
Read `cspell.json` (`ignorePaths` array) and `.markdownlintignore`. Confirm whether `.claude/skills/` project skills (`audit-portal`, `regression`, `rls-check`) are excluded:
```bash
grep -n "skills" cspell.json .markdownlintignore
```
Expected: only the **vendored** skills (`mermaid`, `ccpm`, `data-scientist`) are in `ignorePaths` — the project's own skills are **linted**. Record the result. This locks LINT1: `process-modeling` / `process-analysis` are written lint-clean and **not** added to `ignorePaths` (if the inspection shows project skills ARE ignored wholesale, follow that pattern instead and record the deviation).
- [ ] **Step 6: Read the live Tooling counter + ADR number (NUM1)**
```bash
ls docs/adr/
```
Read `docs/Tooling_v8_3.md` Прил. Н §0 — record the **live** tool counter and the last `§4.x` subsection number. Expected (post-A11): counter `50`, last subsection `§4.25`. Record:
- C10 numbers = `counter+1 .. counter+4`: operations, process-modeling, process-analysis, n8n-mcp (pending).
- C10 subsections = next four after the last `§4.x`.
- ADR number = next free after the highest existing `ADR-NNN` (expected `ADR-008``ADR-007-ml-ai-tooling.md` exists).
- off-phase subcategory ordinal (expected 11th — ml-ai-tooling is 10th).
No repo files changed → no commit.
---
## Task 2: Install the operations plugin (OPS4)
**Files:** none in the repo — installs a Claude Code plugin into `~/.claude`
- [ ] **Step 1: Add the marketplace if missing**
If Task 1 Step 4 found `knowledge-work-plugins` absent:
```bash
claude plugin marketplace add anthropics/knowledge-work-plugins
```
Expected: marketplace added. (Usually already present from #42/#46.)
- [ ] **Step 2: Install the operations plugin**
```bash
claude plugin install operations@knowledge-work-plugins
```
Expected: the plugin installs; it appears in `claude plugin list`. Record the installed version.
- [ ] **Step 3: Verify the slash-commands surfaced (OPS4)**
In-session, confirm the `/ops:*` commands are available (e.g. `/ops:process-doc`, `/ops:change-request`, `/ops:capacity-plan`, `/ops:vendor-review`). Record the exact command list — it feeds the Tooling entry (Task 7) and `docs/process/README.md` (Task 5). If `/ops:*` do **not** appear → **stop**, report; the operations plugin does not activate in Claude Code and the design must be revisited.
- [ ] **Step 4: Verify NO lifecycle hooks were added (OPS4)**
Read the `hooks` block of `~/.claude/settings.json` AND project `.claude/settings.json`. Both must be **unchanged** vs the Task 1 Step 2 snapshot. knowledge-work plugins (#42/#46) are skills + slash-commands and added no hooks — confirm operations is the same. If a `hooks` entry appeared → stop, re-audit.
- [ ] **Step 5: Confirm `enabledPlugins`**
Confirm `operations@knowledge-work-plugins` is listed in `~/.claude/settings.json` `enabledPlugins` (so it is a formalized, PSR_v1 R0.2-compliant plugin). Record the entry. No repo files changed in Task 2 → no commit.
---
## Task 3: Author the process-modeling skill (BPMN1 / LINT1)
**Files:**
- Create: `.claude/skills/process-modeling/SKILL.md`
- Create: `.claude/skills/process-modeling/references/bpmn.md`
- [ ] **Step 1: Create the skill directory + SKILL.md**
```bash
mkdir -p ".claude/skills/process-modeling/references"
```
Create `.claude/skills/process-modeling/SKILL.md` with exactly this content:
```markdown
---
name: process-modeling
description: Моделирование бизнес-процесса — BPMN 2.0 (пулы, дорожки, задачи, гейтвеи, события), карты процессов, customer-journey / value-stream, RACI-матрицы, state-машины. Триггеры — «смоделируй процесс», «нарисуй BPMN», «карта процесса», «swimlane / дорожки», «customer journey», «RACI», проектирование state-машины (воронка сделок, цепочка джобов). Раздел C10 карты «Бизнес-процессы (общее)».
---
# Process Modeling
Превращает словесное описание бизнес-процесса в формальную модель. Скил даёт
**нотацию и методологию** — рендер диаграмм делегируется скилу `mermaid`
(process-modeling не рендерит сам — конфликт-граница OPS1/BPMN1: mermaid
остаётся рендер-SoT).
## Когда какой артефакт
| Нужно | Артефакт |
|---|---|
| Кто-что-в-каком-порядке делает, с ветвлениями | BPMN 2.0 / swimlane |
| Сквозной поток end-to-end крупными блоками | Карта процесса (flowchart) |
| Опыт клиента/лида по этапам + точки боли | Customer-journey map |
| Поток создания ценности + потери и ожидания | Value-stream map |
| Распределение ответственности по шагам | RACI-матрица |
| Конечный автомат (статусы + переходы) | State-диаграмма |
## Рабочий процесс
1. **Собрать процесс** — уточнить: триггер (что запускает), участники (роли),
шаги по порядку, ветвления и условия, итог, исключения. Неясное — один
вопрос за раз.
2. **Выбрать артефакт** по таблице выше.
3. **Построить модель** в нотации (BPMN — см. `references/bpmn.md`).
4. **Отрендерить** — передать исходник скилу `mermaid`.
5. **Свериться** — модель не должна противоречить ТЗ / `db/schema.sql` /
`Открытые_вопросы`. Процесс вне ТЗ И не в реестре открытых вопросов —
hard-стоп (Pravila §7): не моделировать молча, поднять вопрос.
## BPMN 2.0 — ядро
Полная нотация и маппинг на mermaid — `references/bpmn.md`. Кратко:
- **Pool** — организация/система; **Lane** — роль внутри pool.
- **Task** — атомарное действие; **Sub-process** — свёрнутый под-поток.
- **Gateway** — ветвление: exclusive (XOR — один путь), parallel (AND — все
пути), inclusive (OR — один и более).
- **Event** — start / intermediate / end; типы: timer, message, error.
- **Sequence flow** — порядок внутри pool; **Message flow** — между pool'ами.
## Границы
- **Рендер диаграмм** — скил `mermaid` (C10 OPS1/BPMN1). Этот скил исходник не
рисует — отдаёт его mermaid.
- **DDD-границы доменных процессов** — скил `architecture-patterns` (bounded
context = граница бизнес-процесса).
- **Документ процесса, change-request, оптимизация** — плагин `operations`
(скилы `process-doc`, `change-request`, `process-optimization`).
- **Анализ as-is процесса** (discovery, узкие места) — скил `process-analysis`.
- Этот скил — про проектирование **to-be модели**, не про вскрытие as-is.
```
- [ ] **Step 2: Create the BPMN reference**
Create `.claude/skills/process-modeling/references/bpmn.md` with exactly this content:
```markdown
# BPMN 2.0 — справочник нотации и рендер в mermaid
mermaid не имеет нативного BPMN-рендера. BPMN-модель выражается через mermaid
`flowchart` (swimlane через `subgraph` = дорожки) или `stateDiagram-v2`.
## Элементы BPMN → mermaid
| BPMN | Смысл | mermaid-выражение |
|---|---|---|
| Pool / Lane | организация / роль | `subgraph Роль ... end` |
| Task | действие | прямоугольник `id[Текст]` |
| Sub-process | свёрнутый поток | `id[[Текст]]` |
| Start event | старт | `id((Старт))` |
| End event | конец | `id((Конец))` |
| Exclusive gateway (XOR) | один путь | ромб `id{Условие?}` + подписи на рёбрах |
| Parallel gateway (AND) | все пути | ромб `id{И}` с несколькими исходящими |
| Sequence flow | порядок | `-->` |
| Message flow | между pool | `-.->` |
## Шаблон swimlane
\`\`\`mermaid
flowchart TD
subgraph Менеджер
A((Старт)) --> B[Принять лид]
B --> C{Лид валиден?}
end
subgraph Система
C -->|да| D[Создать сделку]
C -->|нет| E((Отклонён))
D --> F((Сделка создана))
end
\`\`\`
## State-машина
Для конечных автоматов (воронка сделок — 14 статусов из `db/schema.sql`)
использовать `stateDiagram-v2`:
\`\`\`mermaid
stateDiagram-v2
[*] --> new
new --> in_progress
in_progress --> won
in_progress --> lost
won --> [*]
lost --> [*]
\`\`\`
Статус-слаги — из `db/schema.sql` (источник истины воронки), не выдумывать.
## Правила
- Один gateway — один вопрос; каждое исходящее ребро подписано условием.
- Каждый путь оканчивается end-событием (нет «висящих» задач).
- Исключения (timer/error) моделировать явно, не прятать в «happy path».
```
- [ ] **Step 3: Reload and verify the skill is discoverable**
```
/reload-plugins
```
Confirm `process-modeling` is listed among available skills (project `.claude/skills/` is auto-discovered, like `audit-portal`/`regression`). Confirm neither `settings.json` `hooks` block changed.
- [ ] **Step 4: Lint + commit**
```bash
npx markdownlint-cli2 ".claude/skills/process-modeling/**/*.md"
npx cspell --no-progress --no-summary --no-gitignore ".claude/skills/process-modeling/**/*.md"
```
Expected: clean. cspell will flag technical terms (`BPMN`, `swimlane`, `gateway`, `mermaid`, `stateDiagram`, `flowchart`, …) — add the valid ones to `cspell-words.txt` (LINT1: self-authored skills are linted, not ignored). Then:
```bash
git add .claude/skills/process-modeling/ cspell-words.txt
git commit -m "feat(c10): add self-authored process-modeling skill (BPMN/process maps)"
```
---
## Task 4: Author the process-analysis skill (PA1 / LINT1)
**Files:**
- Create: `.claude/skills/process-analysis/SKILL.md`
- Create: `.claude/skills/process-analysis/references/discovery.md`
- [ ] **Step 1: Create the skill directory + SKILL.md**
```bash
mkdir -p ".claude/skills/process-analysis/references"
```
Create `.claude/skills/process-analysis/SKILL.md` with exactly this content:
```markdown
---
name: process-analysis
description: Анализ и оптимизация существующего бизнес-процесса — process discovery (реконструкция as-is процесса из кода Laravel и audit-логов), поиск узких мест, трассировка требование→процесс, метрики и KPI процесса. Триггеры — «проанализируй процесс», «где узкое место», «process discovery», «как устроен процесс X», «метрики процесса», «оптимизируй процесс». Раздел C10 карты «Бизнес-процессы (общее)».
---
# Process Analysis
Разбирает **существующий** бизнес-процесс: восстанавливает фактическую модель,
находит узкие места, считает метрики. Парный скил к `process-modeling` — тот
проектирует to-be, этот вскрывает as-is.
## Четыре режима
### 1. Process discovery — реконструкция as-is
Восстановить фактический процесс из артефактов кода (карта источников —
`references/discovery.md`): маршруты + контроллеры (точки входа), джобы/события
(асинхронные шаги), enum статусов + переходы (state-машина), audit-таблицы
(фактические следы), cron/scheduler (периодические шаги). Итог — модель,
которую можно передать `process-modeling` для отрисовки.
### 2. Bottleneck — поиск узких мест
Паттерны: ручной шаг между авто-шагами; шаг с ожиданием внешней системы; точка
сериализации (advisory-lock, `lockForUpdate`); N+1 внутри шага; ретраи/таймауты;
шаг с наибольшей долей исключений.
Граница: это **процессные** узкие места. Runtime/код-производительность —
`perf-analyzer` / скил `analysis:bottleneck-detect` (PA1).
### 3. Трассировка требование→процесс
Связать пункт ТЗ / `Открытые_вопросы` → шаги процесса → код (file:line) →
тесты. Выявить шаги без требования (скрытая логика) и требования без
реализации.
### 4. Метрики процесса
Определить KPI: throughput, cycle time, конверсия между статусами, доля
исключений, объём ручного труда. Числа берутся из БД через `Boost`, не
выдумываются.
Граница: продуктовые метрики — плагин `product-management` (`/metrics-review`).
## Рабочий процесс
1. Определить режим (1-4) по запросу.
2. Собрать факты из кода / БД / логов — никаких допущений без пинов (file:line).
3. Выдать находки: модель / список узких мест / матрицу трассировки / таблицу
метрик.
4. Рекомендации направить в `process-modeling` (to-be) или в задачи. Этот скил
код не правит.
## Границы
- **Проектирование to-be модели** — скил `process-modeling`.
- **Runtime / код-производительность**`perf-analyzer`,
`analysis:bottleneck-detect` (PA1).
- **Продуктовые метрики** — плагин `product-management`.
- **Документ / change-request процесса** — плагин `operations`.
- **Генерик-методология оптимизации процесса** — скил `process-optimization`
плагина `operations`. Этот скил — про code-grounded discovery конкретного
процесса Лидерры (вскрытие as-is), не про общую методологию и не про
проектирование to-be.
```
- [ ] **Step 2: Create the discovery reference**
Create `.claude/skills/process-analysis/references/discovery.md` with exactly this content:
```markdown
# Process discovery — карта источников as-is процесса в Лидерре
Где в коде Лидерры лежат факты о фактическом бизнес-процессе.
## Источники
| Артефакт процесса | Где искать |
|---|---|
| Точки входа процесса | `app/routes/*.php` + `app/app/Http/Controllers/**` |
| Синхронные шаги | методы контроллеров + `app/app/Services/**` |
| Асинхронные шаги | `app/app/Jobs/**`, `app/app/Events/**` + listeners |
| State-машина | enum/константы статусов + `db/schema.sql` (воронка — 14 статусов) |
| Фактические следы выполнения | `audit_*` таблицы, `audit_chain_hash` (событийный лог) |
| Периодические шаги | `app/app/Console/**` + scheduler (`partitions:create-months` и пр.) |
| Бизнес-правила в шагах | `calc_lead_score` (SQL), `PricingTierResolver`, `LedgerService` |
## Метод
1. От **точки входа** (route → controller) пройти по вызовам до терминального
состояния.
2. Каждый `dispatch()` / событие — асинхронная ветка; проследить listener/job.
3. Переход статуса = ребро state-машины; собрать все переходы в автомат.
4. Свериться с **audit-логом**: фактический порядок событий в `audit_*` может
расходиться с «проектным» — расхождение само по себе находка.
5. Зафиксировать каждый шаг пином `file:line`; без пина — это допущение, не факт.
## Антипаттерны при discovery
- Принять «happy path» за весь процесс — исключения (catch, failed jobs,
таймауты) тоже шаги.
- Пропустить cron-шаги — они не видны из route-графа.
- Доверять имени метода вместо его тела.
```
- [ ] **Step 3: Reload and verify the skill is discoverable**
```
/reload-plugins
```
Confirm `process-analysis` is listed among available skills. Confirm neither `settings.json` `hooks` block changed.
- [ ] **Step 4: Lint + commit**
```bash
npx markdownlint-cli2 ".claude/skills/process-analysis/**/*.md"
npx cspell --no-progress --no-summary --no-gitignore ".claude/skills/process-analysis/**/*.md"
```
Expected: clean. Add valid flagged terms (`throughput`, `discovery`, `bottleneck`, `listener`, …) to `cspell-words.txt`. Then:
```bash
git add .claude/skills/process-analysis/ cspell-words.txt
git commit -m "feat(c10): add self-authored process-analysis skill (discovery/bottleneck)"
```
---
## Task 5: Bootstrap the C10 home — `docs/process/` + worked example + ADR-008
**Files:**
- Create: `docs/process/README.md`, `docs/process/examples/deal-lifecycle-process.md`
- Create: `docs/adr/ADR-008-business-process-tooling.md`
- Modify (conditional): `cspell-words.txt`
- [ ] **Step 1: Create the C10 home + the BP convention**
```bash
mkdir -p "docs/process/examples"
```
Create `docs/process/README.md` with exactly this content:
```markdown
# docs/process — business-process playbook (map section C10)
Home of the `C10 «Бизнес-процессы (общее)»` section. Defines the tooling Лидерра
uses to model, automate and analyse business processes.
## Toolset
| Tool | Role | Status |
|---|---|---|
| **operations plugin** | 9 skills — `process-doc`, `process-optimization`, `change-request`, `capacity-plan`, `compliance-tracking`, `risk-assessment`, `runbook`, `status-report`, `vendor-review`. | installed — `operations@knowledge-work-plugins` v1.2.0 |
| **process-modeling skill** | Model a to-be process — BPMN 2.0, process maps, customer-journey / value-stream, RACI, state-machines. | installed — self-authored skill |
| **process-analysis skill** | Analyse an as-is process — discovery from code/audit-logs, bottlenecks, traceability, BP metrics. | installed — self-authored skill |
| **n8n-mcp** | Workflow-automation engine. | **deferred** — see below |
| mermaid · architecture-patterns · CCPM · product-management · writing-plans | Reuse — diagram render · DDD process boundaries · requirements→process traceability · process specs/metrics · process decomposition. | reuse — cross-referenced into C10 |
## Boundaries (which tool for which job)
- **Documenting a process / a change to it / capacity / generic optimization**
the **operations** plugin skills (`process-doc`, `change-request`,
`capacity-plan`, `process-optimization`, …).
- **Designing a to-be process model** (formal BPMN 2.0, swimlane, state-machine) →
the **process-modeling** skill — which delegates *rendering* to the **mermaid**
skill (operations and process-modeling emit Mermaid source; mermaid renders —
OPS1/BPMN1).
- **Reverse-engineering an as-is process from the Лидерра codebase** → the
**process-analysis** skill (discovery from `routes/`, `app/Jobs`, `audit_*`
tables — what the generic `process-optimization` skill cannot do; OPS5).
- The operations `capacity-plan` skill plans **cross-functional business-process**
capacity; dev-team sprint capacity is product-management `/sprint-planning` (OPS2).
- The operations `change-request` skill = a **business-process** change; an
architecture decision is an ADR (adr-kit); a normative-doc edit is
claude-md-management (OPS3).
- process-analysis finds **business-process** bottlenecks; **code/runtime**
performance is `perf-analyzer` / `analysis:bottleneck-detect` (PA1).
## n8n-mcp — why deferred
n8n-mcp builds workflows for the n8n platform. The Лидерра stack has **no n8n**
the process engine is the Laravel queue (Redis) + events/jobs. Adopting n8n as
portal infrastructure is an architecture decision, not a tooling pick. n8n-mcp is a
**reserved slot**: registered in the Tooling registry as *pending*, installed by a
separate severable task when (and if) n8n is adopted via its own ADR. See the C10
plan's "Deferred Task".
```
- [ ] **Step 2: Create the worked example**
Create `docs/process/examples/deal-lifecycle-process.md` with exactly this content:
```markdown
# Worked example — the deal-lifecycle process
A worked `process-modeling` artifact: the lead→deal lifecycle as a BPMN-style
swimlane. Demonstrates the C10 modeling workflow. Status slugs are illustrative —
the source of truth for the funnel is `db/schema.sql`.
\`\`\`mermaid
flowchart TD
subgraph Поставщик
A((Лид поступил)) --> B[Лид в проекте-канале]
end
subgraph Менеджер
B --> C{Лид валиден?}
C -->|нет| D((Отклонён))
C -->|да| E[Создать сделку — статус new]
E --> F[Квалификация]
F --> G{Квалифицирован?}
G -->|нет| H((Потерян — lost))
G -->|да| I[Работа по сделке — in_progress]
end
subgraph Система
I --> J{Итог сделки}
J -->|успех| K((Выиграна — won))
J -->|провал| H
K --> L[Списание по тарифу — LedgerService]
L --> M((Завершено))
end
\`\`\`
## How this was built
1. **process-analysis** (discovery mode) reconstructed the as-is flow from
`routes/*.php`, the deal controllers, and the status enum in `db/schema.sql`.
2. **process-modeling** chose the swimlane artifact (three roles, branching) and
wrote the BPMN structure.
3. The **mermaid** skill rendered the source above.
4. **operations** `/ops:process-doc` would wrap this into a full process document
when one is needed.
```
- [ ] **Step 3: Write ADR-008**
Create `docs/adr/ADR-008-business-process-tooling.md` (verify `ADR-008` is the next free number from Task 1 Step 6; adjust if not) with exactly this content:
```markdown
# ADR-008: Business-process tooling (C10)
- **Status:** Accepted
- **Date:** 2026-05-17
- **Deciders:** Дмитрий
## Context
The `C10 «Бизнес-процессы (общее)»` map section had zero tooling. C10 is the
catch-all of bucket C — its work (modeling, automation, analysis of business
processes) partly overlaps already-populated sections (C9 PM, E2 orchestration,
A6 diagrams). A toolset is needed without duplicating those.
## Decision
C10 adopts a hybrid toolset (Approach 3):
- **operations plugin** (`operations@knowledge-work-plugins`, Anthropic) —
process documentation, change management, capacity planning.
- **process-modeling skill** — self-authored vendored skill: BPMN 2.0, process
maps, RACI, state-machines. Renders via the mermaid skill.
- **process-analysis skill** — self-authored vendored skill: as-is discovery,
bottlenecks, traceability, BP metrics.
- **Five reuse cross-references** (mermaid, architecture-patterns, CCPM,
product-management, writing-plans) surfaced via `NODE_SECTION_SECONDARY` — no
re-tagging of their home sections.
- **n8n-mcp** (workflow engine) is **deferred**: the portal stack has no n8n
(the process engine is the Laravel queue); adopting n8n is an architecture
decision with its own ADR. n8n-mcp is a reserved registry slot.
- C10 tools are non-UI → the `business-process` off-phase category, outside the
PSR_v1 UI-pool.
## Consequences
- Positive: C10 populated; modeling + automation + analysis covered with zero
duplication of C9/E2/A6 tools.
- Risk: the two skills are self-authored — owned by the project, no upstream
dependency (this is the mitigation, not a risk).
- Deferred: no workflow engine until n8n is adopted as infrastructure — accepted,
this is the decision.
## Enforcement
None — C10 tools are advisory; verified by use and code review.
```
- [ ] **Step 4: Lint + commit**
```bash
npx markdownlint-cli2 "docs/process/**/*.md" "docs/adr/ADR-008-*.md"
npx cspell --no-progress --no-summary --no-gitignore "docs/process/**/*.md" "docs/adr/ADR-008-*.md"
```
Add valid flagged terms to `cspell-words.txt`. Then:
```bash
git add docs/process/ docs/adr/ADR-008-*.md cspell-words.txt
git commit -m "feat(c10): bootstrap docs/process — README + worked example + ADR-008"
```
---
## Task 6: Smoke-test the C10 toolset
**Files:** none modified
- [ ] **Step 1: Smoke-test the operations plugin**
Invoke the operations `process-doc` skill on a trivial process (e.g. "лид поступил → менеджер принял → создана сделка"). Expected: the skill loads and produces a process document / flowchart. Functional smoke — no file output required. The 9 operations skills were already enumerated on install (Task 2): `process-doc`, `process-optimization`, `change-request`, `capacity-plan`, `compliance-tracking`, `risk-assessment`, `runbook`, `status-report`, `vendor-review`.
- [ ] **Step 2: Smoke-test the process-modeling skill**
Invoke the `process-modeling` skill with a trivial request (e.g. "смоделируй процесс приёма лида в виде swimlane"). Expected: the skill loads, `SKILL.md` routes the intent, it picks the swimlane artifact and emits a BPMN/mermaid structure (delegating render to mermaid).
- [ ] **Step 3: Smoke-test the process-analysis skill**
Invoke the `process-analysis` skill with a trivial request (e.g. "process discovery: как устроен процесс создания сделки"). Expected: the skill loads, routes to discovery mode, and references the code/audit-log sources from `references/discovery.md`.
- [ ] **Step 4: Confirm the hook chain is intact (OPS4)**
Submit a trivial prompt; the economy marker still appears, the Stop verifier still runs, ruflo + CLAUDE.md-warn hooks fire. No plugin/skill leaked a `hooks` entry vs the Task 1 Step 2 snapshot. No repo files changed in Task 6 → no commit.
---
## Task 7: Normative registry sync (NUM1 / CAT1)
**Files:** Modify `docs/Tooling_v8_3.md`, `docs/Plugin_stack_rules_v1.md`, `docs/Pravila_raboty_Claude_v1_1.md`, `CLAUDE.md`, `docs/CHANGELOG_claude_md.md`
- [ ] **Step 1: Read the registry homes + the live counter (NUM1)**
Read for exact insertion points and the **current** counter: `docs/Tooling_v8_3.md` Прил. Н §0 + the last `§4.x` subsection (expected `§4.25`); `docs/Plugin_stack_rules_v1.md` R10.1 (Block 1 marketplace plugins, Block 3 MCP servers); `docs/Pravila_raboty_Claude_v1_1.md` §13.2. Assign the C10 numbers from `counter+1`:
- `#N` operations, `#N+1` process-modeling, `#N+2` process-analysis, `#N+3` n8n-mcp (registered **pending**) — sequential. Expected `#51``#54`; **verify against the live counter**, do not hard-code.
- [ ] **Step 2: Add the Tooling Прил. Н business-process subsections**
Edit `docs/Tooling_v8_3.md`: add `§4.26``§4.29` (next four after the last subsection), category **business-process** (off-phase). Per tool:
- **operations** (`#51`) — marketplace plugin `operations@knowledge-work-plugins` v1.2.0 (`anthropics/knowledge-work-plugins`), Anthropic Verified; ships **9 skills** (`process-doc`, `process-optimization`, `change-request`, `capacity-plan`, `compliance-tracking`, `risk-assessment`, `runbook`, `status-report`, `vendor-review`); **0 lifecycle hooks**; boundaries OPS1/OPS2/OPS3/OPS5.
- **process-modeling** (`#52`) — self-authored standalone skill in `.claude/skills/process-modeling/`; BPMN 2.0 + process maps; renders via mermaid (BPMN1); linted, not lint-ignored (LINT1).
- **process-analysis** (`#53`) — self-authored standalone skill in `.claude/skills/process-analysis/`; as-is discovery / bottleneck / traceability / metrics; boundary vs perf-analyzer (PA1).
- **n8n-mcp** (`#54`) — `czlonkowski/n8n-mcp`, MIT, **pending — NOT installed**; deferred severable task gated on an ADR adopting n8n (N8N1); the Sentry #34 / Jupyter #50 "pending" precedent.
Add **business-process** as the **11th** off-phase subcategory (after UI-pool, infrastructure, debug-runtime, orchestration, architecture-tooling, audit-security, project-management, design-tooling, integration-tooling, ml-ai-tooling — confirm the exact count against the live file). Bump §0 counter (`50→54`, total `70→74`). Bump the Прил. Н version header.
- [ ] **Step 3: Add PSR_v1 R10.1 rows**
Edit `docs/Plugin_stack_rules_v1.md`: R10.1 — Block 1 (marketplace plugins) += operations; Block 1 note += the two self-authored skills; Block 3 (MCP servers) += n8n-mcp marked **DEFERRED**. Category **business-process** (off-phase), explicitly *outside* the UI-pool → no R6.0/R6.1 stack-filter, no R14 pipeline. Bump the PSR_v1 version header.
- [ ] **Step 4: Add the Pravila §13.2 note**
Edit `docs/Pravila_raboty_Claude_v1_1.md` §13.2: add a one-line **business-process** category note, alongside the existing infrastructure / debug-runtime / architecture-tooling / audit-security / project-management / design-tooling / integration-tooling / ml-ai-tooling notes. Re-read Pravila §0/§13 first to keep section numbering consistent. Bump the Pravila version header.
- [ ] **Step 5: Update CLAUDE.md via the governed channel**
Invoke `/claude-md-management:claude-md-improver`. Apply: §3 title count bump, §1 priority-chain row 2b count bump, new §3.3 business-process row(s) (operations + the 2 skills + n8n-mcp DEFERRED), §6 + §9 entries. The plugin also writes the `docs/CHANGELOG_claude_md.md` entry and bumps §0 cross-ref versions (Tooling / PSR_v1 / Pravila). **Do not** edit `CLAUDE.md` directly (§5 п.10).
- [ ] **Step 6: Lint + commit**
```bash
npx markdownlint-cli2 "docs/Tooling_v8_3.md" "docs/Plugin_stack_rules_v1.md" "docs/Pravila_raboty_Claude_v1_1.md" "docs/CHANGELOG_claude_md.md"
npx cspell --no-progress --no-summary --no-gitignore "docs/Tooling_v8_3.md" "docs/Plugin_stack_rules_v1.md" "docs/Pravila_raboty_Claude_v1_1.md"
git add docs/Tooling_v8_3.md docs/Plugin_stack_rules_v1.md docs/Pravila_raboty_Claude_v1_1.md CLAUDE.md docs/CHANGELOG_claude_md.md cspell-words.txt
git commit -m "docs(c10): register business-process category — operations/process-modeling/process-analysis/n8n-mcp (NUM1)"
```
---
## Task 8: Reflect C10 on the map — close the section
**Files:** Modify `docs/automation-graph.html`
- [ ] **Step 1: Read the structures to replicate**
In `docs/automation-graph.html` read, as templates: a plugin node (`fd_plugin` / `product_mgmt`), a project-skill node (the `mermaid_skill` / `sk_qitem` shape), the `NODE_SECTION` C9/A11 comment blocks, the `NODE_SECTION_SECONDARY` A3 block, and the "Паспорт узла" `since:` date field. Record the current node/edge counts from the header and the group-count comments.
- [ ] **Step 2: Add the 3 C10 nodes**
Add to `NODES`, replicating the template shapes:
- `ops_plugin` — label `operations\n(plugin)`, `plugins` group.
- `process_modeling` — label `process-modeling\n(skill)`, `skills_proj` group.
- `process_analysis` — label `process-analysis\n(skill)`, `skills_proj` group.
Add matching `nd(...)` / `NODE_DETAILS` entries (Russian, per the file's convention), Паспорт `since: '2026-05-17'`:
- `ops_plugin` — "Плагин Anthropic operations: документирование процессов, change-management, capacity-планирование. Раздел C10."
- `process_modeling` — "Свой скил: моделирование to-be процесса — BPMN 2.0, карты процессов, RACI. Рендер — через mermaid."
- `process_analysis` — "Свой скил: анализ as-is процесса — discovery из кода, узкие места, трассировка, метрики."
- [ ] **Step 3: Map the 3 nodes to section C10**
In `NODE_SECTION` add (a new comment block, after the A11/A3 blocks):
```js
// C10 business-process 17.05.2026 — раздел «Бизнес-процессы (общее)» наполнен
ops_plugin: 'C10', process_modeling: 'C10', process_analysis: 'C10',
```
`C10 «Бизнес-процессы (общее)»` goes from 0 → 3 primary nodes — the section is no longer empty. (No `n8n_mcp` node — n8n-mcp is deferred, N8N1.)
- [ ] **Step 4: Add the 5 cross-references (XREF1)**
In `NODE_SECTION_SECONDARY` add the five reuse nodes (each maps to its own home section primarily; `['C10']` is the cross-ref):
```js
// C10 business-process 17.05.2026 — кросс-реф reuse-инструментов
mermaid_skill: ['C10'],
arch_patterns: ['C10'],
ccpm: ['C10'],
product_mgmt: ['C10'],
sk_wplans: ['C10'],
```
(Verify none of these five already has a `NODE_SECTION_SECONDARY` entry — the A3 block uses `mcp_boost`/`context7`/`ag_pest`/`mcp_semgrep`/`mcp_sentry`, no overlap. If an overlap is found, append `'C10'` to the existing array instead of adding a new key.)
- [ ] **Step 5: Update header metrics + group-count comments**
Bump the node count in the map header/legend by 3. Bump the edge count if Step 2's node details add governing edges (match how the A11 nodes were wired — replicate that pattern, else node-only). Update the `NODE_SECTION` group-count comments (`plugins`, `skills_proj`).
- [ ] **Step 6: Smoke-test the map**
```bash
npx stylelint docs/automation-graph.html
```
Open `docs/automation-graph.html` (Playwright MCP or a local `http.server` — quirk 90: `file://` rejected). Expected: 0 JS console errors; the 3 new nodes render; clicking section `C10` highlights the 3 primary nodes **and** the 5 cross-referenced nodes.
- [ ] **Step 7: Commit**
```bash
git add docs/automation-graph.html
git commit -m "feat(map): C10 nodes — closes section «Бизнес-процессы (общее)»"
```
---
## Task 9: Final regression & branch finish
**Files:** none modified
- [ ] **Step 1: Rebase onto latest origin/main (sequencing)**
```bash
git fetch origin
git rebase origin/main
```
Expected: a clean rebase. If `origin/main` moved (another epic landed touching the map / 4 normative docs / the Tooling counter) — resolve any conflict by **re-reading the live file and re-applying the C10 delta**, never blindly. Re-run the live-counter read (Task 7 Step 1) if the Tooling counter moved.
- [ ] **Step 2: Full pre-commit chain**
```bash
npx lefthook run pre-commit
```
Expected: all jobs green — C10 adds **no** lefthook job (job count unchanged vs the Task 1 baseline).
- [ ] **Step 3: Confirm app code untouched — run the suites**
C10 changes no `app/` code → suites must match the Task 1 Step 3 baseline:
```bash
cd app && php artisan test --parallel
cd .. && npm run test:vue
```
Expected: Pest and Vitest counts unchanged vs the Task 1 baseline (0 regressions). Record exact counts; write out any failure with file:line.
- [ ] **Step 4: Confirm the economy/ruflo hook chain is intact**
Economy marker still appears; the Stop verifier still runs; no plugin/skill leaked a `hooks` entry into either `settings.json`. Compare to the Task 1 Step 2 snapshot.
- [ ] **Step 5: Pre-push checks**
```bash
./bin/gitleaks.exe detect --source . --no-banner --redact
./bin/lychee.exe --config .lychee.toml "docs/**/*.md" "*.md"
```
Expected: gitleaks 0 leaks; lychee 0 broken (new `docs/process/**/*.md` + `docs/adr/ADR-008-*.md` are scanned — fix or `.lychee.toml`-exclude any link).
- [ ] **Step 6: Finish the branch**
Invoke `superpowers:finishing-a-development-branch` — present the standard options. Do **not** push without an explicit user choice. Push pattern: `git push origin worktree-c10-business-process-tooling:main`.
---
## Deferred Task (NOT in this plan — future, severable)
**n8n-mcp install — gated on adopting n8n as infrastructure.** When (and if) n8n is
adopted as a portal workflow engine, run a separate task:
1. Write an ADR deciding to adopt n8n as portal infrastructure — an explicit
architecture weighing (where n8n runs, which processes it owns vs the Laravel
queue, the operational cost).
2. Spike `czlonkowski/n8n-mcp` before integrating — confirm Claude Code
compatibility and the n8n-instance connection model.
3. Install n8n-mcp as a new `.mcp.json` server, version-pinned.
4. Flip the Tooling registry entry from **pending** to **active**; add an `n8n_mcp`
node to `docs/automation-graph.html``NODE_SECTION` C10 (now 4 primary nodes).
5. Re-run the full regression + the conflict re-audit.
Until then, C10 is fully covered by the 3 installed positions + the 5 reuse
cross-references — the automation subcategory is served by process-modeling
(state-machine design of the Laravel-queue automations) + the writing-plans / ruflo
cross-refs.
---
## Self-Review
**1. Spec coverage (the 9-position C10 coverage, Approach 3).** operations — installed (Task 2), smoked (Task 6 Step 1), registered (Task 7). process-modeling — authored (Task 3), smoked (Task 6 Step 2), registered (Task 7). process-analysis — authored (Task 4), smoked (Task 6 Step 3), registered (Task 7). n8n-mcp — registered pending (Task 7 Step 2), deferred task documented ("Deferred Task"). The 5 reuse cross-refs — surfaced on the map (Task 8 Step 4), documented in `docs/process/README.md` (Task 5 Step 1). C10 home — `docs/process/` (Task 5). Section closure: normative (Task 7), map (Task 8), regression/finish (Task 9). Conflict audit: OPS1→T5.1+T7.2, OPS2→T5.1, OPS3→T5.1, OPS4→T2.3-5, BPMN1→T3.1, LINT1→T1.5+T3.4+T4.4, PA1→T4.1+T5.1, N8N1→T7.2+"Deferred Task", XREF1→T8.4, CAT1→T7.2-4, BUS1→T7.2, NUM1→T1.6+T7.1+T9.1. No gaps.
**2. Placeholder scan.** `#N`/`#51``#54`, `ADR-008` (verified in Task 1 Step 6), the `§4.26``§4.29` numbers, and the map node group choice are **runtime-resolved / verified-in-Task-1 by design** — the live Tooling counter and the live ADR/subsection numbers are not knowable before Task 1, and each carries concrete resolution criteria (the A11 NUM1 pattern). The `/ops:*` command list is verified at install (Task 2 Step 3) — the design cannot pin it before the plugin is installed. All file contents shown in full — `docs/process/README.md`, the worked example, ADR-008, both `SKILL.md` files, both `references/*.md` files. No "TBD" / "handle edge cases".
**3. Consistency.** Branch `worktree-c10-business-process-tooling` consistent T1↔T9. Node ids `ops_plugin` / `process_modeling` / `process_analysis` consistent T8 Steps 2-3 + the `docs/process/README.md` table. Skill names `process-modeling` / `process-analysis` consistent across the SKILL.md frontmatter, the `.claude/skills/` paths, the Tooling entries (Task 7), and the map nodes. Category name **business-process** consistent T7 Steps 2-4 + ADR-008. n8n-mcp flagged **deferred/pending** uniformly (Tool Identity, N8N1, Task 7 Step 2, Task 8 Step 3, "Deferred Task"). LINT1 resolved consistently — skills linted, no `cspell.json`/`.markdownlintignore` change (File Structure + Task 3/4 Step 4). No lefthook job added.
---
## Execution Handoff
Plan complete and saved to `docs/superpowers/plans/2026-05-17-c10-business-process-tooling-integration.md`. Two execution options:
1. **Subagent-Driven** — fresh subagent per task, two-stage review. *Caveat:* Task 2 (`claude plugin install`, `/ops:*` verification), Task 3/4 Step 3 (`/reload-plugins`, skill smoke), Task 6 (skill/plugin invocations) and Task 7 Step 5 (`/claude-md-management`) are main-session-bound — those steps stay with the controller.
2. **Inline Execution**`superpowers:executing-plans`, batch with checkpoints. **Recommended here** — install/config/docs-heavy with many interactive main-session steps (the A6/C9/D3/A11 pattern).
One open item before execution: execution method — **1** (Subagent-Driven) or **2** (Inline, recommended here).
File diff suppressed because it is too large Load Diff

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