Compare commits

...

43 Commits

Author SHA1 Message Date
Дмитрий b40f2c8ffb feat(map): discovery_interview node — discovery-tooling, E5 section
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:35:36 +03:00
Дмитрий 63337b418d docs(discovery): process-analysis — reciprocal SKIP boundary to discovery-interview
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:28:34 +03:00
Дмитрий 2ebc776cc9 docs(discovery): register discovery-tooling — Tooling/PSR/Pravila/CLAUDE.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 06:37:16 +03:00
Дмитрий a0691e8857 docs(discovery): ADR-009 — discovery-interview tooling decision
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 06:24:51 +03:00
Дмитрий 50fc188f01 feat(discovery): add docs/discovery — README + brief/snapshot templates
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 06:23:42 +03:00
Дмитрий 14f92d5147 feat(discovery): add discovery-interview skill — FEATURE + SYSTEM modes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 06:22:08 +03:00
Дмитрий 802cda1b34 docs(discovery): brainstorming spec + integration plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 05:28:58 +03:00
Дмитрий 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
106 changed files with 8143 additions and 2927 deletions
+142
View File
@@ -0,0 +1,142 @@
---
name: discovery-interview
description: Структурированное интервью-discovery ПЕРЕД проектированием. Два режима. FEATURE — заказчик описывает проблему, боль или цель без готового решения («менеджеры жалуются на…», «сделки теряются», «хочу чтобы…»): JTBD-интервью вскрывает проблему до решения и отдаёт discovery-brief в brainstorming. SYSTEM — запрос ориентации по проекту («сориентируй», «где мы сейчас», «что в тулчейне / на карте», «catch-up по…»): синтез по мета-слою (карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log). SKIP — чёткий директив на реализацию («интегрируй X», «закрой находку Y», «поправь Z»): это не discovery. SKIP — анализ бизнес-процесса из кода или диагностика просадки измеримой метрики/конверсии («как устроен процесс X», «process discovery», «где узкое место», «почему просела конверсия»): это skill process-analysis. Используй при «discovery interview», «проведи discovery», «сориентируй по проекту» и при расплывчатом проблемном запросе, даже если слово «discovery» не названо.
---
# Discovery Interview
Структурированное интервью, которое вскрывает **проблему** прежде, чем кто-либо
начнёт проектировать решение. Два режима — FEATURE (интервью заказчика перед
фичей) и SYSTEM (интервью-ориентация по состоянию проекта).
Зачем скил существует: запрос вида «менеджеры жалуются на X» или «хочу, чтобы Y» —
это симптом, не задача. Уйдёшь сразу в дизайн — спроектируешь решение не той
проблемы. Discovery interview удерживает разговор в проблемном поле ровно столько,
сколько нужно, чтобы понять *настоящую* потребность, и только потом передаёт
эстафету проектированию.
## Когда какой режим
| Запрос | Действие |
|---|---|
| Заказчик описал проблему / боль / цель без решения | режим **FEATURE** |
| Заказчик просит сориентировать по проекту | режим **SYSTEM** |
| Заказчик дал чёткий директив («сделай X», «интегрируй Y») | скил не нужен — работай напрямую |
| Вопрос про устройство бизнес-процесса из кода | скил `process-analysis`, не этот |
## Несущий принцип — три слоя-источника
Этот скил соседствует со скилом `process-analysis` (раздел C10 карты). Чтобы не
дублировать его, способности разведены по **слою данных**, с которым работают:
| Способность | Слой-источник | Метод |
|---|---|---|
| `process-analysis` | app-код — `routes/`, `app/Jobs`, `audit_*` | реконструкция бизнес-процесса из кода |
| discovery-interview **FEATURE** | голова заказчика | интервью человека |
| discovery-interview **SYSTEM** | мета-слой — карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log | интервью + синтез |
Правило разведения: если ответ добывается **чтением кода** — это `process-analysis`.
Если ответ лежит в голове заказчика или в управляющих документах — это
discovery-interview.
## Режим FEATURE
### Триггер
Заказчик описывает проблему, боль, раздражение или цель — но НЕ готовое решение.
Признаки: «менеджеры жалуются…», «X теряется», «неудобно делать Y», «хочу, чтобы…»,
«было бы хорошо, если…».
### SKIP
Не запускай FEATURE, если запрос — чёткий директив на реализацию: «интегрируй X»,
«закрой находку Y», «поправь Z», «добавь endpoint». Проблема уже понята заказчиком,
discovery только затормозит. Работай напрямую — или через `brainstorming`, если
дизайн решения нетривиален.
Не запускай FEATURE и если запрос — **диагностика просадки измеримой метрики или
конверсии** («почему падает конверсия B2», «где теряем в воронке», «почему лиды не
доходят до оплаты»). Ответ там добывается анализом кода и audit-данных — это скил
`process-analysis`. FEATURE — про UX-боль и желаемые возможности, не про диагностику
чисел.
### Процесс
1. **Один вопрос за раз.** Не вываливай список — это интервью, не анкета. Ответ на
первый вопрос определяет второй.
2. **Спрашивай про прошлое поведение, не про гипотетику.** «Расскажи, как ты делал
это в последний раз» сильнее, чем «как бы ты хотел». Люди плохо предсказывают
своё поведение и точно помнят прошлое.
3. **Копай до корня — «5 почему».** Первая названная проблема обычно симптом.
4. **Не задавай наводящих вопросов.** «Тебе мешает отсутствие фильтра?» подсказывает
ответ. Спроси открыто: «что именно замедляет тебя на этом экране?».
5. **Поняв проблему — собери discovery-brief и остановись.** Не проектируй решение —
это работа `brainstorming`.
Банк вопросов по шагам JTBD — `references/jtbd-questions.md`.
### Артефакт — discovery-brief
Проблема · JTBD (какую работу заказчик «нанимает» решение сделать) · Текущий обходной
путь · Цена боли (время / деньги / частота) · Сигнал успеха (как поймём, что закрыто)
· Ограничения. Шаблон — `docs/discovery/templates/discovery-brief.md`.
### Хэндофф
discovery-brief — это вход для `brainstorming`. Передай brief как готовую проблемную
секцию: `brainstorming` берёт её и переходит к решению — он **не перезадаёт** уже
выясненные вопросы. discovery-interview отвечает за «что за проблема», brainstorming —
за «что построим». Отдельным файлом FEATURE-brief не сохраняется — он вливается в
спеку brainstorming.
## Режим SYSTEM
### Триггер
Заказчик просит сориентировать его по состоянию проекта: «сориентируй», «где мы
сейчас», «что у нас по X», «что в тулчейне / на карте», «catch-up».
### SKIP
Не запускай SYSTEM, если вопрос про устройство **бизнес-процесса** («как устроен
процесс сделок», «process discovery», «где узкое место в воронке») — это скил
`process-analysis`, он читает код. SYSTEM отвечает на «где мы в проекте», не «как
работает процесс X».
### Процесс
1. **Короткое уточнение scope** — что именно ориентировать? Весь проект, конкретный
раздел, тулчейн, открытые вопросы? Без scope ответ будет рыхлым.
2. **Синтез по мета-слою:** карта `docs/automation-graph.html`, `CLAUDE.md`, MEMORY,
`docs/Открытые_вопросы_*.md`, `docs/Tooling_*.md`, `git log`.
3. **Запрет:** не читай `app/`-код для реконструкции процессов — это исключительный
метод `process-analysis`. SYSTEM работает только с мета-слоем.
4. **Выдай синтез**, а не пересказ документа целиком — ответ на запрос ориентации с
пинами на источники.
### Артефакт — system-snapshot
Если ориентация существенная — сохрани `docs/discovery/YYYY-MM-DD-<тема>.md` по
шаблону `docs/discovery/templates/system-snapshot.md`. Мелкий устный ответ файла не
требует.
## JTBD-дисциплина (общая для обоих режимов)
- **Один вопрос за раз** — интервью, не анкета.
- **Прошлое, не гипотетика** — «когда это случилось в последний раз?».
- **«5 почему»** — корень, не симптом.
- **Не наводи** — открытые вопросы, без подсказанного ответа.
- **Слушай, не защищай** — если заказчик критикует существующее, не оправдывай его,
копай дальше.
## Границы
- **`brainstorming`** — проектирование решения. discovery-interview вскрывает проблему
и передаёт brief; brainstorming проектирует. Не дублируй его вопросы.
- **`process-analysis`** (раздел C10) — анализ as-is бизнес-процесса из кода и
диагностика метрик/конверсии. Если ответ требует чтения `routes/` / `app/Jobs` /
`audit_*` или расчёта метрик процесса — это `process-analysis`, не этот скил.
- **`audit-portal`** — качественный вердикт о здоровье портала. SYSTEM даёт
ориентацию («где мы»), не вердикт («здорово ли»).
- **Интервью конечных пользователей Лидерры** — вне этого скила (defer post-Б-1; для
методологии user research — `design:user-research`).
@@ -0,0 +1,26 @@
{
"skill_name": "discovery-interview",
"note": "Триггер-eval: should_trigger=true → должен вызваться discovery-interview; false → должен сработать другой инструмент (expected_skill). Особое внимание — near-miss к process-analysis (C10).",
"evals": [
{ "id": 1, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "менеджеры жалуются что не видят, какие сделки сегодня надо обзвонить — каждое утро роются в фильтрах вручную" },
{ "id": 2, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "у меня ощущение что лиды из B2 проседают по конверсии, но не пойму почему — хочу разобраться" },
{ "id": 3, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "хочу чтобы поставщики сами видели свой баланс, а то постоянно пишут в поддержку спрашивают" },
{ "id": 4, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "проведи discovery interview по идее напоминаний — я пока сам не уверен что именно нужно" },
{ "id": 5, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "не нравится как сейчас сделана выгрузка отчётов, неудобно, давай покопаем что не так" },
{ "id": 6, "should_trigger": true, "expected_skill": "discovery-interview/FEATURE", "prompt": "клиенты часто отваливаются на этапе оплаты, надо понять что там за проблема" },
{ "id": 7, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "сориентируй меня — где мы сейчас по проекту, что закрыто что нет" },
{ "id": 8, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "что у нас вообще в тулчейне по безопасности, я запутался" },
{ "id": 9, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "вернулся после недели отсутствия, сделай catch-up что произошло по проекту" },
{ "id": 10, "should_trigger": true, "expected_skill": "discovery-interview/SYSTEM", "prompt": "что там на карте в разделе биллинга, какие узлы" },
{ "id": 11, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "как устроен процесс обработки сделки от создания до закрытия — пройди по коду" },
{ "id": 12, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "где узкое место в воронке лидов, какой шаг тормозит" },
{ "id": 13, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "сделай process discovery по джобам импорта лидов" },
{ "id": 14, "should_trigger": false, "expected_skill": "process-analysis", "prompt": "посчитай метрики процесса: cycle time по статусам сделок" },
{ "id": 15, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "интегрируй openapi-mcp-server в .mcp.json" },
{ "id": 16, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "закрой находку аудита G7 по AdminBillingController" },
{ "id": 17, "should_trigger": false, "expected_skill": "systematic-debugging", "prompt": "поправь падающий тест RlsSmokeTest, он валится на teardown" },
{ "id": 18, "should_trigger": false, "expected_skill": "directive (no skill)", "prompt": "добавь endpoint POST /api/deals/{id}/archive" },
{ "id": 19, "should_trigger": false, "expected_skill": "write-spec / brainstorming", "prompt": "напиши спеку для фичи мультивалютного биллинга" },
{ "id": 20, "should_trigger": false, "expected_skill": "audit-portal", "prompt": "проведи полный аудит портала перед релизом" }
]
}
@@ -0,0 +1,45 @@
# Банк вопросов JTBD — режим FEATURE
Вопросы для discovery-интервью. Задавать **по одному**, адаптируя формулировку под
контекст. Все вопросы — про прошлое поведение, без подсказанного ответа.
## 1. Вскрыть проблему
- Расскажи, что произошло в последний раз, когда [ситуация]?
- Что именно тебя в этом раздражало или замедляло?
- Как часто это случается?
## 2. Текущий обходной путь
- Как ты решаешь это сейчас?
- Что делаешь, когда [проблема] происходит?
- Кто ещё это делает и как?
## 3. Цена боли
- Сколько времени это съедает за неделю?
- Что случается, если не сделать это вовремя?
- Были случаи, когда из-за этого что-то сорвалось?
## 4. JTBD — какую работу «нанимают» решение сделать
- Если бы это работало идеально — что бы ты перестал делать руками?
- Какого результата ты на самом деле добиваешься?
## 5. Сигнал успеха
- Как ты поймёшь, что проблема закрыта?
- Что должно стать видимо иначе?
## 6. Ограничения
- Что нельзя ломать или менять?
- Есть ли срок?
## Антипаттерны
- **Наводящий вопрос** («тебе мешает отсутствие X?») — подсказывает ответ; заказчик
согласится из вежливости.
- **Гипотетика** («как бы ты хотел?») — люди плохо предсказывают своё поведение.
- **Список вопросов разом** — это анкета, не интервью; теряется ветвление по ответам.
- **Принять первый ответ за корень** — копай «5 почему» до настоящей причины.
+68
View File
@@ -0,0 +1,68 @@
---
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`.
- **Интервью заказчика про будущую фичу / ориентация по проекту** — скил
`discovery-interview`. Тот вскрывает проблему до решения через интервью человека
(режим FEATURE) и синтезирует мета-слой проекта (режим SYSTEM); этот скил — про
вскрытие as-is процесса из app-кода. «process discovery», «как устроен процесс X»,
«где узкое место» — сюда; «проведи discovery interview», «сориентируй по проекту» —
в `discovery-interview`.
- **Генерик-методология оптимизации процесса** — скил `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».
+23 -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();
});
+35
View File
@@ -1385,3 +1385,38 @@ 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)
линтуются
# discovery-interview integration — spec/plan/skill (2026-05-18)
JTBD
триггерится
фичу
фичей
гипотетика
гипотетику
гипотетики
хэндофф
тулчейне
пинами
evals
+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
+11 -1
View File
@@ -1,8 +1,12 @@
# Plugin Stack Rules — Superpowers + Frontend Design (v3.10)
# Plugin Stack Rules — Superpowers + Frontend Design (v3.12)
**Дата:** 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.12** — discovery-interview: R10.1 Блок 1 +note (v3.12) — **discovery-interview** (Tooling #55, self-authored project-скил `.claude/skills/discovery-interview/`, как process-modeling/process-analysis; режимы FEATURE + SYSTEM). Новая 12-я off-phase подкатегория **discovery-tooling** (§4.30) — не UI → вне R6.0/R6.1/R14. Содержательных изменений R0–R14: 0. Связано: Tooling v2.13, Pravila v1.26, CLAUDE.md v2.13; план `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`.
**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`.
@@ -413,6 +417,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.
@@ -422,6 +427,10 @@ Stack — **головной**. Все плагины вне stack'а — **ин
**Блок 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.
**Блок 1 — note (v3.12):** **discovery-interview** (Tooling #55) — self-authored project-скил в `.claude/skills/discovery-interview/`, **не** вендоренный сторонний и **не** через marketplace; написан проектом (паттерн project-скилов `audit-portal`/`regression`/`process-modeling`/`process-analysis`). **Линтуется** lefthook'ом (cspell+markdownlint), **не** в `cspell.json` `ignorePaths` / `.markdownlintignore` (LINT1). Категория **discovery-tooling** (12-я off-phase подкатегория), вне R6.0/R6.1/R14.
**Отмена:** через удаление из `enabledPlugins` в `~/.claude/settings.json` или через live-override `/имя-плагина` (R0.4.B) на одно действие.
#### Блок 2: Built-in skills Claude Code (всегда доступны через `Skill` tool по `/имя`)
@@ -455,6 +464,7 @@ Stack — **головной**. Все плагины вне stack'а — **ин
| **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-интерфейса.
+12 -2
View File
@@ -1,10 +1,14 @@
# Правила работы Claude в проекте «Лидерра»
**Версия:** v1.24 (17.05.2026)
**Дата:** 17.05.2026
**Версия:** v1.26 (18.05.2026)
**Дата:** 18.05.2026
**Назначение:** настройки проекта (Project instructions) — Claude читает этот файл в каждом чате и следует правилам ниже.
**Статус документа:** ✅ утверждён. Содержимое скопировано в поле "Project instructions" Claude.ai. Файл хранится в архиве как служебный документ.
**Что изменилось в v1.26 относительно v1.25:** §13.2 +абзац «Off-phase discovery-tooling» — формализован скил `discovery-interview` (Tooling #55; self-authored project-скил `.claude/skills/discovery-interview/`, режимы FEATURE+SYSTEM) как двенадцатая off-phase подкатегория; как проектный скил регистрируется в §13.2, не §12.2. Границы — ADR-009 (DI1–DI6, разрез по слою-источнику с process-analysis #53). Связано: Tooling v2.13 / PSR_v1 v3.12 / CLAUDE.md v2.13. План `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`.
**Что изменилось в 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`.
@@ -579,6 +583,8 @@ P0 = блокер старта спринта или регуляторного
| **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. |
| **v1.26** | **18.05.2026** | discovery-interview: §13.2 +абзац «Off-phase discovery-tooling» — формализован скил `discovery-interview` (Tooling #55, §4.30; self-authored project-скил `.claude/skills/discovery-interview/`, режимы FEATURE+SYSTEM — интервью-discovery до проектирования) как двенадцатая off-phase подкатегория, отдельная от всех предыдущих; не UI → вне R6.0/R6.1/R14. Как проектный скил регистрируется в §13.2, **не** в §12.2 (карта Superpowers-скилов); триггер-eval 20/20. Границы — ADR-009 (DI1DI6). Связано: Tooling v2.13 / PSR_v1 v3.12 / CLAUDE.md v2.13. Через manual Edit всех 4 нормативных файлов (claude-md-management неприменим — исполнение в worktree, §5 п.10 worktree-constraint эксцепшн — как v1.24/v1.25). План `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`. Архитектурных изменений в §§1–12 + §§13.1, 13.314: 0. |
---
@@ -725,6 +731,10 @@ Frontend Design и `obra/superpowers` (v5.1.0, 14 skills) — **парный sta
**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`.
**Off-phase discovery-tooling (v1.26, 18.05.2026):** скил `discovery-interview` (Tooling #55, §4.30; self-authored project-скил `.claude/skills/discovery-interview/` — как `audit-portal`/`regression`/`process-modeling`/`process-analysis`) — структурированное интервью-discovery до проектирования: режим FEATURE (JTBD-интервью заказчика — вскрывает проблему, отдаёт discovery-brief в `brainstorming`), режим SYSTEM (интервью-ориентация по мета-слою проекта — карта/CLAUDE.md/MEMORY/Открытые_вопросы/Tooling/git log). **Двенадцатая** off-phase подкатегория. Не UI → вне R6.0/R6.1/R14 PSR_v1. Как **проектный** скил (не Superpowers-скил) регистрируется здесь в §13.2, **не** в §12.2 (карта Superpowers-скилов) — триггерится штатным механизмом using-superpowers по `description` (триггер-eval 20/20). Дубль с `process-analysis` #53 исключён разрезом по слою-источнику; границы — ADR-009 (DI1–DI6). Регулируется PSR_v1 R10.1 Блок 1 note (self-authored project-скил). Установлен 18.05.2026 на ветке `worktree-discovery-interview`; план `docs/superpowers/plans/2026-05-18-discovery-interview-integration.md`.
### 13.3. Скоуп
| Тип задачи | Кто отвечает |
+77 -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
@@ -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.
@@ -0,0 +1,59 @@
# ADR-009: Discovery-interview tooling
- **Status:** Accepted
- **Date:** 2026-05-18
- **Deciders:** Дмитрий
## Context
Запрос вида «менеджеры жалуются на X» или «хочу, чтобы Y» — симптом, не задача.
`brainstorming` уходит в проектирование решения, не удерживая разговор в проблемном
поле; для расплывчатых проблемных запросов нет слоя, который вскрывает проблему до
решения (JTBD / customer discovery). Аналогично у заказчика нет способа получить
синтезированную ориентацию по состоянию проекта — CLAUDE.md и MEMORY грузятся
пассивно, `audit-portal` даёт качественный вердикт, не ориентацию.
Параллельно 17.05.2026 раздел C10 карты ввёл скил `process-analysis`, чей режим 1 —
«process discovery» (реконструкция as-is бизнес-процесса из кода). Это создаёт риск
дубля (§5 п.6 CLAUDE.md) и коллизии триггеров по слову «discovery».
## Decision
Вводится проектный vendored-скил `discovery-interview` (`.claude/skills/`), два
режима:
- **FEATURE** — интервью заказчика перед фичей: JTBD вскрывает проблему, отдаёт
discovery-brief в `brainstorming`.
- **SYSTEM** — интервью-ориентация по состоянию проекта: синтез по мета-слою (карта,
CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log).
Режим «интервью конечных пользователей» — **defer** post-Б-1 (нет живых
пользователей; дублировал бы `design:user-research`).
Дубль с `process-analysis` исключён **разрезом по слою-источнику**: `process-analysis`
работает с app-кодом (`routes/`, `app/Jobs`, `audit_*`); discovery-interview — с
головой заказчика (FEATURE) и мета-слоем управления (SYSTEM). Триггер-коллизия по
слову «discovery» снята лексическим разведением описаний + взаимными SKIP-блоками;
проверено триггер-eval'ом 20/20 (`.claude/skills/discovery-interview/evals/`) —
переименование скила (fallback) не понадобилось.
discovery-interview — *проектный* скил (как `audit-portal`, `regression`), не
Superpowers-скил → регистрируется в Pravila §13.2; §12.2 (карта Superpowers-скилов)
не трогается. Категория — новая 12-я off-phase подкатегория `discovery-tooling`,
вне UI-пула PSR_v1; реестр Tooling — #55.
## Consequences
- Положительно: расплывчатый проблемный запрос получает дисциплину discovery до
проектирования; заказчик получает синтез-ориентацию on-demand; дубля с C10
`process-analysis` нет (разрез по слою), коллизия триггеров снята (eval 20/20).
- Риск: скил self-authored — принадлежит проекту, без upstream-зависимости (это
смягчение, не риск).
- Defer: режим «интервью конечных пользователей» — до появления живых пользователей
(блокер Б-1).
## Enforcement
None — discovery-interview advisory; корректность срабатывания проверяется
триггер-eval'ом (`evals/evals.json`) и code review. Границы с `process-analysis`,
`brainstorming` и `audit-portal` зафиксированы в SKILL.md секции «Границы».
+72 -1
View File
@@ -286,6 +286,12 @@ const NODES = [
{ id: 'claude_api', label: 'claude-api\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 345) },
{ id: 'data_scientist', label: 'Data Scientist\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 355) },
{ id: 'promptfoo', label: 'promptfoo', group: 'plugins', size: 20, ring: 2, ...pos(2, 365) },
// C10 business-process (17.05.2026) — плагин и скилы раздела «Бизнес-процессы (общее)»
{ id: 'ops_plugin', label: 'operations\n(plugin)', group: 'plugins', size: 20, ring: 2, ...pos(2, 385) },
{ id: 'process_modeling', label: 'process-modeling\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 367) },
{ id: 'process_analysis', label: 'process-analysis\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 377) },
// discovery-tooling (18.05.2026) — self-authored скил интервью-discovery
{ id: 'discovery_interview', label: 'discovery-interview\n(skill)', group: 'skills_proj', size: 18, ring: 3, ...pos(3, 387) },
// ── ХУКИ (12) — S+infra + E (economy/skill) ───
{ id: 'hk_session', label: 'SessionStart:\ncontext-inject', group: 'hooks', size: 24, ring: 4, ...pos(4, 100) },
@@ -551,6 +557,17 @@ const EDGES = [
E('tooling', 'claude_api', 'reuse — built-in skill\n(PSR_v1 R10.1 блок 2)'),
E('tooling', 'data_scientist', '§4.24 #49 — реестр'),
// ── C10 BUSINESS-PROCESS 17.05.2026 — связи новых узлов ──
E('psr_v1', 'ops_plugin', 'R10.1 блок 1:\nbusiness-process'),
E('tooling', 'process_modeling', '§4.27 #52 — реестр'),
E('tooling', 'process_analysis', '§4.28 #53 — реестр'),
// ── DISCOVERY-TOOLING 18.05.2026 — связи узла discovery-interview ──
E('tooling', 'discovery_interview', '§4.30 #55 — реестр'),
E('psr_v1', 'discovery_interview', 'R10.1 блок 1 note:\ndiscovery-tooling'),
E('discovery_interview', 'sk_brainstorm', 'хэндофф:\nFEATURE-brief'),
E('discovery_interview', 'process_analysis', 'граница: слой-источник\n(ADR-009 DI2)'),
// ══════════════════════════════════════════════════
// КОНФЛИКТЫ — 3-color classification (iter2 §4)
// 🔴 не закрыт правилом / ⚫ возник на практике / 🟢 закрыт правилом
@@ -886,6 +903,42 @@ const NODE_DETAILS = {
[]
),
// ── 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 пара' }]
),
// ── DISCOVERY-TOOLING (18.05.2026) ────────────
discovery_interview: nd(
'Self-authored скил: структурированное интервью-discovery до проектирования — FEATURE (JTBD-интервью заказчика) + SYSTEM (ориентация по мета-слою проекта).',
'При расплывчатом проблемном запросе — провести JTBD-интервью, отдать discovery-brief в brainstorming; при «сориентируй по проекту» — синтез по карте/CLAUDE.md/MEMORY/Открытые_вопросы/Tooling.',
'Свой project-скил в .claude/skills/discovery-interview/ (не вендоренный → линтуется, LINT1). Не UI → вне фильтров R6.0/R6.1/R14. Триггер-eval 20/20. Tooling §4.30 #55, CLAUDE.md §3.3 #55, ADR-009.',
[{ name: 'PSR_v1', cond: 'R10.1 блок 1 note: discovery-tooling' }, { name: 'Tooling', cond: '§4.30 #55 — реестр' }],
[{ name: 'DI2', cond: 'разрез по слою-источнику с process-analysis (ADR-009)' }],
[{ name: 'process-analysis', cond: 'граница: app-код ↔ голова заказчика/мета-слой' }, { name: 'brainstorming', cond: 'хэндофф FEATURE-brief' }]
),
// ── СКИЛЫ SUPERPOWERS ────────────────────────────
sk_brainstorm: nd(
'Продумывает задачу вместе с заказчиком, формулирует варианты A/B/C и согласует дизайн до написания кода.',
@@ -1988,6 +2041,14 @@ const NODE_META = {
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: 'скил' },
// ── DISCOVERY-TOOLING (18.05.2026) ──
discovery_interview: { since: '18.05.2026', changed: '—', uses: null, usesSrc: 'скил' },
};
// Явные парные дубли (Фича 3) — попадают в кнопку «⧉ Дубли».
@@ -2070,7 +2131,7 @@ const SECTIONS = [
{ id: 'E7', bucket: 'E', label: 'Исследования' },
{ id: 'E8', bucket: 'E', label: 'Самообучение Claude' },
];
// Узел -> раздел. Покрывает все 121 узлов карты.
// Узел -> раздел. Покрывает все 125 узлов карты.
const NODE_SECTION = {
// правила (4)
pravila: 'E1', claude_md: 'E1', psr_v1: 'E1', tooling: 'E1',
@@ -2124,6 +2185,10 @@ const NODE_SECTION = {
ag_apidocs: 'A3', mcp_openapi: 'A3',
// A11 ml-ai-tooling 17.05.2026 — раздел «ML / AI-разработка» наполнен
claude_api: 'A11', promptfoo: 'A11', data_scientist: 'A11',
// C10 business-process 17.05.2026 — раздел «Бизнес-процессы (общее)» наполнен
ops_plugin: 'C10', process_modeling: 'C10', process_analysis: 'C10',
// discovery-interview 18.05.2026 — раздел E5 «Стратегия и принятие решений» (рядом с brainstorming)
discovery_interview: 'E5',
};
// Вторичная классификация: узел первично в NODE_SECTION, дополнительно — в этих
// разделах (кросс-реф). Введено A3-интеграцией 17.05.2026 — раздел A3 наполняется
@@ -2134,6 +2199,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]));
+21
View File
@@ -0,0 +1,21 @@
# docs/discovery — артефакты discovery interview
Home раздела `discovery-tooling` карты. Каталог хранит артефакты скила
`discovery-interview` (`.claude/skills/discovery-interview/`).
## Что здесь лежит
- **SYSTEM-snapshot'ы**`YYYY-MM-DD-<тема>.md`, результаты режима SYSTEM
(синтез-ориентация по состоянию проекта). Шаблон — `templates/system-snapshot.md`.
## Чего здесь НЕ лежит
- **FEATURE-brief** (режим FEATURE) отдельным файлом не сохраняется — он вливается
проблемной секцией в спеку `brainstorming` (`docs/superpowers/specs/`). Шаблон
`templates/discovery-brief.md` задаёт структуру этой секции.
## Связано
- Скил — `../../.claude/skills/discovery-interview/SKILL.md`
- Дизайн — `../superpowers/specs/2026-05-18-discovery-interview-design.md`
- ADR — `../adr/ADR-009-discovery-interview-tooling.md`
@@ -0,0 +1,30 @@
# Discovery-brief — шаблон (режим FEATURE)
Структура проблемной секции, которую `discovery-interview` FEATURE отдаёт в
`brainstorming`. Заполняется по итогам интервью. Отдельным файлом не коммитится —
вливается в спеку brainstorming как готовая проблемная секция.
## Проблема
<Что именно болит — одно-два предложения, формулировкой заказчика.>
## JTBD
<Какую работу заказчик «нанимает» решение сделать. Формат: «Когда <ситуация>, я хочу
<мотив>, чтобы <результат>».>
## Текущий обходной путь
<Как заказчик решает это сейчас — вручную или другим инструментом.>
## Цена боли
<Время / деньги / частота. Сколько стоит НЕ решать проблему.>
## Сигнал успеха
<Как поймём, что проблема закрыта — наблюдаемый признак.>
## Ограничения
<Что нельзя ломать или менять; сроки; технические и процессные рамки.>
@@ -0,0 +1,26 @@
# System-snapshot — шаблон (режим SYSTEM)
Результат режима SYSTEM скила `discovery-interview` — синтез-ориентация по состоянию
проекта. Сохраняется как `docs/discovery/YYYY-MM-DD-<тема>.md`.
## Запрос ориентации
<Что просили сориентировать. Scope: весь проект / конкретный раздел / тулчейн /
открытые вопросы.>
## Состояние
<Синтез: где проект сейчас по запрошенному срезу.>
## Что открыто
<Незакрытые вопросы, блокеры, недоделанное в рамках scope.>
## Источники
<Пины на мета-слой: карта, CLAUDE.md, MEMORY, Открытые_вопросы, Tooling, git log —
конкретные файлы, секции, коммиты.>
## Следующий шаг
<Что логично сделать дальше, если применимо.>
+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