chore: prune Liderra frontend-product specs-plans from claude-brain
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,611 +0,0 @@
|
||||
# Полный аудит портала Лидерра — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (recommended for этого аудита — последовательные фазы с verify-gates). Subagent dispatch — точечно для Phase 1 (4 независимых блока статанализа).
|
||||
|
||||
**Дата:** 2026-05-12
|
||||
**Ветка:** `plan5-frontend-projects` (79 commits ahead of `main`, 0 commits behind upstream)
|
||||
**Режим:** экономия 0% (full reads, full output, verify-before-completion обязателен на каждом шаге)
|
||||
**Триггер:** заказчик уходит спать, попросил «полный аудит, выяви все ошибки, неработающие элементы, неточности; исправь что сможешь; вопросы фиксируй и продолжай»
|
||||
|
||||
## Согласованные решения (2026-05-12 ночь, перед стартом)
|
||||
|
||||
1. **Phase 5 UI smoke = Full × 24 + interactive.** Все 24 view + критические interactive flows (login, dashboard, create deal, kanban drag, billing pricing tiers, admin tenants создать-сменить статус). Бюджет 120+ мин. Phase 8 fix-loop соответственно сокращается, но за ночь укладывается.
|
||||
2. **DemoSeeder.php — read + использовать после проверки безопасности.** Сначала Read содержимое; если seeder создаёт только tenant+admin+тест-данные (без вызовов prod API / external calls) — `php artisan db:seed --class=DemoSeeder` на dev. Если найду опасные паттерны — записать в blocked, skip seeder, fallback на tinker.
|
||||
3. **38 untracked .png — переместить в `audit-screens/legacy/`.** Не удалять (могут быть полезные ссылки на skeleton'ы прошлых fix'ов), но убрать из git status шума.
|
||||
4. **Phase 9 final commit — push на `plan5-frontend-projects`.** После прохода pre-push lefthook (gitleaks-full-history + lychee). НЕ push на main; НЕ create PR (пользователь утром решит сам).
|
||||
|
||||
**Goal:** Снять состояние портала по 7 осям (статанализ × 8 tools, runtime tests × 3, UI smoke через Playwright по 24+ экранам, schema-integrity, SAST/gitleaks, doc cross-ref integrity, реестр open-questions vs фактический state). Зафиксировать все находки. Исправить всё, что не пересекается с правилами-стопами из §«Decision tree» ниже. Зафиксировать blocked-вопросы заказчику в `docs/superpowers/audits/.../-blocked.md`.
|
||||
|
||||
**Архитектура подхода:** девять phase'ов, каждый завершается verify-gate (`Expected:` блок). Phase 1 — параллельный subagent dispatch на 4 независимых статанализа (backend/frontend/docs/sql). Phase 5 — UI smoke через Playwright MCP в режиме golden path × 24+ экрана с скриншотами и console-капчёй. Phase 7 — categorization gate перед массовыми правками. Phase 8 — fix-loop в порядке P0 → P1 → P2 → P3, по одному logical group → один commit, после каждого relevant verify (Pest/Vitest по контексту).
|
||||
|
||||
**Tech stack:** Pest 4 + Vitest 4 + Histoire 1.0-beta.1 + Vite 8 + vue-tsc 3.2 + ESLint 10 + Prettier 3 + Larastan 3.9 + Pint 1.29 + squawk 2.51 + pgFormatter 5.9 + Semgrep + gitleaks + cspell + markdownlint-cli2 + lychee + Playwright MCP + claude-md-management skill (для CLAUDE.md правок) + writing-plans + dispatching-parallel-agents + verification-before-completion skills.
|
||||
|
||||
**Артефакты:**
|
||||
|
||||
- `docs/superpowers/audits/2026-05-12-portal-full-audit-report.md` — итоговый сводный отчёт (executive summary + per-phase)
|
||||
- `docs/superpowers/audits/2026-05-12-portal-full-audit-findings.md` — детальный список находок с `file:line` + severity + статус (fixed/blocked/deferred)
|
||||
- `docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md` — вопросы заказчику (P0 product/design decisions, новые pending для реестра)
|
||||
- `audit-screens/` — UI smoke скриншоты (npm `start` portal + Playwright)
|
||||
- Один commit на одну logical fix-group; финальный `chore(audit): summary report + findings` commit поверх всех
|
||||
|
||||
---
|
||||
|
||||
## Decision tree во время аудита (read each phase under this lens)
|
||||
|
||||
**Hard-stop (не делать без явного `закрываем` от заказчика):**
|
||||
|
||||
1. **НЕ менять `db/schema.sql`** — Pravila §4.2 требует синхронной правки `db/CHANGELOG_schema.md` + согласования.
|
||||
2. **НЕ закрывать `Биз-*`/`CTO-*`/`Ю-*`/`Диз-*`/`DO-*`/`OPEN-*`** — Pravila §2.2.
|
||||
3. **НЕ устанавливать новые пакеты** (`composer require`/`npm install --save`) — Pravila §13.10 + PSR_v1 R0.6 hard-stop.
|
||||
4. **НЕ трогать** `docs/CRM_bp-gr_Инструкция_v8_5.md`, `BRANDBOOK_v2.md`, `DEVELOPER_HANDOFF.md` — ТЗ и handoff = first sources.
|
||||
5. **НЕ править корневой `CLAUDE.md` напрямую** — только через `claude-md-management:claude-md-improver` или `revise-claude-md` (§5 п.10).
|
||||
6. **НЕ trigger force-push, reset --hard, branch -D, --no-verify** — Bash-tool safety.
|
||||
|
||||
**Можно делать (без эскалации):**
|
||||
|
||||
- Fix lint (Pint, ESLint, markdownlint) на staged-видимых сейчас файлах.
|
||||
- Fix Larastan ошибок выше baseline (новые) — фиксим до baseline.
|
||||
- Fix Vitest/Pest упавших тестов, если корень причины очевиден (1 файл, 1 hypothesis после ≥3-гипотезного falsification).
|
||||
- Fix typos в docs (cspell positives после проверки), битые ссылки (lychee, если URL изменился очевидно).
|
||||
- Fix unused imports/vars от knip.
|
||||
- Fix новые ESLint `no-explicit-any` / `no-unused-vars` / type-ошибок vue-tsc.
|
||||
- Удалить ghost-screenshots из workdir (38 `.png` в git status — pre-session dirt) после явного user-OK на удаление.
|
||||
- Запустить migrate:fresh на dev — деструктивно, но dev только; запросить confirmation если найду drift schema vs CLAUDE.md метрик.
|
||||
- Обновить CLAUDE.md/memory если найду расхождения через `claude-md-management`.
|
||||
|
||||
**Если сомнение → пиши в `-blocked.md` и продолжай.** Не блокировать ночной прогон на ожидание ответа.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Pre-flight (≈3 мин)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read-only: `git status`, `git log --oneline -50`, `git fetch && git status -sb`
|
||||
- Create: `docs/superpowers/audits/2026-05-12-portal-full-audit-findings.md` (skeleton)
|
||||
- Create: `docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md` (skeleton)
|
||||
- Create: `audit-screens/` directory
|
||||
|
||||
- [ ] **Step 0.1: Fetch + branch sanity**
|
||||
|
||||
```
|
||||
git fetch origin
|
||||
git log --oneline -1 origin/plan5-frontend-projects
|
||||
git log --oneline -1 HEAD
|
||||
git log --oneline -1 origin/main
|
||||
```
|
||||
|
||||
Expected: `HEAD == origin/plan5-frontend-projects == b5849bb`; `origin/main == 615db99`; нет divergence.
|
||||
|
||||
- [ ] **Step 0.2: Inventory ghost files**
|
||||
38 untracked `.png` файлов + 2 untracked `docs/superpowers/specs|plans/*` от Quiet Luxury session. Не удалять — заказчик их не видел. Записать в `findings.md` под `[Phase 0] workspace dirt: 38 PNG + 2 docs uncommitted`. Скриншоты от прошлых сессий не мешают аудиту — оставить.
|
||||
|
||||
- [ ] **Step 0.3: Создать skeletons файлов**
|
||||
|
||||
`docs/superpowers/audits/2026-05-12-portal-full-audit-findings.md`:
|
||||
|
||||
```markdown
|
||||
# Portal Full Audit — Findings (2026-05-12)
|
||||
|
||||
## Severity легенда
|
||||
- P0 — блокирует production / data corruption / security incident
|
||||
- P1 — нарушение функциональности / failing test / type error / a11y violation
|
||||
- P2 — warning / style / dead code / stale doc
|
||||
- P3 — cosmetic / nice-to-have
|
||||
|
||||
## Phase 0 — Pre-flight
|
||||
…
|
||||
|
||||
## Phase 1 — Static analysis
|
||||
…
|
||||
(остальные phase'ы заполняются по ходу)
|
||||
```
|
||||
|
||||
`docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md`:
|
||||
|
||||
```markdown
|
||||
# Portal Full Audit — Blocked questions (2026-05-12)
|
||||
|
||||
## Что нужно от заказчика для закрытия
|
||||
…
|
||||
```
|
||||
|
||||
- [ ] **Step 0.4: Commit skeletons (если они появятся)**
|
||||
Не коммитим скелеты пустыми — заполним содержимым из Phase 1+. Финальный commit аудита соберёт это всё.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Static checks (parallel, ≈8-12 мин)
|
||||
|
||||
**Подход:** dispatching-parallel-agents — 4 независимых subagent через одно сообщение с 4 `Agent` calls. Каждый возвращает **raw output** (как требует экономия 0%), не summary. Результаты в `findings.md` под `## Phase 1`.
|
||||
|
||||
**Subagent A — Backend static** (general-purpose):
|
||||
|
||||
```
|
||||
cd app
|
||||
composer pint --test # zero-tolerance — должен быть 0 issues
|
||||
composer stan # должен пройти baseline. Новые ошибки = P1.
|
||||
composer audit-offline # composer audit --locked. CVE = P0.
|
||||
```
|
||||
|
||||
Expected: pint 0 issues, stan 0 errors above baseline, audit 0 advisories. Любое отклонение → P0/P1 в findings.
|
||||
|
||||
**Subagent B — Frontend static** (general-purpose):
|
||||
|
||||
```
|
||||
cd app
|
||||
npx eslint "resources/js/**/*.{ts,vue}" "tests/Frontend/**/*.ts" --max-warnings=0
|
||||
npx vue-tsc --noEmit
|
||||
npx prettier --check "resources/js/**/*.{ts,vue,css}" "tests/Frontend/**/*.ts"
|
||||
npx knip --production --reporter=compact # report-only; найдёт unused exports/files/deps
|
||||
```
|
||||
|
||||
Expected: eslint 0/0, vue-tsc 0 errors, prettier all formatted. knip — informational; mark в findings как `[Phase 1 / Subagent B / knip] N unused exports` для review в Phase 7. **Tail-context**: VSCode-extension running, относительные импорты вместо `@/` alias (memory: квирк 52).
|
||||
|
||||
**Subagent C — Docs/Markdown** (general-purpose):
|
||||
|
||||
```
|
||||
cd "c:/моя/проекты/портал crm/Документация"
|
||||
npm run lint:md
|
||||
npm run spell
|
||||
./bin/lychee.exe --config .lychee.toml "docs/**/*.md" "db/**/*.md" "*.md"
|
||||
```
|
||||
|
||||
Expected: markdownlint 0 issues, cspell 0 unknown words, lychee 0 broken links. Tail-context: cspell словарь `cspell-words.txt`, lychee config `.lychee.toml`.
|
||||
|
||||
**Subagent D — SQL/Schema** (general-purpose):
|
||||
|
||||
```
|
||||
cd "c:/моя/проекты/портал crm/Документация"
|
||||
./bin/squawk.exe db/schema.sql
|
||||
perl -I bin/pgFormatter/lib bin/pgFormatter/pg_format db/schema.sql > db/.schema-formatted.tmp.sql
|
||||
diff -q db/schema.sql db/.schema-formatted.tmp.sql
|
||||
rm db/.schema-formatted.tmp.sql 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: squawk 0 issues (есть `.squawk.toml` exclude list), pgFormatter — `diff` пуст ИЛИ только cosmetic whitespace (если diff не пуст — P3 в findings, не fix самим pgFormatter potentially-перепишет structure).
|
||||
|
||||
- [ ] **Step 1.0: Запустить 4 subagent параллельно** (одно сообщение, 4 Agent tool uses; `description` ≤5 слов; в `prompt` — точное название блока + точные команды + явное «верни raw output, не summary; verify exit codes; запиши findings под P0/P1/P2/P3 в формате `file:line — описание`»).
|
||||
|
||||
- [ ] **Step 1.1: Aggregate findings**
|
||||
Собрать 4 raw outputs → дописать в `findings.md` под `## Phase 1`. Categorize: P0 (audit advisories, CVE), P1 (lint errors, type errors, broken links), P2 (warnings, knip unused), P3 (style/cosmetic).
|
||||
|
||||
**Expected total time:** 8-12 мин (parallel). Self-review trigger §8: ≥3 групп патчей в дальнейшем → схема-метрики сверка + 0 orphan-FK; пока только инвентаризация.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Test suites (sequential, ≈10-18 мин)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read-only: запуск тестов
|
||||
|
||||
- [ ] **Step 2.1: Pest без --parallel**
|
||||
|
||||
```
|
||||
cd app
|
||||
php artisan config:clear --ansi
|
||||
php artisan test --without-tty 2>&1 | tail -200
|
||||
```
|
||||
|
||||
Expected: 742+ passed (memory: 742/738/1 flaky/3 skipped) или близко. Любой failure ≠ известных flaky (если найду «flaky-known» — записать) → P1 в findings с `file:test_name — assertion message`.
|
||||
|
||||
Memory-quirk учесть: dev-БД `liderra` теперь ICU; test `liderra_testing` всё ещё `Russian_Russia.1251`. **Если** Pest падает на cyrillic ILIKE — это «known» (квирк 61) для test-DB, не P0; записать в findings как P2 с предложением «применить recreate с ICU на `liderra_testing`».
|
||||
|
||||
- [ ] **Step 2.2: Pest с --parallel (вторая прогон для flaky detection)**
|
||||
|
||||
```
|
||||
cd app
|
||||
php artisan test --parallel --without-tty 2>&1 | tail -200
|
||||
```
|
||||
|
||||
Expected: те же ±1 flaky. Diff между --parallel и no-parallel → P2 для investigation.
|
||||
|
||||
- [ ] **Step 2.3: Vitest**
|
||||
|
||||
```
|
||||
cd app
|
||||
npx vitest run --reporter=verbose 2>&1 | tail -200
|
||||
```
|
||||
|
||||
Expected: 579+/582 (memory: foundation tests + квирки 53-57 могут давать ±). Failed → P1. Записать `file:line — message`.
|
||||
|
||||
- [ ] **Step 2.4: Histoire build (smoke)**
|
||||
|
||||
```
|
||||
cd app
|
||||
npx histoire build 2>&1 | tail -80
|
||||
```
|
||||
|
||||
Expected: 24+ stories / 31+ variants собрались. Build fail → P1.
|
||||
|
||||
- [ ] **Step 2.5: Vite production build (smoke)**
|
||||
|
||||
```
|
||||
cd app
|
||||
npx vite build 2>&1 | tail -120
|
||||
```
|
||||
|
||||
Expected: build success, no warnings о circular deps / large chunks > 500 KB (если есть — P2 + bundle-analyzer запустить отдельно в Phase 7). Записать chunk sizes top-10 в findings для inventory.
|
||||
|
||||
- [ ] **Step 2.6: Aggregate test findings**
|
||||
Дописать в `findings.md` под `## Phase 2`.
|
||||
|
||||
**Expected total time:** 10-18 мин.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Schema integrity (≈5 мин)
|
||||
|
||||
**Цель:** сверить факт схемы с заявленными метриками CLAUDE.md §0 / §2 / §8 (v8.19: **62 base tables + 12 partitions + 117 indexes + 39 RLS-policies + 5 functions + 13 triggers + 5 DB roles**).
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read-only: query через Laravel Boost MCP (предпочтительно — мнеемкее) ИЛИ `psql -d liderra -c "..."`
|
||||
|
||||
- [ ] **Step 3.1: Сверить базовые таблицы**
|
||||
Через Laravel Boost MCP `mcp__laravel-boost__database-query`:
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM information_schema.tables
|
||||
WHERE table_schema='public' AND table_type='BASE TABLE'
|
||||
AND table_name NOT LIKE '%_p20%' AND table_name NOT LIKE '%_default%';
|
||||
```
|
||||
|
||||
Expected: `62`. Diff → P1 (drift), в findings с конкретным `actual_count`.
|
||||
|
||||
- [ ] **Step 3.2: Индексы**
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM pg_indexes WHERE schemaname='public';
|
||||
```
|
||||
|
||||
Expected: `117`.
|
||||
|
||||
- [ ] **Step 3.3: RLS-политики**
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM pg_policies WHERE schemaname='public';
|
||||
```
|
||||
|
||||
Expected: `39`.
|
||||
|
||||
- [ ] **Step 3.4: Функции + триггеры**
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';
|
||||
SELECT count(*) FROM pg_trigger WHERE NOT tgisinternal;
|
||||
```
|
||||
|
||||
Expected: `5` функций, `13` триггеров.
|
||||
|
||||
- [ ] **Step 3.5: DB-роли**
|
||||
|
||||
```sql
|
||||
SELECT rolname FROM pg_roles WHERE rolname LIKE 'crm_%';
|
||||
```
|
||||
|
||||
Expected: 5 ролей включая `crm_supplier_worker` (Plan 3+4).
|
||||
|
||||
- [ ] **Step 3.6: Orphan FK check (self-review trigger §8)**
|
||||
|
||||
```sql
|
||||
SELECT conname, conrelid::regclass FROM pg_constraint
|
||||
WHERE contype='f'
|
||||
AND NOT EXISTS (SELECT 1 FROM pg_class WHERE oid=confrelid);
|
||||
```
|
||||
|
||||
Expected: 0 rows.
|
||||
|
||||
- [ ] **Step 3.7: Aggregate**
|
||||
Дописать в `findings.md` под `## Phase 3` таблицу `metric | expected | actual | diff`. Если все equal → `## Phase 3 — clean`.
|
||||
|
||||
**Expected total time:** 5 мин.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Security scan (≈15-30 мин)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read-only: scan
|
||||
|
||||
- [ ] **Step 4.1: gitleaks полная история**
|
||||
|
||||
```
|
||||
cd "c:/моя/проекты/портал crm/Документация"
|
||||
./bin/gitleaks.exe detect --source . --no-banner --config .gitleaks.toml --redact 2>&1 | tail -100
|
||||
```
|
||||
|
||||
Expected: 0 leaks. **Any leak = P0**. Если найдено — НЕ удалять без consent (может потребоваться history rewrite или allowlist-добавление); записать в findings + blocked.
|
||||
|
||||
- [ ] **Step 4.2: Semgrep SAST**
|
||||
|
||||
```
|
||||
cd "c:/моя/проекты/портал crm/Документация"
|
||||
npx semgrep --config=p/php --config=p/javascript --config=p/typescript --config=p/secrets --config=.semgrep.yml --error --time --json --output=audit-semgrep.json 2>&1 | tail -80
|
||||
```
|
||||
|
||||
Expected: 0 high/critical. Низкие severity → P2 list. Если Semgrep не установлен глобально — запустить через `pip install semgrep` skipped (нельзя ставить пакеты — Pravila §13.10) → mark `[Phase 4 Semgrep] tool unavailable, requires local install` blocked.
|
||||
|
||||
- [ ] **Step 4.3: Roave/SecurityAdvisories check (already in composer.json)**
|
||||
|
||||
```
|
||||
cd app
|
||||
composer audit --locked 2>&1 | tail -40
|
||||
```
|
||||
|
||||
Expected: уже сделано в Phase 1 Subagent A — cross-link, не дублировать.
|
||||
|
||||
- [ ] **Step 4.4: Aggregate**
|
||||
Дописать в `findings.md` под `## Phase 4`.
|
||||
|
||||
**Expected total time:** 15-30 мин (Semgrep — самая медленная часть).
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — UI smoke через Playwright (≈40-60 мин)
|
||||
|
||||
**Цель:** golden path × 24 view, capture screenshots + console + network 4xx/5xx + axe-like a11y через Pa11y, найти неработающие элементы.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Background: `cd app && php artisan serve --port=8000` + `cd app && npm run dev`
|
||||
- Tool: Playwright MCP (`mcp__playwright__browser_*`)
|
||||
- Output: `audit-screens/<view-name>.png`, `audit-screens/<view-name>-console.txt`
|
||||
|
||||
**Roster экранов (24+):**
|
||||
|
||||
| Тип | View | Path | Auth required |
|
||||
|---|---|---|---|
|
||||
| auth | LoginView | /login | no |
|
||||
| auth | RegisterView | /register | no |
|
||||
| auth | TwoFactorView | /2fa | partial |
|
||||
| auth | RecoveryCodesView | /recovery | partial |
|
||||
| auth | UseRecoveryCodeView | /use-recovery | partial |
|
||||
| auth | ForgotPasswordView | /forgot | no |
|
||||
| auth | ResetPasswordView | /reset | no |
|
||||
| main | DashboardView | /dashboard | yes |
|
||||
| main | DealsView | /deals | yes |
|
||||
| main | KanbanView | /kanban | yes |
|
||||
| main | ProjectsView | /projects | yes |
|
||||
| main | ReportsView | /reports | yes |
|
||||
| main | BillingView | /billing | yes |
|
||||
| main | SettingsView | /settings | yes |
|
||||
| main | RemindersView | /reminders | yes |
|
||||
| admin | AdminTenantsView | /admin/tenants | yes (admin) |
|
||||
| admin | AdminTenantDetailView | /admin/tenants/:id | yes |
|
||||
| admin | AdminBillingView | /admin/billing | yes |
|
||||
| admin | AdminIncidentsView | /admin/incidents | yes |
|
||||
| admin | AdminSystemView | /admin/system | yes |
|
||||
| admin | AdminImpersonationView | /admin/impersonation | yes |
|
||||
| admin | AdminPricingTiersView | /admin/pricing-tiers | yes |
|
||||
| admin | AdminSupplierPricesView | /admin/supplier-prices | yes |
|
||||
| errors | ErrorView 404 | /not-found-path | n/a |
|
||||
| errors | ErrorView 403 | (trigger via tenant mismatch) | n/a |
|
||||
| errors | ErrorView 500 | (n/a — нельзя гарантированно вызвать) | n/a |
|
||||
|
||||
- [ ] **Step 5.1: Start dev servers (background, run_in_background=true)**
|
||||
|
||||
```
|
||||
cd app && php artisan serve --port=8000
|
||||
cd app && npm run dev
|
||||
```
|
||||
|
||||
**ВАЖНО**: оба в background; ждать ≈10 сек на boot (Vite HMR ≈3-7 сек, artisan serve ≈2 сек). Получить URL Vite (обычно http://127.0.0.1:5173 + Laravel-config через `laravel-vite-plugin`).
|
||||
|
||||
Запасной вариант если Vite порт занят / artisan конфликтует с lock'ом — `php artisan config:clear`, перезапуск.
|
||||
|
||||
- [ ] **Step 5.2: Подготовка seed-пользователя для логина**
|
||||
Проверить, есть ли уже seeded admin/tenant у `php artisan db:seed --class=DemoSeeder` (есть `DemoSeeder.php` untracked — скорее всего создан в прошлой сессии, проверить содержимое и **НЕ** запускать без awareness; альтернатива — `tinker`-команда создать минимального user).
|
||||
|
||||
Если seed недоступен — записать `[Phase 5] no demo seed available — skipping auth-required views beyond login screen` в blocked, продолжать со screenshot'ами только auth-views.
|
||||
|
||||
- [ ] **Step 5.3: Loop по экранам**
|
||||
Для каждого экрана:
|
||||
|
||||
1. `mcp__playwright__browser_navigate` → URL
|
||||
2. `mcp__playwright__browser_wait_for` networkidle (≈2 сек)
|
||||
3. `mcp__playwright__browser_take_screenshot` → `audit-screens/<view>.png` (full page)
|
||||
4. `mcp__playwright__browser_console_messages` → собрать errors/warnings → `<view>-console.txt`
|
||||
5. `mcp__playwright__browser_network_requests` → найти 4xx/5xx → `<view>-net.txt`
|
||||
6. Опционально: `mcp__playwright__browser_evaluate` инжект axe-core минимальной проверки роли/контраста (если получится без npm-install — Pa11y запускать отдельно)
|
||||
7. Записать в findings: `[Phase 5 <view>] OK` ИЛИ список observed issues (`console error: foo`, `network 404 /api/bar`, `visual: text overflows on .ld-card` — со скриншот-ref'ом)
|
||||
|
||||
- [ ] **Step 5.4: Pa11y по live URL'ам**
|
||||
Pa11y configured в `pa11y.config.json` — проверить content (наверняка указывает на `web/v8/*.html` концепты; для live URL потребует временной правки конфига, что не P0). Запустить как есть для baseline; если нацелено на `web/v8/*.html` — это check концептов, не реального портала. Записать P2 если найду divergence.
|
||||
|
||||
- [ ] **Step 5.5: Aggregate UI findings**
|
||||
Дописать в `findings.md` под `## Phase 5` per-view блоки + сводная таблица «view | status | issue count P0/P1/P2/P3».
|
||||
|
||||
- [ ] **Step 5.6: Stop dev servers**
|
||||
KillBash оба фоновых процесса.
|
||||
|
||||
**Expected total time:** 40-60 мин (sequential по экранам, Playwright MCP slow per-call).
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Cross-doc integrity (≈10 мин)
|
||||
|
||||
**Цель:** сверить декларации в CLAUDE.md / Pravila / Tooling / PSR_v1 / memory с фактическим состоянием.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read-only: cross-check
|
||||
- Write (через `claude-md-management:claude-md-improver`): CLAUDE.md обновления если найду stale
|
||||
|
||||
- [ ] **Step 6.1: Cross-ref versions**
|
||||
Прочитать шапки (frontmatter / topmost версионные строки):
|
||||
- `CLAUDE.md` (v1.87 на 2026-05-12 ночь)
|
||||
- `docs/Pravila_raboty_Claude_v1_1.md` (v1.11)
|
||||
- `docs/Plugin_stack_rules_v1.md` (v2.0)
|
||||
- `docs/Tooling_v8_3.md` (v1.16)
|
||||
- `docs/Открытые_вопросы_v8_3.md` (v1.81)
|
||||
|
||||
Сверить cross-refs. Memory говорит: CLAUDE.md v1.88, Pravila v1.11, PSR_v1 v2.0, Tooling v1.16, реестр v1.81. **Если факт-версия меньше memory** — memory stale, перезаписать через `memory_state.md` update. **Если факт-версия больше memory** — нормально, обновить memory.
|
||||
|
||||
- [ ] **Step 6.2: Schema metrics в CLAUDE.md vs Phase 3**
|
||||
CLAUDE.md §0 row «Схема БД» / §2 / §6 / §8 декларируют v8.19 = 62/12/117/39/5/13/5. Phase 3 actual цифры → diff. Если drift → `claude-md-improver` targeted update (правка только §0/§2/§6/§8 чисел).
|
||||
|
||||
- [ ] **Step 6.3: Open-questions registry sanity**
|
||||
Прочитать `docs/Открытые_вопросы_v8_3.md` § «Сводка» (~line 2186) → таблица счётчиков (open/in-progress/closed). Найти все «P0» / «BLOCKED» / «открыт» строки. Сверить с CLAUDE.md §6 «Биз-25..31 / DO-*/ OPEN-* / etc». Любое расхождение → P2 в findings.
|
||||
|
||||
- [ ] **Step 6.4: Memory consistency**
|
||||
Прочитать `memory/MEMORY.md` индекс. Найти rows ссылающиеся на конкретные file:line или версии. Cross-check с current state. Особое внимание:
|
||||
- `project_state.md` → branch, commit count, test counts
|
||||
- `feedback_environment.md` → 62 квирка
|
||||
- `reference_archive.md` → cross-refs версий
|
||||
- `project_supplier_integration.md` → Plan progression
|
||||
- `project_portal_redesign.md` → 20 commits на ветке
|
||||
- `project_dev_indices.md` → 10 commits + temporary feature flag
|
||||
|
||||
Stale → update memory file. Если расхождение существенное (например, branch уже merged в main — не нашёл такого в Phase 0, но проверить) → blocked для confirm.
|
||||
|
||||
- [ ] **Step 6.5: Aggregate**
|
||||
Дописать в `findings.md` под `## Phase 6`. Если есть docs-fixes — применить через skill, commit отдельно.
|
||||
|
||||
**Expected total time:** 10 мин (сама сверка) + 5-15 мин (применение fixes через claude-md-improver, если нужны).
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — Categorize + plan fixes (≈5 мин, no tool calls)
|
||||
|
||||
**Цель:** просмотреть весь `findings.md`, расставить severity, решить что фиксим vs документируем.
|
||||
|
||||
- [ ] **Step 7.1: Severity pass**
|
||||
Каждая находка → P0/P1/P2/P3 (легенда в шапке `findings.md`). Конфликты — приоритет более высокий.
|
||||
|
||||
- [ ] **Step 7.2: Fix-eligibility classify**
|
||||
Для каждой находки — флаг `[FIX-NOW]`, `[FIX-DEFER]`, `[BLOCKED]`:
|
||||
- `[FIX-NOW]`: попадает под «Можно делать» из decision-tree, я могу решить sam без user-input, фикс ≤30 min — фиксим в Phase 8.
|
||||
- `[FIX-DEFER]`: можно фикснуть, но требует ≥1 час / большой refactor / захватывает несколько модулей — записать как новый pending в `-blocked.md` как «предлагаемая задача для следующей сессии».
|
||||
- `[BLOCKED]`: попадает под hard-stop из decision-tree (schema, регистрационные вопросы, новые packages, ТЗ-правки) → `-blocked.md` с явным «нужно от заказчика: <решение>».
|
||||
|
||||
- [ ] **Step 7.3: Order fix-queue**
|
||||
P0 → P1 → P2 → P3 внутри каждого — по «cheap first» (быстрые wins перед dolgo). Записать в `findings.md` секцию `## Phase 7 — Fix Queue` упорядоченный list с ID.
|
||||
|
||||
**Expected total time:** 5 мин.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Fix loop (≈variable, до конца сессии)
|
||||
|
||||
**Подход:** TDD где применимо (для PHP — Pest test first; для Vue — Vitest test first; для config-only — skip TDD). Один logical fix → один commit. После каждого commit — relevant verify (контекстный test prepository file). Self-review trigger §8 после ≥3 групп.
|
||||
|
||||
**Pattern per fix:**
|
||||
|
||||
- [ ] **Step 8.N.1: Pick top finding from queue**
|
||||
|
||||
- [ ] **Step 8.N.2: Write failing test** (если применимо — code fix, не config)
|
||||
|
||||
- [ ] **Step 8.N.3: Run test → verify FAIL**
|
||||
|
||||
- [ ] **Step 8.N.4: Implement fix**
|
||||
|
||||
- [ ] **Step 8.N.5: Run test → verify PASS**
|
||||
|
||||
- [ ] **Step 8.N.6: Run relevant suite (всё что зависит от файла)**
|
||||
- PHP fix → `php artisan test --filter=<related>` or full Pest
|
||||
- Vue fix → `vitest run <related-file>` or full Vitest
|
||||
- Lint fix → relevant lint command (pint --test, eslint, markdownlint)
|
||||
- Schema fix → **STOP** — это hard-stop из decision-tree
|
||||
|
||||
- [ ] **Step 8.N.7: Commit (atomic)**
|
||||
|
||||
```
|
||||
git add <specific-files>
|
||||
git commit -m "fix(<scope>): <conventional message>
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
Pre-commit hooks обязательно прошли (gitleaks, markdownlint, cspell, stylelint, pint, larastan, squawk, eslint-vue).
|
||||
|
||||
- [ ] **Step 8.N.8: Update findings.md status**
|
||||
`[fix #N] status: fixed in <commit-sha>` рядом с finding.
|
||||
|
||||
**Expected total time:** до конца сессии. После каждых 3 commits — self-review §8 (schema metrics, narrative версии, реестр).
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Final verification + report (≈15 мин)
|
||||
|
||||
- [ ] **Step 9.1: Re-run всех Phase 1 static checks**
|
||||
Идентично Phase 1 — параллельный subagent dispatch на 4 блока. Expected: всё прошло (или те же flaky/known что и в Phase 1, явно отмеченные blocked).
|
||||
|
||||
- [ ] **Step 9.2: Re-run всех Phase 2 test suites**
|
||||
Pest + Vitest + Histoire build + Vite build. Expected: 0 regression — counts ≥ Phase 2 numbers.
|
||||
|
||||
- [ ] **Step 9.3: Pre-commit emulate (lefthook)**
|
||||
|
||||
```
|
||||
cd "c:/моя/проекты/портал crm/Документация"
|
||||
npx lefthook run pre-commit
|
||||
```
|
||||
|
||||
Expected: все 8 jobs прошли на текущем working tree.
|
||||
|
||||
- [ ] **Step 9.4: Write summary report**
|
||||
`docs/superpowers/audits/2026-05-12-portal-full-audit-report.md`:
|
||||
|
||||
```markdown
|
||||
# Portal Full Audit — Summary (2026-05-12)
|
||||
|
||||
## TL;DR
|
||||
- Findings total: N (P0: a, P1: b, P2: c, P3: d)
|
||||
- Fixed: M (across K commits)
|
||||
- Deferred: L (см. blocked.md)
|
||||
- Blocked on user: J (см. blocked.md)
|
||||
|
||||
## По phase'ам
|
||||
… (1 параграф на phase + ссылка на детальный блок в findings.md)
|
||||
|
||||
## Commits (audit fixes only)
|
||||
- sha — scope — message
|
||||
…
|
||||
|
||||
## Blocked questions for user
|
||||
- (короткие 1-strok формулировки + ссылка на blocked.md детали)
|
||||
|
||||
## Метрики до/после
|
||||
- Pest: was 742/738 → now N/M
|
||||
- Vitest: was 579/582 → now N/M
|
||||
- Larastan: was 0 above baseline → now N
|
||||
…
|
||||
```
|
||||
|
||||
- [ ] **Step 9.5: Commit summary**
|
||||
|
||||
```
|
||||
git add docs/superpowers/audits/2026-05-12-portal-full-audit-*.md audit-screens/
|
||||
git commit -m "chore(audit): portal full audit 2026-05-12 — findings + fixes + blocked"
|
||||
```
|
||||
|
||||
**НЕ** push (Pravila — push только по явному запросу заказчика; и не на main в любом случае).
|
||||
|
||||
**Expected total time:** 15 мин.
|
||||
|
||||
---
|
||||
|
||||
## Self-review checklist (после plan'а, перед execution)
|
||||
|
||||
**1. Spec coverage:** Заказчик хотел «полный аудит, выяви все ошибки, неработающие элементы, неточности; исправь что сможешь; вопросы фиксируй». План покрывает: статанализ ✓, runtime tests ✓, UI smoke ✓, schema ✓, security ✓, docs ✓, реестр ✓, fix loop ✓, blocked list ✓, final report ✓.
|
||||
|
||||
**2. Placeholder scan:** Все step'ы содержат конкретные команды + Expected output. Нет «TBD», «handle edge cases», «similar to step N» (Phase 8 — паттерн step 8.N.x намеренный, конкретизируется по факту находок).
|
||||
|
||||
**3. Type consistency:** `findings.md` / `blocked.md` / `report.md` — три файла, имена согласованы во всех phase'ах. Severity P0..P3 — единый код. Phase нумерация 0..9 без gaps.
|
||||
|
||||
**4. Decision-tree coverage:** schema-changes / open-question closures / package-installs / ТЗ-правки / CLAUDE.md direct edits — все имеют явные «hard-stop / эскалировать в blocked» handling в соответствующих phase'ах.
|
||||
|
||||
**5. Время:** Σ ≈ 110-180 мин для Phase 0-9 без Phase 8. Phase 8 — открытый horizon до конца сессии. Заказчик идёт спать → ночной прогон, до 6-8 часов до его пробуждения; это укладывается с запасом.
|
||||
|
||||
---
|
||||
|
||||
## Открытые в плане предположения (если что — fallback)
|
||||
|
||||
1. **Vite порт 5173 свободен.** Если нет — `cd app && npx kill-port 5173` или ENV `VITE_PORT=5174`.
|
||||
2. **PostgreSQL service running на Windows.** Если упадёт `database/query` — записать как infra-blocked, продолжать без Phase 3 + skip auth-required views в Phase 5.
|
||||
3. **`DemoSeeder.php`** untracked есть. Прочитать перед запуском (не run без read'а).
|
||||
4. **Playwright MCP доступен** и работает в текущей сессии (deferred tool, надо ToolSearch select).
|
||||
5. **Subagent budget.** Phase 1 — 4 параллельных. Если падает на budget → перейти в sequential.
|
||||
6. **Semgrep CLI** может не быть установлен — fallback в Step 4.2.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,720 +0,0 @@
|
||||
# Quiet Luxury редизайн элементов #1440 + #896 — 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:** перевести два визуально выпадающих элемента (`span.card-check__box` в `ProjectCard.vue` и `v-text-field` «Название проекта» в `NewProjectDialog.vue`) на язык токенов `tokens.css` (Quiet Luxury I1) — без новых компонентов, без правок токенов, без правок API/store/валидации.
|
||||
|
||||
**Architecture:** CSS-only правка scoped-стилей для #1440 + Vuetify-prop расширение + scoped `:deep()` override для #896. Имена классов и template-структура сохраняются → существующие Vitest behavior-тесты и dev-indices manifest не ломаются.
|
||||
|
||||
**Tech Stack:** Vue 3.5, Vuetify 3.12, Vite, Vitest 4.1, vue-tsc, ESLint flat-config 10, Histoire 1.0-beta.1, lefthook 2.1, Pa11y (optional smoke).
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-12-quiet-luxury-elements-1440-896-design.md](../specs/2026-05-12-quiet-luxury-elements-1440-896-design.md) (commit `7d86971`).
|
||||
|
||||
**Branch:** `plan5-frontend-projects` (текущая active).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Modified (2 files):**
|
||||
|
||||
| Путь | Что меняется | Зачем |
|
||||
|---|---|---|
|
||||
| `app/resources/js/components/projects/ProjectCard.vue` | Полная замена `<style scoped>` (lines 132–172). Template и script нетронуты. | Применить `--liderra-line` / `--radius-6` / tonal teal checked-стейт + добавить `:focus-visible`. |
|
||||
| `app/resources/js/views/projects/NewProjectDialog.vue` | Расширить props у `<v-text-field>` на line 59 (variant/density/color/rounded/class). Добавить новый `<style scoped>` блок в конец файла (после `</script>` на line 208). | Перевести «Название проекта» с filled на outlined под палитру токенов; уникальный `.ld-input-quiet` локализует override. |
|
||||
|
||||
**Not modified:** `tokens.css`, `app.css`, `motion.css`, `typography.css`, `vuetify.ts`, `dev-indices.json`, `ProjectsView.vue` (toolbar checkbox + filter bar — out of scope per spec §5).
|
||||
|
||||
**Not created:** новые primitive-компоненты (`LdTextField` / `LdCheckbox`) — out of scope.
|
||||
|
||||
---
|
||||
|
||||
## Task 0: Pre-flight verification (риски §6 спеки)
|
||||
|
||||
**Files:** read-only.
|
||||
|
||||
- [ ] **Step 0.1: Re-read `app/resources/js/plugins/vuetify.ts`** — подтвердить, что Vuetify theme определяет `primary: '#0F6E56'` (или `var(--liderra-teal)`). Это закрывает риск §6.4 спеки.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cat app/resources/js/plugins/vuetify.ts
|
||||
```
|
||||
|
||||
Expected: в `createVuetify({ theme: { themes: { ... primary: ... } } })` значение primary совпадает с `#0F6E56` (`--liderra-teal`). Если не совпадает — STOP, согласовать со спекой / заказчиком, не имплементить.
|
||||
|
||||
- [ ] **Step 0.2: Проверить `rounded="lg"` mapping в Vuetify 3.12** — закрывает риск §6.3 спеки.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
grep -rn "rounded" node_modules/vuetify/lib/composables/rounded.* 2>/dev/null | head -20
|
||||
```
|
||||
|
||||
или вызов через context7:
|
||||
|
||||
```
|
||||
mcp__plugin_context7_context7__query-docs "vuetify 3 rounded prop lg value pixels"
|
||||
```
|
||||
|
||||
Expected: `lg` соответствует `8px` border-radius. Если значение другое — добавить в Task 2 CSS override `:deep(.v-field) { border-radius: var(--radius-8); }` явно.
|
||||
|
||||
- [ ] **Step 0.3: Найти Vitest spec-файлы для затрагиваемых компонентов**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
find app/resources/js -type f \( -name "ProjectCard*.spec.ts" -o -name "ProjectCard*.test.ts" -o -name "NewProjectDialog*.spec.ts" -o -name "NewProjectDialog*.test.ts" \) 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: нули или несколько путей. Зафиксировать список как `$TESTED_SPECS` для последующих task'ов. Если spec'и не найдены — записать в notes; пропустить шаги «run targeted Vitest» и переключиться на manual smoke (Task 5).
|
||||
|
||||
- [ ] **Step 0.4: Найти Histoire-stories для затрагиваемых компонентов**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
find app/resources/js -type f \( -name "ProjectCard.story.vue" -o -name "NewProjectDialog.story.vue" \) 2>/dev/null
|
||||
```
|
||||
|
||||
Expected: нули или 1–2 пути. Зафиксировать как `$STORIES`. Если stories нет — Histoire-smoke step в Task 5 → skipped (с явной отметкой в commit message).
|
||||
|
||||
- [ ] **Step 0.5: Зафиксировать baseline Vitest**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app && npm run test:vue 2>&1 | tail -60
|
||||
```
|
||||
|
||||
Expected: green (per memory `project_state.md` — 594 passed / 3 skipped / 74 files на текущей ветке). Зафиксировать ровно столько pass/skip/fail — это baseline, не должен регрессировать после правок. Если baseline уже red — STOP, починить baseline отдельным task'ом до начала.
|
||||
|
||||
---
|
||||
|
||||
## Task 0.5b: Pre-baseline fix — починить `ProjectCard.spec.ts:43`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/tests/Frontend/ProjectCard.spec.ts:42`
|
||||
|
||||
**Подход:** Pre-existing failing test (commit `c9ee8d8`, см. spec §6.6). `data-testid="card-select"` на `<label>`, но `@change` handler на `<input>` внутри (ProjectCard.vue:9). В jsdom change-event на label не bubble. Fix: trigger на input через CSS-селектор. Atomic commit перед Task 1, чтобы baseline стал зелёным.
|
||||
|
||||
- [ ] **Step 0.5b.1: Edit `ProjectCard.spec.ts:42`**
|
||||
|
||||
Заменить:
|
||||
|
||||
```ts
|
||||
await wrapper.find('[data-testid="card-select"]').trigger('change');
|
||||
```
|
||||
|
||||
на:
|
||||
|
||||
```ts
|
||||
await wrapper.find('[data-testid="card-select"] input').trigger('change');
|
||||
```
|
||||
|
||||
- [ ] **Step 0.5b.2: Targeted Vitest run на ProjectCard.spec**
|
||||
|
||||
Run (из корня репо):
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run tests/Frontend/ProjectCard.spec.ts --reporter=verbose 2>&1 | tail -30
|
||||
```
|
||||
|
||||
Expected: 4 passed (раньше 3 passed / 1 failed).
|
||||
|
||||
- [ ] **Step 0.5b.3: Stage + atomic commit**
|
||||
|
||||
```bash
|
||||
git add app/tests/Frontend/ProjectCard.spec.ts
|
||||
git commit -m "test(projects): fix ProjectCard change-trigger target
|
||||
|
||||
Pre-existing failing test from commit c9ee8d8 — data-testid lives on
|
||||
<label>, but @change handler sits on <input> inside it. jsdom does not
|
||||
bubble change-event from label to input via @vue/test-utils trigger.
|
||||
Use child-input selector to fire the event on the right node.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
- [ ] **Step 0.5b.4: Re-baseline Vitest**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app && npm run test:vue 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: `614 passed | 3 skipped | 0 failed (617)` / 79 files. Если что-то ещё red — STOP, разобраться (применить systematic-debugging skill).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: ProjectCard #1440 — заменить `<style scoped>`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/projects/ProjectCard.vue:132-172`
|
||||
|
||||
**Подход:** CSS-only. Поведение, emit'ы и атрибуты `data-testid` сохраняются → существующие behavior-тесты пройдут без правок (за исключением snapshot-тестов, если они есть; их обновляем как intended).
|
||||
|
||||
- [ ] **Step 1.1: Открыть файл и заменить блок `<style scoped>`**
|
||||
|
||||
Edit `app/resources/js/components/projects/ProjectCard.vue`, заменить ровно строки 132–172 (от `<style scoped>` до `</style>` включительно) на:
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
.project-card.paused {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.card-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
.card-check input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-check__box {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--liderra-line);
|
||||
border-radius: var(--radius-6);
|
||||
background: var(--liderra-surface);
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 200ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.card-check:hover .card-check__box {
|
||||
border-color: var(--liderra-line-strong);
|
||||
}
|
||||
.card-check input:focus-visible + .card-check__box {
|
||||
outline: 2px solid var(--liderra-teal);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.card-check input:checked + .card-check__box {
|
||||
background: rgba(15, 110, 86, 0.10);
|
||||
border-color: var(--liderra-teal);
|
||||
}
|
||||
.card-check input:checked + .card-check__box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 0;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid var(--liderra-teal);
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
- [ ] **Step 1.2: ESLint на изменённый файл**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app && npx eslint resources/js/components/projects/ProjectCard.vue 2>&1
|
||||
```
|
||||
|
||||
Expected: 0 errors / 0 warnings. Если есть — пофиксить ДО продолжения.
|
||||
|
||||
- [ ] **Step 1.3: vue-tsc на изменённый файл (через full type-check)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app && npm run type-check 2>&1 | tail -30
|
||||
```
|
||||
|
||||
Expected: green. CSS-only правка не должна задеть типы, но проверяем (на случай если scoped-стиль сломал что-то структурное).
|
||||
|
||||
- [ ] **Step 1.4: Targeted Vitest run на ProjectCard.spec, если найден в Task 0.3**
|
||||
|
||||
Run (заменив `$SPEC_PATH` на найденный путь):
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run "$SPEC_PATH" --reporter=verbose 2>&1 | tail -40
|
||||
```
|
||||
|
||||
Expected: green. Если behavior-spec'и пройдут — отлично. Если есть snapshot-тесты и они зафейлились на CSS-diff — это **intended change**, обновить snapshot'ы:
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run "$SPEC_PATH" -u 2>&1 | tail -40
|
||||
```
|
||||
|
||||
Re-run без `-u` чтобы убедиться зелёный после update:
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run "$SPEC_PATH" --reporter=verbose 2>&1 | tail -40
|
||||
```
|
||||
|
||||
Если spec-файл не найден в Task 0.3 — skip с явной отметкой и переход к Step 1.5.
|
||||
|
||||
- [ ] **Step 1.5: Stage файл (без коммита — коммит общий после Task 2)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/projects/ProjectCard.vue
|
||||
git status --short app/resources/js/components/projects/ProjectCard.vue
|
||||
```
|
||||
|
||||
Expected: `M app/resources/js/components/projects/ProjectCard.vue`. Если есть staged snapshot — также staged (`git add` его явно).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: NewProjectDialog #896 + 4 близнеца — `class="ld-input-quiet"` на 5 v-text-field + `<style scoped>`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/projects/NewProjectDialog.vue` lines 21, 30, 48, 59, 61 (template — добавить `class="ld-input-quiet"` к каждому v-text-field)
|
||||
- Modify: `app/resources/js/views/projects/NewProjectDialog.vue:208+` (новый `<style scoped>` блок в конец файла)
|
||||
|
||||
**Подход:** Vuetify global default из [vuetify.ts:50-54](../../app/resources/js/plugins/vuetify.ts#L50-L54) уже делает все v-text-field outlined с color=primary (теал-teal на focus). Достаточно добавить класс `.ld-input-quiet` к 5 нужным полям и один scoped CSS-блок, который переписывает border-color (4-state) и border-radius через `:deep()`.
|
||||
|
||||
- [ ] **Step 2.1: Добавить `class="ld-input-quiet"` ко всем 5 v-text-field**
|
||||
|
||||
Edit `app/resources/js/views/projects/NewProjectDialog.vue`. Пять точечных правок:
|
||||
|
||||
**Line 21 (Домен сайта):**
|
||||
|
||||
```vue
|
||||
<v-text-field
|
||||
v-model="form.signal_identifier"
|
||||
label="Домен конкурента"
|
||||
placeholder="okna-konkurent.ru"
|
||||
:readonly="mode === 'edit'"
|
||||
:error-messages="errors.signal_identifier"
|
||||
/>
|
||||
```
|
||||
|
||||
→ добавить атрибут `class="ld-input-quiet"`:
|
||||
|
||||
```vue
|
||||
<v-text-field
|
||||
v-model="form.signal_identifier"
|
||||
label="Домен конкурента"
|
||||
placeholder="okna-konkurent.ru"
|
||||
:readonly="mode === 'edit'"
|
||||
class="ld-input-quiet"
|
||||
:error-messages="errors.signal_identifier"
|
||||
/>
|
||||
```
|
||||
|
||||
**Line 30 (Номер звонка):** Аналогично — добавить `class="ld-input-quiet"` в существующий v-text-field перед `:error-messages`.
|
||||
|
||||
**Line 48 (Ключевое слово SMS):** Аналогично.
|
||||
|
||||
**Line 59 (Название проекта):**
|
||||
|
||||
```vue
|
||||
<v-text-field v-model="form.name" label="Название проекта" :error-messages="errors.name" />
|
||||
```
|
||||
|
||||
→
|
||||
|
||||
```vue
|
||||
<v-text-field v-model="form.name" label="Название проекта" class="ld-input-quiet" :error-messages="errors.name" />
|
||||
```
|
||||
|
||||
**Line 61 (Лимит лидов в день):** Аналогично — добавить `class="ld-input-quiet"`.
|
||||
|
||||
После всех 5 правок проверить, что:
|
||||
|
||||
```bash
|
||||
grep -c 'class="ld-input-quiet"' app/resources/js/views/projects/NewProjectDialog.vue
|
||||
```
|
||||
|
||||
выводит ровно `5`. И что:
|
||||
|
||||
```bash
|
||||
grep -c '<v-text-field' app/resources/js/views/projects/NewProjectDialog.vue
|
||||
```
|
||||
|
||||
выводит `5` (все 5 v-text-field есть, ни одного дополнительного / пропущенного).
|
||||
|
||||
- [ ] **Step 2.2: Добавить новый `<style scoped>` блок в конец файла**
|
||||
|
||||
Edit `app/resources/js/views/projects/NewProjectDialog.vue`, добавить ПОСЛЕ строки `</script>` (line 208) новый блок:
|
||||
|
||||
```vue
|
||||
|
||||
<style scoped>
|
||||
.ld-input-quiet :deep(.v-field) {
|
||||
border-radius: var(--radius-8);
|
||||
}
|
||||
.ld-input-quiet :deep(.v-field__outline__start),
|
||||
.ld-input-quiet :deep(.v-field__outline__end),
|
||||
.ld-input-quiet :deep(.v-field__outline__notch::before),
|
||||
.ld-input-quiet :deep(.v-field__outline__notch::after) {
|
||||
border-color: var(--liderra-line);
|
||||
opacity: 1;
|
||||
transition: border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.ld-input-quiet :deep(.v-field:hover .v-field__outline__start),
|
||||
.ld-input-quiet :deep(.v-field:hover .v-field__outline__end),
|
||||
.ld-input-quiet :deep(.v-field:hover .v-field__outline__notch::before),
|
||||
.ld-input-quiet :deep(.v-field:hover .v-field__outline__notch::after) {
|
||||
border-color: var(--liderra-line-strong);
|
||||
opacity: 1;
|
||||
}
|
||||
.ld-input-quiet :deep(.v-field--focused .v-field__outline__start),
|
||||
.ld-input-quiet :deep(.v-field--focused .v-field__outline__end),
|
||||
.ld-input-quiet :deep(.v-field--focused .v-field__outline__notch::before),
|
||||
.ld-input-quiet :deep(.v-field--focused .v-field__outline__notch::after) {
|
||||
border-color: var(--liderra-teal);
|
||||
opacity: 1;
|
||||
}
|
||||
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__start),
|
||||
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__end),
|
||||
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__notch::before),
|
||||
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__notch::after) {
|
||||
border-color: currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
(Пустая строка перед `<style scoped>` для отделения от `</script>`.)
|
||||
|
||||
**Almost-trap-avoidance заметки** (from Task 2 code review):
|
||||
|
||||
- `opacity: 1` обязателен в каждом override-блоке — иначе наследуется Vuetify's `--v-field-border-opacity: 0.38` и `--liderra-line` (alpha 0.08) → effective alpha 0.03 (invisible).
|
||||
- Hover должен быть `.v-field:hover` ВНУТРИ `:deep()`, не `.ld-input-quiet:hover` на root — root класс на `.v-input` включает hint/error-message-область.
|
||||
- Error-стейт rule обязателен — без него override блокирует `currentColor`-наследование от Vuetify error-themable color, и красный error-border никогда не появляется на 5 валидируемых полях.
|
||||
|
||||
- [ ] **Step 2.3: ESLint на изменённый файл**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app && npx eslint resources/js/views/projects/NewProjectDialog.vue 2>&1
|
||||
```
|
||||
|
||||
Expected: 0 errors / 0 warnings.
|
||||
|
||||
- [ ] **Step 2.4: vue-tsc full type-check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app && npm run type-check 2>&1 | tail -30
|
||||
```
|
||||
|
||||
Expected: green. `class` атрибут — стандартный Vue prop, типизирован.
|
||||
|
||||
- [ ] **Step 2.5: Targeted Vitest run на NewProjectDialog.spec и EditProjectDialog.spec**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run tests/Frontend/NewProjectDialog.spec.ts tests/Frontend/EditProjectDialog.spec.ts --reporter=verbose 2>&1 | tail -40
|
||||
```
|
||||
|
||||
Expected: green. Изменения template:
|
||||
|
||||
- Behavior-тесты (tab rendering, sms-tab-switching, validation guard, submit button presence) — не зависят от class и border-color, должны пройти.
|
||||
- Snapshot-тесты — отсутствуют в этих spec'ах (Task 0.3 показал — никаких `.snap` файлов нет), regression-риска нет.
|
||||
|
||||
Если что-то red — STOP, применить systematic-debugging:
|
||||
|
||||
1. Hypothesis 1: класс `ld-input-quiet` ломает `text()` assertions (маловероятно — у Vuetify класс не выводится в текст).
|
||||
2. Hypothesis 2: новый `<style scoped>` блок ломает SFC-парсинг (проверить через `cat` файла).
|
||||
3. Hypothesis 3: `:deep(.v-field) { border-radius }` конфликтует с Vuetify-internal CSS-cascade в jsdom (маловероятно — jsdom не render'ит CSS, но проверить).
|
||||
|
||||
- [ ] **Step 2.6: Stage файл**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/projects/NewProjectDialog.vue
|
||||
git status --short app/resources/js/views/projects/NewProjectDialog.vue
|
||||
```
|
||||
|
||||
Expected: `M app/resources/js/views/projects/NewProjectDialog.vue` (плюс staged snapshot'ы если были обновлены).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Full Vitest regression
|
||||
|
||||
**Files:** none modified, run-only.
|
||||
|
||||
- [ ] **Step 3.1: Full Vitest run**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app && npm run test:vue 2>&1 | tail -80
|
||||
```
|
||||
|
||||
Expected: pass count ≥ baseline из Task 0.5 (≥594 passed / 3 skipped / 74 files, минус snapshot-fails если они уже обновлены в Task 1/2). Failures выписать дословно с file:line.
|
||||
|
||||
Если регрессия (тест, который проходил в Task 0.5, теперь fail) — STOP. Применить systematic-debugging skill:
|
||||
|
||||
1. Hypothesis 1: scoped-стиль сломал className в render (DOM-assertion fail).
|
||||
2. Hypothesis 2: Vuetify outlined-variant добавил extra wrapper-div, ломающий `findAll(...)` индексы.
|
||||
3. Hypothesis 3: snapshot не был обновлён (`-u` не сработал) — re-run с `-u`.
|
||||
Falsify по очереди, fix, re-run Step 3.1.
|
||||
|
||||
- [ ] **Step 3.2: Full ESLint**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app && npm run lint:vue 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: green.
|
||||
|
||||
- [ ] **Step 3.3: Full vue-tsc (если ещё не делали полностью в Task 2.4)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app && npm run type-check 2>&1 | tail -20
|
||||
```
|
||||
|
||||
Expected: green.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Histoire smoke (опционально, по результатам Task 0.4)
|
||||
|
||||
**Files:** none modified, run-only.
|
||||
|
||||
- [ ] **Step 4.1: Histoire build (если есть `$STORIES` из Task 0.4)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cd app && npm run story:build 2>&1 | tail -40
|
||||
```
|
||||
|
||||
Expected: build success, без ошибок компиляции `.story.vue`. Если story-файлов нет — пропустить с отметкой в commit message.
|
||||
|
||||
- [ ] **Step 4.2: Histoire dev preview (опциональный визуальный smoke)**
|
||||
|
||||
Run в фоне:
|
||||
|
||||
```bash
|
||||
cd app && npm run story 2>&1
|
||||
```
|
||||
|
||||
Открыть http://localhost:6006 (или порт по конфигу `histoire.config.ts`), вручную пройти по state'ам `ProjectCard.story.vue` (idle / hover / focus / checked / partial) и `NewProjectDialog.story.vue` (rest / hover / focus / error) — визуально подтвердить Quiet Luxury стиль. Записать наблюдения. Это опциональный шаг, не блокер.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Manual browser smoke
|
||||
|
||||
**Files:** none modified, run-only.
|
||||
|
||||
- [ ] **Step 5.1: Запустить dev-сервера в фоне**
|
||||
|
||||
Run в двух разных background bash:
|
||||
|
||||
```bash
|
||||
cd app && php artisan serve
|
||||
```
|
||||
|
||||
```bash
|
||||
cd app && npm run dev
|
||||
```
|
||||
|
||||
Expected: artisan на `http://127.0.0.1:8000`, Vite на `http://localhost:5173`. См. memory `feedback_environment.md` Plan 5 quirks (SESSION_DRIVER=file + seed Tenant).
|
||||
|
||||
- [ ] **Step 5.2: Открыть в браузере и проверить визуально**
|
||||
|
||||
Открыть `http://127.0.0.1:8000/projects` (Playwright MCP или вручную). Аутентифицироваться (demo-tenant — см. memory `project_supplier_integration.md`).
|
||||
|
||||
Проверочный лист:
|
||||
|
||||
- **#1440 idle:** карточки проекта — checkbox-бокс 16×16 с тонкой `--liderra-line` границей, фон белый.
|
||||
- **#1440 hover:** на hover карточки или mouseover самого бокса — граница становится `--liderra-line-strong` (чуть видимее).
|
||||
- **#1440 keyboard focus:** Tab до checkbox → видимый teal-outline 2px вокруг бокса (focus-visible).
|
||||
- **#1440 checked:** клик → tonal teal-заливка (~`#E7F1ED`) + teal-stroke галочки, граница teal.
|
||||
- **5 v-text-field idle (site/call/sms tabs + name + daily limit):** «+ Создать проект» → каждое поле имеет 1px тонкую `--liderra-line` рамку, радиус 8px, label сверху (floating).
|
||||
- **5 v-text-field hover:** hover на каждое поле → рамка `--liderra-line-strong`.
|
||||
- **5 v-text-field focus:** клик в каждое поле → рамка `--liderra-teal`, label и caret teal.
|
||||
- **Validation:** оставить «Название проекта» пустым, нажать «Создать» → должна появиться error-message от 422-валидации; рамка в error-стейте управляется Vuetify (теаловый теплёт error → красный, не override-им).
|
||||
- **Контраст: combobox / autocomplete / btn-toggle остаются с дефолтным border-цветом** — это ожидаемо (out-of-scope).
|
||||
|
||||
Если на одном из чек-боксов визуал не сошёлся со спекой — записать наблюдение в notes и применить systematic-debugging.
|
||||
|
||||
- [ ] **Step 5.3: Остановить dev-сервера**
|
||||
|
||||
Background-процессы из Step 5.1 остановить (Ctrl+C / kill).
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Pre-commit + commit + push
|
||||
|
||||
**Files:** all staged from Task 1.5 + Task 2.6 (+ snapshot-updates если были).
|
||||
|
||||
- [ ] **Step 6.1: Сверить staged diff**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff --cached --stat
|
||||
git diff --cached
|
||||
```
|
||||
|
||||
Expected: только 2 файла + опционально snapshot'ы:
|
||||
|
||||
- `app/resources/js/components/projects/ProjectCard.vue` — изменения в `<style scoped>`.
|
||||
- `app/resources/js/views/projects/NewProjectDialog.vue` — добавлены props на line 59 + новый `<style scoped>` в конце.
|
||||
- Возможные snapshot'ы из `__snapshots__/` папок.
|
||||
|
||||
НЕ должны быть staged: остальные dirty файлы из workspace (`app/dev-indices.json`, `app/resources/js/views/ProjectsView.vue`, png-скриншоты в корне).
|
||||
|
||||
- [ ] **Step 6.2: Запустить полный lefthook pre-commit run вручную**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx lefthook run pre-commit 2>&1 | tail -40
|
||||
```
|
||||
|
||||
Expected: все jobs green:
|
||||
|
||||
- gitleaks ✔
|
||||
- markdownlint (skip — нет .md в staged)
|
||||
- cspell (skip — нет .md в staged)
|
||||
- stylelint (skip — нет .html в staged)
|
||||
- pint (skip — нет .php в staged)
|
||||
- larastan (skip — нет .php в staged)
|
||||
- squawk (skip — нет .sql в staged)
|
||||
- eslint-vue ✔ (ProjectCard.vue + NewProjectDialog.vue).
|
||||
|
||||
Если eslint-vue fail — пофиксить, re-stage, re-run.
|
||||
|
||||
- [ ] **Step 6.3: Атомарный commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(projects-ui): Quiet Luxury redesign for card-check + 5 dialog v-text-fields
|
||||
|
||||
ProjectCard.vue: replace 2px noir solid border on .card-check__box with
|
||||
1px var(--liderra-line) idle / var(--liderra-line-strong) hover / var(--liderra-teal)
|
||||
checked. Checked state uses tonal 10% teal bg instead of full fill. Size 20→16px.
|
||||
Added :focus-visible outline for keyboard nav.
|
||||
|
||||
NewProjectDialog.vue: add a local .ld-input-quiet class to all 5 v-text-field
|
||||
in the dialog (domain / phone / sms keyword / name / daily limit). The class
|
||||
overrides v-field outline border-color through :deep() to use the tokens.css
|
||||
1px line / line-strong / teal palette, and sets border-radius to var(--radius-8).
|
||||
All variant/density/color values come from Vuetify global defaults in
|
||||
plugins/vuetify.ts:50-54.
|
||||
|
||||
Twin elements left out of scope: .toolbar-check__box in ProjectsView.vue,
|
||||
v-combobox/v-autocomplete/v-btn-toggle inside the same dialog, and the
|
||||
filter-bar v-select inputs.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-12-quiet-luxury-elements-1440-896-design.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Lefthook сработает ещё раз на коммите — повторение Step 6.2 для надёжности; должен пройти.
|
||||
|
||||
- [ ] **Step 6.4: Verify commit landed**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git log -1 --stat
|
||||
```
|
||||
|
||||
Expected: один новый commit на `plan5-frontend-projects`, 2 (или 2 + N snapshot) файла изменены.
|
||||
|
||||
- [ ] **Step 6.5: Pre-push hook (lefthook full gitleaks + lychee) — manual dry run**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx lefthook run pre-push 2>&1 | tail -30
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- `gitleaks-full-history` ✔ (no leaks across history).
|
||||
- `lychee-links` ✔ (existing markdown links OK).
|
||||
|
||||
Если что-то fail — STOP, разобраться. **НЕ push'ить с битыми ссылками или leaks.**
|
||||
|
||||
- [ ] **Step 6.6: Push на удалённую ветку**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git push origin plan5-frontend-projects 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: push success. Memory `reference_github.md` подтверждает что `origin/plan5-frontend-projects` существует (последний known HEAD `9a7615b`, после спеки `7d86971`, после implementation +1 commit).
|
||||
|
||||
---
|
||||
|
||||
## Verification before completion
|
||||
|
||||
Перед claim «done» обязательно пройти через **superpowers:verification-before-completion** skill. Чек-лист:
|
||||
|
||||
1. [ ] Baseline fix commit (`test(projects): fix ProjectCard change-trigger target`) landed перед impl-коммитом.
|
||||
2. [ ] `git status` чистый по 3 целевым файлам (`ProjectCard.spec.ts`, `ProjectCard.vue`, `NewProjectDialog.vue`).
|
||||
3. [ ] Все 7 task'ов выше отмечены `[x]` (Task 0 + Task 0.5b + Task 1..6).
|
||||
4. [ ] Vitest 614 passed / 3 skipped / 0 failed (Task 3.1).
|
||||
5. [ ] vue-tsc 0 errors (Task 3.3).
|
||||
6. [ ] ESLint 0 errors (Task 3.2).
|
||||
7. [ ] Manual smoke (Task 5.2) — все 4 стейта #1440 + 4 стейта 5 v-text-field подтверждены глазами.
|
||||
8. [ ] lefthook pre-commit + pre-push зелёные.
|
||||
9. [ ] Импл-коммит с правильным conventional message landed (`feat(projects-ui): ...`).
|
||||
10. [ ] Push на `origin/plan5-frontend-projects` подтверждён `git ls-remote`.
|
||||
|
||||
Только после прохождения всех 10 пунктов — финальный отчёт пользователю.
|
||||
|
||||
---
|
||||
|
||||
## Rollback plan
|
||||
|
||||
Если что-то пойдёт не так после push:
|
||||
|
||||
```bash
|
||||
# Откатить commit локально и на remote
|
||||
git revert <commit-sha>
|
||||
git push origin plan5-frontend-projects
|
||||
```
|
||||
|
||||
или (если push ещё не сделан):
|
||||
|
||||
```bash
|
||||
git reset --hard HEAD~1
|
||||
```
|
||||
|
||||
CSS-only правка изолирована — никаких миграций, никаких schema-изменений, никакого API. Rollback безопасен.
|
||||
|
||||
---
|
||||
|
||||
## Notes / not verified
|
||||
|
||||
- **Vuetify 3.12 internal class names** — `.v-field__outline__start/end/notch::before/::after` подтверждены по официальной документации Vuetify 3.x, но **не верифицировал** их присутствие в `node_modules/vuetify@3.12.0/` локально. Если `:deep()` селекторы не сработают — выполнить:
|
||||
|
||||
```bash
|
||||
grep -rn "v-field__outline" node_modules/vuetify/lib/components/VField/ | head -20
|
||||
```
|
||||
|
||||
и адаптировать селекторы под фактические имена.
|
||||
- **Snapshot-тесты для ProjectCard и NewProjectDialog** — отсутствуют (Task 0.3 проверил: spec'и в `app/tests/Frontend/`, snapshot-папок нет). Risk зачёркнут.
|
||||
- **Histoire story-файлы** — присутствуют: `ProjectCard.story.vue` + `NewProjectDialog.story.vue` (Task 0.4). Task 4 Histoire build применим.
|
||||
- **Re-scope finding (Task 0.1)** — Vuetify global default в [vuetify.ts:50-54](../../app/resources/js/plugins/vuetify.ts#L50-L54) уже устанавливает `variant=outlined density=comfortable color=primary` для всех VTextField. Spec §1.2 «variant=filled по умолчанию» оказался неверен, спека и план переписаны (Commit 1 в sequence). Task 2 теперь применяет `.ld-input-quiet` ко всем 5 v-text-field в `NewProjectDialog.vue`, не только к #896.
|
||||
- **Pre-existing baseline test failure** — Task 0.5b в плане фиксит `ProjectCard.spec.ts:43` отдельным atomic-коммитом перед impl Task 1. После fix baseline становится 614 / 3 / 0.
|
||||
- **Pa11y a11y smoke** — `/projects` route требует auth, Pa11y без cookie-handling не зайдёт. Запускать только если есть auth-cookie-config (`pa11y-ci` JSON); иначе skip (manual focus-visible / contrast уже в Task 5).
|
||||
- **Safari `:focus-visible` fallback** — не добавляем (spec §6.2 risk-decision). Safari < 15.4 не поддерживает `:focus-visible`, но share <1%. Если выявится конкретный пользовательский bug — добавить `.card-check input:focus + .card-check__box` дублирующий правило `:focus-visible` отдельным PR.
|
||||
@@ -1,902 +0,0 @@
|
||||
# Remove R15 motion-runtime restrictions — 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:** Remove R15 motion-runtime restrictions from project rule base across 4 normative documents (PSR_v1 v1.7 → v2.0, CLAUDE.md v1.87 → v1.88, Tooling v1.15 → v1.16, Pravila v1.10 → v1.11), so that `motion-v`, `gsap`, `anime.js`, `lottie-web`, `popmotion`, `@motionone/*` become unconditionally permitted. `framer-motion` remains a **technical block** (React-only, peerDep `react+react-dom` — physically does not run in Vue), no longer a regulatory rule.
|
||||
|
||||
**Architecture:** Atomic edit of 4 `.md` files + CHANGELOG + memory sync, executed via `/claude-md-management:claude-md-improver` for CLAUDE.md (mandated by CLAUDE.md §5 п.10) and direct Edit for the other three normative docs. Done on isolated branch `rules/remove-r15` off `origin/main` (`48f27b4`). One atomic commit gates lefthook (gitleaks + lychee + markdownlint). PR follows. No code (`resources/js/`, `app/`, `db/`) touched — Pest/Vitest/Histoire not run, no migrations.
|
||||
|
||||
**Tech Stack:** Markdown only (`.md`). Tooling: git, lefthook, lychee, gitleaks, markdownlint-cli2, `gh` (GitHub CLI), `/claude-md-management:claude-md-improver` skill, semantic grep (Grep tool).
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-12-remove-r15-motion-restrictions-design.md](../specs/2026-05-12-remove-r15-motion-restrictions-design.md)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
Files modified by this plan (all `.md`):
|
||||
|
||||
| Path | Purpose | Size of change |
|
||||
|---|---|---|
|
||||
| `docs/Plugin_stack_rules_v1.md` | PSR_v1 v1.7 → v2.0: R15 section removal + R0.6 п.11 + R8 motion tie-breakers + R11.6 + R13 motion rows + header + changelog | Major (~90 lines deleted, header reformulated, v2.0 changelog entry added) |
|
||||
| `CLAUDE.md` (repository root) | v1.87 → v1.88: §5 п.12 → marker, §2 Animation default stack reformulated, header + §9 history entry | Medium |
|
||||
| `docs/Tooling_v8_3.md` | Прил. Н v1.15 → v1.16: §9.2 reformulated, header cross-refs sync, changelog entry | Medium |
|
||||
| `docs/Pravila_raboty_Claude_v1_1.md` | v1.10 → v1.11: §13.10 deleted, §11.5 + §13.2 + §13.9 + §13.6 cross-ref version bumps | Medium |
|
||||
| `docs/CHANGELOG_claude_md.md` | New v1.88 entry at top | Small (new entry) |
|
||||
| `MEMORY.md` (user memory dir) | Index hook for plugin paired stack — version bump | Small (1 line) |
|
||||
| `memory/feedback_plugin_paired_stack.md` | Version refs v1.7 → v2.0, R15 → historical | Small (3-5 lines) |
|
||||
| `docs/superpowers/specs/2026-05-12-remove-r15-motion-restrictions-design.md` | Already written, only needs commit | No edit |
|
||||
| `docs/superpowers/plans/2026-05-12-remove-r15-motion-restrictions.md` | This plan file | Created in T1 |
|
||||
|
||||
Files NOT touched: `app/`, `resources/`, `db/`, `tests/`, `package.json`, `composer.json`, `lefthook.yml`, `.mcp.json`, settings.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Setup branch and commit design doc + plan
|
||||
|
||||
**Files:**
|
||||
|
||||
- Already exists: `docs/superpowers/specs/2026-05-12-remove-r15-motion-restrictions-design.md`
|
||||
- Created in T1: `docs/superpowers/plans/2026-05-12-remove-r15-motion-restrictions.md` (this file)
|
||||
|
||||
- [ ] **Step 1: Verify origin/main HEAD matches plan's base assumption**
|
||||
|
||||
Run: `git fetch origin main && git log -1 --format=%H origin/main`
|
||||
Expected: `48f27b4...` (or newer — note the SHA actually returned for the PR body later)
|
||||
|
||||
- [ ] **Step 2: Create branch from `origin/main`**
|
||||
|
||||
Run: `git checkout -b rules/remove-r15 origin/main`
|
||||
Expected: `Switched to a new branch 'rules/remove-r15'`
|
||||
|
||||
- [ ] **Step 3: Stage and commit design doc + plan separately from rule edits**
|
||||
|
||||
Both files are currently untracked (`git status` lists them). Stage them explicitly:
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-05-12-remove-r15-motion-restrictions-design.md \
|
||||
docs/superpowers/plans/2026-05-12-remove-r15-motion-restrictions.md
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(spec+plan): R15 motion-runtime removal — design + impl plan
|
||||
|
||||
Brainstormed via superpowers:brainstorming. User decision 12.05.2026:
|
||||
remove R15 PSR_v1 section entirely (variant B). Conscious rollback of
|
||||
audited construction from v1.83 (10.05.2026).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-12-remove-r15-motion-restrictions-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-12-remove-r15-motion-restrictions.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify lefthook passed on design+plan commit**
|
||||
|
||||
If lefthook blocked (lychee broken link / markdownlint error / gitleaks leak) — fix the file content and re-commit as a NEW commit (do NOT --amend; CLAUDE.md §5 п.11 forbids amend on hook failure). Investigate and fix root cause.
|
||||
|
||||
Expected: clean commit, no hook failures (these are markdown-only files with no secrets and no external links beyond existing docs).
|
||||
|
||||
- [ ] **Step 5: Verify branch state**
|
||||
|
||||
Run: `git status && git log --oneline -3`
|
||||
Expected:
|
||||
|
||||
- Branch clean
|
||||
- HEAD commit message: `docs(spec+plan): R15 motion-runtime removal...`
|
||||
- Parent = `48f27b4` (or whatever origin/main was at fetch time)
|
||||
|
||||
---
|
||||
|
||||
## Task 2: PSR_v1 v1.7 → v2.0 — remove R15 + R0.6 п.11 + R8 motion + R11.6 + R13 motion + finale + properties + header + changelog
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/Plugin_stack_rules_v1.md` (lines 1, 3, 4, 6, 10, 136, 148, 353-355, 523-547, 609-613, 743-820, 824, 830-831, 845, header changelog block)
|
||||
|
||||
This is the largest task. **Read the file in chunks first** (the file is 916 lines, too large for a single Read), then apply edits in sequence.
|
||||
|
||||
- [ ] **Step 1: Re-Read line range 1–55 (header + R0.1)**
|
||||
|
||||
Read: `docs/Plugin_stack_rules_v1.md` lines 1–55.
|
||||
Confirm content matches what plan expects:
|
||||
|
||||
- L1: `# Plugin Stack Rules — Superpowers + Frontend Design (v1.7)`
|
||||
- L4 contains `+ R15 (motion-системы` narrative
|
||||
- L6 contains `Runtime-зависимости проекта (motion-библиотеки и т.п.) подчиняются R15.`
|
||||
- L10 contains `§5 п.12 — на R15 (motion-системы)`
|
||||
|
||||
- [ ] **Step 2: Edit header line 1 → v2.0**
|
||||
|
||||
Use Edit tool. Replace:
|
||||
|
||||
```
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v1.7)
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```
|
||||
# Plugin Stack Rules — Superpowers + Frontend Design (v2.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Edit date line 3 → 12.05.2026**
|
||||
|
||||
Replace:
|
||||
|
||||
```
|
||||
**Дата:** 10.05.2026 (поздний вечер)
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```
|
||||
**Дата:** 12.05.2026
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Edit line 4 (narrative shapка) — strip R15 ссылки**
|
||||
|
||||
Read line 4 fully first (it's long; Grep `^\*\*Назначение` to spot it). Replace the existing line 4 with a rewritten version where:
|
||||
|
||||
- The phrase `+ правила runtime motion-стойки` is **removed** (no motion stack as part of normative base any more)
|
||||
- The phrase `+ 2 новых правила R14 (pipeline внешних UI-генераторов: UPM + 21st Magic MCP) и R15 (motion-системы: framer-motion hard-запрет + motion-v узкое окно по 4 условиям) в v1.4` → keep the R14 part but truncate the R15 part. Replace `R14 (pipeline внешних UI-генераторов: UPM + 21st Magic MCP) и R15 (motion-системы: framer-motion hard-запрет + motion-v узкое окно по 4 условиям) в v1.4` with `R14 (pipeline внешних UI-генераторов: UPM + 21st Magic MCP) в v1.4 (R15 motion-системы введены в v1.4 и удалены в v2.0)`.
|
||||
- Append at end of line a new v2.0 entry: `+ 1 правка четвёртого аудита в v2.0 (12.05.2026 — снят R15 motion-runtime по решению заказчика; conscious rollback v1.4 audited construction; framer-motion переведён из regulatory hard-запрета в technical-guidance уровень: peerDep на react+react-dom, не работает в Vue физически)`
|
||||
|
||||
(Exact wording is verbose; the engineer should preserve existing v1.0–v1.7 narrative and only patch the R15-specific clauses + add v2.0 clause at end of line 4.)
|
||||
|
||||
- [ ] **Step 5: Edit line 6 — strip motion-runtime sentence**
|
||||
|
||||
Replace:
|
||||
|
||||
```
|
||||
Stack никогда не уступает первенство **уровням 4–6** — даже когда задача предметно ближе к чужому плагину, stack первой делает классификацию и потом **делегирует** инструменту, не отдаёт ему gate. Runtime-зависимости проекта (motion-библиотеки и т.п.) подчиняются R15.
|
||||
```
|
||||
|
||||
Wait — Re-Read line 6 first (Grep `Runtime-зависимости проекта`). The phrase is in line 6 (the principle-axiom paragraph). Replace just the last sentence:
|
||||
|
||||
- old: `Runtime-зависимости проекта (motion-библиотеки и т.п.) подчиняются R15.`
|
||||
- new: `` (empty — remove the sentence entirely; runtime motion deps no longer governed by PSR_v1)
|
||||
|
||||
- [ ] **Step 6: Edit line 10 — strip §5 п.12 R15 ref**
|
||||
|
||||
Replace:
|
||||
|
||||
```
|
||||
- [CLAUDE.md](../CLAUDE.md) v1.86+ — оперативная карта; §1 priority chain, §3.3 строка #33, §5 п.5 ссылается на этот документ (расширенный пул UI-инструментов: FD + UPM + 21st), §5 п.11 cross-ref на Pravila §12.3 SoT, §5 п.12 — на R15 (motion-системы)
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```
|
||||
- [CLAUDE.md](../CLAUDE.md) v1.88+ — оперативная карта; §1 priority chain, §3.3 строка #33, §5 п.5 ссылается на этот документ (расширенный пул UI-инструментов: FD + UPM + 21st), §5 п.11 cross-ref на Pravila §12.3 SoT, §5 п.12 — резерв (R15 motion-runtime снят в v2.0)
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Edit line 12 — bump Tooling cross-ref to v1.16+ and strip R15 from §9.2 description**
|
||||
|
||||
Replace:
|
||||
|
||||
```
|
||||
- [docs/Tooling_v8_3.md](Tooling_v8_3.md) **v1.15+** — реестр инструментов; #30 Frontend Design + #31 UPM + #32 21st Magic MCP + #33 claude-md-management + §6 +5 конфликтов v1.4 + §7 7-уровневая иерархия (с v1.14 +Tooling explicit slot 2b) + §9.2 motion-runtime denylist отсылают сюда; §10.3 шаг 2 sync (14 skills) с v1.14
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```
|
||||
- [docs/Tooling_v8_3.md](Tooling_v8_3.md) **v1.16+** — реестр инструментов; #30 Frontend Design + #31 UPM + #32 21st Magic MCP + #33 claude-md-management + §6 +5 конфликтов v1.4 (строка «framer-motion ↔ motion-v» — historical после v2.0) + §7 7-уровневая иерархия (с v1.14 +Tooling explicit slot 2b) + §9.2 motion-runtime guidance (technical-only после v1.16); §10.3 шаг 2 sync (14 skills) с v1.14
|
||||
```
|
||||
|
||||
Also bump Pravila cross-ref one line above (line 11): replace `v1.10+` with `v1.11+` and `§13.10 hard-link на R14 PSR_v1` with `§13.10 удалён в v1.11 (был hard-link на R15, снят с R15)`.
|
||||
|
||||
(Engineer: Re-Read line 11 first to confirm exact wording before patching.)
|
||||
|
||||
- [ ] **Step 8: Edit line 136 — strip R15 from cross-ref note**
|
||||
|
||||
Replace:
|
||||
|
||||
```
|
||||
**Hard-стоп даже в Auto mode** — обязательная остановка независимо от классификации, при любом из триггеров (нумерация введена в v1.6 для надёжности cross-refs «R0.6 пункт N»; при добавлении/удалении пункта — пересмотреть все ссылки в R8/R13/R14/R15):
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```
|
||||
**Hard-стоп даже в Auto mode** — обязательная остановка независимо от классификации, при любом из триггеров (нумерация введена в v1.6 для надёжности cross-refs «R0.6 пункт N»; при добавлении/удалении пункта — пересмотреть все ссылки в R8/R13/R14):
|
||||
```
|
||||
|
||||
- [ ] **Step 9: Delete R0.6 п.11 — line 148**
|
||||
|
||||
Delete the entire line:
|
||||
|
||||
```
|
||||
11. **[v1.4]** Установка **motion-v** или любой другой animation runtime библиотеки (`gsap`, `anime.js`, `react-spring`, `lottie-web`, `popmotion`, `@motionone/*` и т.д.) в `package.json` без прохождения R15.2 (а)+(б)+(в)+(г).
|
||||
```
|
||||
|
||||
After deletion: list ends at item 10. Verify items 1–10 remain intact.
|
||||
|
||||
- [ ] **Step 10: Delete R8 motion tie-breaker rows (lines 353-355)**
|
||||
|
||||
Re-Read line range 340–360 to confirm. Delete the three lines:
|
||||
|
||||
```
|
||||
| **[v1.4]** «Поставим motion-v, у нас в Vuetify не хватает spring-physics» | без письменного кейса из ТЗ + категории (б) + Brandbook approval (в) + R12 flow (г) — R15.2 не удовлетворён, hard-стоп R0.6 пункт 11 |
|
||||
| **[v1.4]** «Поставим framer-motion, у других проектов работает» | R15.1 hard-запрет. React-only физически. Не отменяется live-командой |
|
||||
| **[v1.4]** Для анимации список задача — какой слой? | R11.6: 1 (Vue native `<Transition>`) → 2 (Vuetify) → 3 (CSS) → 4 (View Transitions). На уровне 1 уже закрывается типичный case |
|
||||
```
|
||||
|
||||
(Engineer: there are also lines 353/354 with framer-motion / motion-v from earlier grep — Re-Read full range 340–360 to confirm exact line numbers before deletion.)
|
||||
|
||||
- [ ] **Step 11: Delete R11.6 (lines ~523–547) — Иерархия motion-источников**
|
||||
|
||||
Re-Read line range 515–555 to confirm section boundaries.
|
||||
|
||||
Delete the entire R11.6 sub-section, starting at:
|
||||
|
||||
```
|
||||
### 11.6. Иерархия motion-источников (внутри уровня визуального дизайна, v1.4)
|
||||
```
|
||||
|
||||
through the last bullet/sentence of R11.6 (likely ending at line 547 or where R12 begins, e.g., `## Правило 12` or `---`).
|
||||
|
||||
Be careful NOT to delete:
|
||||
|
||||
- R11.5 (ends just before R11.6)
|
||||
- R12 (starts after R11.6)
|
||||
|
||||
- [ ] **Step 12: Delete R13 motion-сценарии rows (lines ~609–613)**
|
||||
|
||||
Re-Read line range 600–620 to confirm. Delete five rows:
|
||||
|
||||
```
|
||||
| **[v1.4]** Анимация на экране, простая (fade/slide/scale/expand) | высокая | **действую**, использую Vuetify transition или Vue `<Transition>` (R11.6 уровни 3–4) |
|
||||
| **[v1.4]** Анимация средней сложности (custom keyframes, hover-state с timing) | высокая | **действую**, использую CSS `@keyframes` (R11.6 уровень 5) |
|
||||
| **[v1.4]** Cross-route transition (hero-transition при переходе между экранами) | средняя | **фиксирую предположение**: «использую View Transitions API с fallback на Vue `<Transition>` для не-Chromium»; если кейс требует motion-v → R15.2 |
|
||||
| **[v1.4]** Запрос «давай добавим framer-motion / motion-v / gsap / lottie» **без явного кейса** | низкая | **hard-стоп R0.6 пункт 11** + R15.2 формальная проверка триггеров; нельзя действовать в Auto mode |
|
||||
| **[v1.4]** Кейс из ТЗ требует gesture с физикой (drag с инерцией, swipe с pinch) | средняя | **действую**, R15.2 запускает brainstorming + 3 варианта (motion-v vs vanilla pointer events vs CSS scroll-snap); презентую таблицу |
|
||||
```
|
||||
|
||||
- [ ] **Step 13: Delete entire Правило 15 (lines ~743–820)**
|
||||
|
||||
Re-Read line range 740–822 to confirm boundaries.
|
||||
|
||||
Delete everything starting from:
|
||||
|
||||
```
|
||||
## Правило 15 — Motion-системы (animation libraries, v1.4)
|
||||
```
|
||||
|
||||
through (and including) the closing `---` separator just before `## Финальная формула` (`## Финальная формула` should NOT be deleted; the `---` immediately before it is the boundary; engineer judges visually).
|
||||
|
||||
Confirmation: after deletion, line `## Правило 14` (or its sub-sections) should be immediately followed by `---` then `## Финальная формула`.
|
||||
|
||||
- [ ] **Step 14: Edit «Финальная формула» (line ~824) — strip R14 trailing R15 ref**
|
||||
|
||||
Re-Read line range 822–826.
|
||||
|
||||
Replace:
|
||||
|
||||
```
|
||||
> **Любая задача → Правило 0 (gate, stack-головной) → Правило 1 (классификация по типу) → Правило 9 (решение, ≤2 итерации) → Правило 13 (decision matrix по уверенности) → Правило 2 (фаза UI-фичи) → исполнение по Правилам 3, 4, 6 → если нужен внешний UI-генератор: Правило 14 pipeline (UPM на фазах 1/2, 21st на фазе 5) → если нужна анимация: Правило 15 (default стойка R15.3, motion-v только по 4 условиям R15.2) → завершение по Правилу 7 → ревью по Правилу 5. Источники истины — Правило 11 (UI/UX) + R11.6 (motion). Паттерны решений — Правило 12. Координация с не-stack плагинами — Правило 10. Тай-брейкеры — Правило 8.**
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```
|
||||
> **Любая задача → Правило 0 (gate, stack-головной) → Правило 1 (классификация по типу) → Правило 9 (решение, ≤2 итерации) → Правило 13 (decision matrix по уверенности) → Правило 2 (фаза UI-фичи) → исполнение по Правилам 3, 4, 6 → если нужен внешний UI-генератор: Правило 14 pipeline (UPM на фазах 1/2, 21st на фазе 5) → завершение по Правилу 7 → ревью по Правилу 5. Источники истины — Правило 11 (UI/UX). Паттерны решений — Правило 12. Координация с не-stack плагинами — Правило 10. Тай-брейкеры — Правило 8.**
|
||||
```
|
||||
|
||||
(Removed: «если нужна анимация: Правило 15 (default стойка R15.3, motion-v только по 4 условиям R15.2)»; «+ R11.6 (motion)».)
|
||||
|
||||
- [ ] **Step 15: Edit «Свойства свода» (lines ~830–831) — strip R15/motion refs**
|
||||
|
||||
Re-Read line range 828–832.
|
||||
|
||||
Replace:
|
||||
|
||||
```
|
||||
- **Полнота:** каждая задача попадает в одну ветку Правила 1, каждая фаза имеет одного владельца (Правило 2), каждый артефакт ревью — один аспект (Правило 5), каждый тип закрытия — один gate (Правило 7), каждое дизайн-решение — один паттерн (Правило 12), каждая комбинация Auto+§12+R0.6 — одна строка matrix'а (Правило 13), каждый внешний UI-генератор имеет одно место в pipeline (Правило 14), каждая motion-задача — один слой R11.6 (Правило 15).
|
||||
- **Головенство stack'а (v1.2 принцип-аксиома, расширена в v1.4):** stack — головной при решении любой задачи. Внешние плагины (включая UPM и 21st Magic MCP) — инструменты, инвокируемые внутри stack-flow (Правило 10) через pipeline R14. Иерархия источников UI — закон, не гайдлайн (Правило 11). Runtime-зависимости (motion) подчиняются R15.
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```
|
||||
- **Полнота:** каждая задача попадает в одну ветку Правила 1, каждая фаза имеет одного владельца (Правило 2), каждый артефакт ревью — один аспект (Правило 5), каждый тип закрытия — один gate (Правило 7), каждое дизайн-решение — один паттерн (Правило 12), каждая комбинация Auto+§12+R0.6 — одна строка matrix'а (Правило 13), каждый внешний UI-генератор имеет одно место в pipeline (Правило 14).
|
||||
- **Головенство stack'а (v1.2 принцип-аксиома, расширена в v1.4):** stack — головной при решении любой задачи. Внешние плагины (включая UPM и 21st Magic MCP) — инструменты, инвокируемые внутри stack-flow (Правило 10) через pipeline R14. Иерархия источников UI — закон, не гайдлайн (Правило 11). Runtime-зависимости (motion-библиотеки и т.п.) — вне регулирования PSR_v1 с v2.0 (см. CLAUDE.md §2 «Animation default stack» — рекомендация, не hard-rule).
|
||||
```
|
||||
|
||||
- [ ] **Step 16: Strip R15 from v1.4 changelog entry properties (line ~845)**
|
||||
|
||||
Re-Read line range 843–848. The v1.4 entry inside `Непротиворечивость` properties block lists `R15 новое правило (R15.1 framer-motion hard-запрет, R15.2 motion-v 4 условия...)`. **Do NOT delete this changelog entry** — it's historical record of v1.4 work. Just append a marker note at end:
|
||||
|
||||
After existing line 845 (the v1.4 line), add a new bullet line:
|
||||
|
||||
```
|
||||
- **v2.0 (снятие R15):** R15 motion-системы (R15.1–R15.7) удалены полностью; R0.6 п.11 удалён; R8 motion тай-брейкеры удалены; R11.6 motion иерархия удалена; R13 motion-сценарии удалены. Default motion stack перенесён в CLAUDE.md §2 как guidance, не hard-rule. framer-motion — technical block (peerDep react+react-dom), не regulatory rule. Conscious rollback v1.4 audited construction по решению заказчика 12.05.2026; через `/superpowers:brainstorming` → 3 варианта → выбор B (полная отмена R15) → `superpowers:writing-plans` → `/claude-md-management:claude-md-improver`.
|
||||
```
|
||||
|
||||
- [ ] **Step 17: Add v2.0 entry to «История версий» (line ~853)**
|
||||
|
||||
Re-Read line range 850–855.
|
||||
|
||||
Above existing line `- **v1.7 от 10.05.2026 (поздний вечер)**`, insert new entry:
|
||||
|
||||
```
|
||||
- **v2.0 от 12.05.2026** — major bump: removal of R15 motion-runtime restrictions per user decision. Conscious rollback of v1.4 audited construction (10.05.2026, R15 двухуровневая motion + R0.6 п.11 + R8 motion-tie-breakers + R11.6 motion-иерархия + R13 motion-сценарии).
|
||||
|
||||
**Удалено:**
|
||||
- Правило 15 целиком (R15.1 framer-motion hard-запрет / R15.2 motion-v 4 условия / R15.3 default стойка / R15.4 формальная проверка / R15.5 hard-запрет дублирования / R15.6 live-override / R15.7 расширение на gsap/anime/lottie/popmotion).
|
||||
- R0.6 пункт 11 (animation runtime hard-стоп).
|
||||
- R8 три тай-брейкера motion (motion-v, framer-motion, motion-слой R11.6).
|
||||
- R11.6 параллельная иерархия 7 motion-источников.
|
||||
- R13 пять строк motion-сценариев decision matrix.
|
||||
- Финальная формула — фраза «если нужна анимация: Правило 15...» + «+ R11.6 (motion)».
|
||||
- Свойства свода — фраза «каждая motion-задача — один слой R11.6 (Правило 15)» + «Runtime-зависимости (motion) подчиняются R15».
|
||||
|
||||
**Изменено:**
|
||||
- Шапка count: «16 правил R0–R15» → «15 правил R0–R14».
|
||||
- Cross-refs шапки: CLAUDE.md v1.86+ → v1.88+; Pravila v1.10+ → v1.11+; Tooling v1.15+ → v1.16+.
|
||||
- L4 narrative: R15 description свёрнуто до «(R15 motion-системы введены в v1.4 и удалены в v2.0)»; добавлена v2.0 клауза.
|
||||
- L6 принцип-аксиома: удалена последняя фраза «Runtime-зависимости проекта... подчиняются R15».
|
||||
- L10 cross-ref CLAUDE.md §5 п.12: «на R15» → «резерв (R15 motion-runtime снят в v2.0)».
|
||||
|
||||
**НЕ затронуто:** R0–R14 целиком (стек-фильтр R6.0/R6.1, paired-stack R10, иерархия источников R11.1–R11.5, decision matrix R12/R13, UI-pipeline R14 — все в силе). Pravila §12 hard rule Superpowers инвокации — без изменений. CLAUDE.md §5 п.10 правки через claude-md-management — без изменений.
|
||||
|
||||
**Rationale:** заказчик 12.05.2026 запросил снять hard-запреты на motion-runtime библиотеки (включая framer-motion). Через sanity-check brainstorming согласовано: framer-motion как React-only остаётся **technical block** (peerDep `react+react-dom`, runtime crash в Vue), но не regulatory rule. Остальные motion-runtime (motion-v, gsap, anime.js, lottie-web, popmotion, @motionone/dom) — безусловно разрешены. Default motion stack (Vue native + Vuetify + CSS + View Transitions) — переводится в CLAUDE.md §2 как guidance, не hard-rule.
|
||||
|
||||
Связанные обновления: CLAUDE.md v1.87 → v1.88, Pravila v1.10 → v1.11, Tooling v1.15 → v1.16, CHANGELOG_claude_md.md + MEMORY sync. Через `/claude-md-management:claude-md-improver` (для CLAUDE.md) + ручные Edit (для PSR_v1, Tooling, Pravila).
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 18: Final pass — verify file integrity**
|
||||
|
||||
Run: `wc -l docs/Plugin_stack_rules_v1.md`
|
||||
Expected: ~830 lines (down from 916; ~85 lines deleted: R15 section ~80 lines, R0.6 п.11 1 line, R8 3 lines, R13 5 lines minus added v2.0 changelog ~30 lines net).
|
||||
|
||||
Run: `Grep -n "R15" docs/Plugin_stack_rules_v1.md`
|
||||
Expected: only historical references in:
|
||||
|
||||
- Line 4 (narrative shapка mentioning «R15 motion-системы введены в v1.4 и удалены в v2.0»)
|
||||
- v1.4 changelog entry (`R15 (новое правило)...`)
|
||||
- v2.0 changelog entry (new, this PR)
|
||||
- v1.7 changelog entry note about cross-refs «R8/R13/R14»
|
||||
- R0.6 cross-ref note (line ~136 — now reads «R8/R13/R14» — should be 0 «R15» hits here)
|
||||
|
||||
If unexpected R15 hits remain — find and re-evaluate (either historical context that should stay, or live reference that needs deletion).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Tooling Прил. Н v1.15 → v1.16 — §9.2 reformulate + header sync + changelog
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/Tooling_v8_3.md` (header lines 1–13, §9.2 lines 429–442, changelog §13 / history section)
|
||||
|
||||
- [ ] **Step 1: Re-Read line range 1–35 (header + cross-refs + recent changelog)**
|
||||
|
||||
Read: `docs/Tooling_v8_3.md` lines 1–35.
|
||||
|
||||
- [ ] **Step 2: Edit header — version bump and cross-refs**
|
||||
|
||||
Replace `**Версия:** 1.15` line content with:
|
||||
|
||||
```
|
||||
**Версия:** 1.16 (закрытие 1 правки четвёртого аудита — снятие R15 motion-runtime denylist'а из §9.2 синхронно с PSR_v1 v1.7 → v2.0 + CLAUDE.md v1.87 → v1.88 + Pravila v1.10 → v1.11. Заказчик 12.05.2026: «сними все запреты на использование framer motion». Через `/claude-md-management:claude-md-improver`.)
|
||||
```
|
||||
|
||||
Replace `**Предыдущая версия:** 1.14...` → `**Предыдущая версия:** 1.15`.
|
||||
|
||||
In cross-ref block (lines 10–13):
|
||||
|
||||
- `Pravila_raboty_Claude_v1_1.md v1.10+` → `v1.11+`
|
||||
- `Plugin_stack_rules_v1.md v1.7+` → `v2.0+`. Drop the `R15 motion-системы` clause from description. Reword to: `координация paired-stack ядра + расширенного пула + claude-md-management (R10.1 разбит на 3 блока, R0.4.A SoT cross-ref, R10.4/R14.7 tier-метки, R8 +тай-брейкер FD↔21st, R0.1 scope-метка; R15 motion удалён в v2.0)`.
|
||||
- `CLAUDE.md v1.86+` → `v1.88+`. Drop the `§5 п.12 motion-runtime denylist` clause. Reword to end at: `§6 счётчик 33 (3 off-phase tools); §5 п.12 — резерв (motion-runtime denylist снят в v1.88)`.
|
||||
|
||||
- [ ] **Step 3: §9.2 — Re-Read line range 425–455**
|
||||
|
||||
Read: `docs/Tooling_v8_3.md` lines 425–455 to confirm exact §9.2 boundaries.
|
||||
|
||||
- [ ] **Step 4: §9.2 — reformulate denylist as technical guidance**
|
||||
|
||||
Replace existing §9.2 section (lines 429–451) with:
|
||||
|
||||
```markdown
|
||||
### 9.2. Motion runtime библиотеки (с v1.16 — technical guidance, не regulatory)
|
||||
|
||||
С v1.16 / PSR_v1 v2.0 motion-runtime библиотеки **разрешены к установке без обоснования** (R15 PSR_v1 снят 12.05.2026). Default motion stack — **Vue native `<Transition>` + Vuetify transitions + CSS @keyframes + View Transitions API** — остаётся рекомендацией (CLAUDE.md §2), но не hard-rule. Эта таблица — **technical guidance** о свойствах библиотек, не regulatory denylist.
|
||||
|
||||
| Библиотека | Статус | Технические свойства |
|
||||
|---|---|---|
|
||||
| **framer-motion** (npm `framer-motion`) | **❌ Technical block (не regulatory)** | React-only архитектурно: peerDeps `react>=18` + `react-dom>=18`. Использует React fiber-tree + hooks + JSX. В Vue 3 + Vuetify стеке физически не работает — runtime crash при `import { motion } from 'framer-motion'` в `.vue` файлах. `npm install` пройдёт с peer-dep warn, но любой import упадёт. Не regulatory rule — свойство библиотеки. |
|
||||
| **react-spring** (npm `@react-spring/*`) | **❌ Technical block** | React-only по той же причине (peerDep react). |
|
||||
| **motion-v** (npm `motion-v`) | ✅ Разрешено | Vue 3 порт API framer-motion от того же maintainer'а Motion. ~30 KB gzipped. ~95% API-совместимость с framer-motion (motion.div, animate, variants, layout, drag, gestures). |
|
||||
| **gsap** (`gsap` npm) | ✅ Разрешено | Imperative timeline-driven. Подходит для marketing/agency UI, сложных choreographed sequences. |
|
||||
| **anime.js** | ✅ Разрешено | Лёгкая alternative gsap, ~17 KB. |
|
||||
| **lottie-web** (`lottie-web`) | ✅ Разрешено | Для Lottie/After Effects-ассетов. |
|
||||
| **popmotion**, **@motionone/dom** | ✅ Разрешено | Низкоуровневые движки. |
|
||||
| **Auto-Animate** (`@formkit/auto-animate`) | ✅ Разрешено | Один компонент, FLIP под капотом, ~3 KB. |
|
||||
|
||||
**Default motion stack (рекомендация, не обязанность):**
|
||||
|
||||
| Слой | Что покрывает | Размер |
|
||||
|---|---|---|
|
||||
| Vue 3 native `<Transition>` / `<TransitionGroup>` | enter/leave hooks, FLIP через TransitionGroup, JS-callbacks | 0 KB (нативно) |
|
||||
| Vuetify 3 transitions (`v-fade`, `v-slide-y`, `v-slide-x`, `v-scale`, `v-expand`, `v-dialog-transition`) | Material-design анимации; уже в `vuetify@3.12` | 0 KB сверху |
|
||||
| CSS `@keyframes` + `transition` + `prefers-reduced-motion` | Произвольные анимации, GPU-acceleration через `transform`/`opacity`, поддержка a11y | 0 KB |
|
||||
| View Transitions API (Chrome 111+, Safari 18+) | Cross-document / cross-route переходы, FLIP под капотом нативно | 0 KB (браузер) |
|
||||
|
||||
Большинство анимационных задач закрываются дефолтным стеком — это рекомендация для bundle size, а не запрет на motion-runtime библиотеки.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add v1.16 entry to history block at top of file**
|
||||
|
||||
Add new block (after existing **«Что нового в v1.13 (10.05.2026)»** block — i.e., before line containing «Что было в v1.12»):
|
||||
|
||||
```markdown
|
||||
**Что нового в v1.16 (12.05.2026):**
|
||||
|
||||
- **§9.2 «Motion runtime библиотеки» переформулирован из regulatory denylist в technical guidance.** R15 PSR_v1 снят целиком (v1.7 → v2.0); motion-v / gsap / anime.js / lottie-web / popmotion / @motionone/dom — безусловно разрешены к установке. framer-motion / react-spring остаются как **technical block** — это peerDep на React, не правило проекта. Default motion stack (Vue native + Vuetify + CSS + View Transitions) — guidance, не hard-rule.
|
||||
- **Cross-refs шапки sync:** PSR_v1 v1.7+ → v2.0+; CLAUDE.md v1.86+ → v1.88+; Pravila v1.10+ → v1.11+.
|
||||
- **§6 строка «framer-motion ↔ motion-v»** оставлена как historical record v1.4 audited construction (snapshot эпохи, не удаляется при rollback'е).
|
||||
- **Связано:** PSR_v1 v1.7 → v2.0 (R15 удалено), CLAUDE.md v1.87 → v1.88 (§5 п.12 → резерв, §2 motion stack → guidance), Pravila v1.10 → v1.11 (§13.10 удалено).
|
||||
|
||||
Через `/claude-md-management:claude-md-improver` + manual Edit.
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Verify file integrity**
|
||||
|
||||
Run: `Grep -n "R15" docs/Tooling_v8_3.md`
|
||||
Expected: only historical references (in v1.12 / v1.13 changelog entries, where R15 is recorded as historical introduction).
|
||||
|
||||
Run: `Grep -n "❌ HARD-ЗАПРЕТ" docs/Tooling_v8_3.md`
|
||||
Expected: 0 matches (the `HARD-ЗАПРЕТ навсегда` labels removed for motion libs).
|
||||
|
||||
Run: `Grep -n "❌ Technical block" docs/Tooling_v8_3.md`
|
||||
Expected: 2 matches (framer-motion + react-spring rows).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Pravila v1.10 → v1.11 — delete §13.10 + bump §11.5 / §13.2 / §13.9 / §13.6 cross-refs + header
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/Pravila_raboty_Claude_v1_1.md` (lines ~11, 14, 97, 527, 610, 625, 651–657, 685–706)
|
||||
|
||||
- [ ] **Step 1: Re-Read line range 1–25 (header changelog block)**
|
||||
|
||||
Read: `docs/Pravila_raboty_Claude_v1_1.md` lines 1–30.
|
||||
Confirm: line 11 likely contains `**§11.5 — версия PSR_v1...»v1.6, 16 правил R0–R15»...»`. Re-Read for exact wording.
|
||||
|
||||
- [ ] **Step 2: Add v1.11 changelog entry at top of header changelog**
|
||||
|
||||
Insert above existing v1.10 entry (likely lines 5–15) a new block:
|
||||
|
||||
```markdown
|
||||
**Что нового в v1.11 (12.05.2026):**
|
||||
|
||||
- **§13.10 удалено целиком** — transitive hard-link на R14 PSR_v1 был связан с R15 motion-системами через цепочку R14 → R10.4 → §13.9 → §13.10 → §13. С удалением R15 в PSR_v1 v2.0 (12.05.2026) §13.10 структурно остаётся но **не имеет более цели**: его hard-link был на R14 PSR_v1 в части использования UPM/21st вне pipeline'а, не на R15 motion. **Уточнение:** §13.10 (введён в v1.8) **сохраняется** как hard-link на R14 (UPM/21st pipeline), но cross-refs на «R15» внутри §13.10 удалены. Альтернатива — оставить §13.10 целиком, что устранит риск breaking change существующих ссылок других документов.
|
||||
- **§11.5 счётчик правил PSR_v1:** «v1.6, 16 правил R0–R15» → «v2.0, 15 правил R0–R14».
|
||||
- **§13.2 счётчик:** «v1.6 (16 правил R0–R15)» → «v2.0 (15 правил R0–R14)».
|
||||
- **§13.9 cross-ref на PSR_v1:** «(v1.6)» → «(v2.0)».
|
||||
- **§13.10 cross-ref на PSR_v1:** «(v1.6)» → «(v2.0)»; внутреннее упоминание R15 удалено (если есть).
|
||||
- **§13.6 tier-таблица:** строка «Transitive hard-rule» — без изменений (R10/R14 hard-links остаются актуальны; снятие R15 их не задевает).
|
||||
- **Связано:** PSR_v1 v1.7 → v2.0 (R15 удалено целиком), CLAUDE.md v1.87 → v1.88 (§5 п.12 → резерв), Tooling v1.15 → v1.16 (§9.2 reformulated).
|
||||
|
||||
Через `/claude-md-management:claude-md-improver` + manual Edit.
|
||||
|
||||
```
|
||||
|
||||
**Note on §13.10:** based on Re-Read of lines 687–706 (already done in plan exploration), §13.10 is hard-link on R14 PSR_v1 — about UPM / 21st Magic MCP pipeline violations. **NOT about R15.** Spec section 2.4 said «§13.10 — transitive hard-link на R15». This is a misreading: spec's design assumed §13.10 was R15-linked, but actually it's R14-linked.
|
||||
|
||||
**Decision (engineer):** Do NOT delete §13.10. It governs UPM/21st pipeline R14, which is unaffected by R15 removal. Only bump cross-ref versions «(v1.6)» → «(v2.0)» inside §13.10 body.
|
||||
|
||||
Update spec text in v1.11 changelog entry to reflect this correction: «§13.10 НЕ удалено (был hard-link на R14, не на R15 — spec section 2.4 содержал misreading). Cross-refs внутри §13.10 «PSR_v1 v1.6» → «v2.0»».
|
||||
|
||||
- [ ] **Step 3: §11.5 — bump count**
|
||||
|
||||
Re-Read line range 525–530 (line 527 area).
|
||||
|
||||
Find phrase `(v1.6, **16 правил R0–R15**)`. Replace with `(v2.0, **15 правил R0–R14**)`.
|
||||
|
||||
- [ ] **Step 4: §13.2 — bump count and PSR_v1 version**
|
||||
|
||||
Re-Read line range 608–612 (line 610 area).
|
||||
|
||||
Replace:
|
||||
|
||||
```
|
||||
Координация — через [docs/Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) **v1.6 (16 правил R0–R15)**.
|
||||
```
|
||||
|
||||
With:
|
||||
|
||||
```
|
||||
Координация — через [docs/Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) **v2.0 (15 правил R0–R14)**.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: §13.9 + §13.10 — bump cross-ref version**
|
||||
|
||||
Re-Read line range 670–710.
|
||||
|
||||
In §13.9 (line ~675): replace `(v1.6)` with `(v2.0)`.
|
||||
|
||||
In §13.10 (line ~689): replace `(v1.6)` with `(v2.0)`.
|
||||
|
||||
If any internal R15 reference exists in §13.9 / §13.10 body — remove. (Per Re-Read of 670–706 already done: no R15 refs inside these sections; the only «R15» mentions in Pravila are inside header changelog entries — which are historical and stay.)
|
||||
|
||||
- [ ] **Step 6: Verify file integrity**
|
||||
|
||||
Run: `Grep -n "R15" docs/Pravila_raboty_Claude_v1_1.md`
|
||||
Expected: only historical references (in header changelog entries v1.6, v1.7, v1.8 mentioning historical R15 introduction).
|
||||
|
||||
Run: `Grep -n "16 правил" docs/Pravila_raboty_Claude_v1_1.md`
|
||||
Expected: 0 matches in §§ bodies (only possibly in changelog narrative entries — historical OK).
|
||||
|
||||
Run: `Grep -n "15 правил R0" docs/Pravila_raboty_Claude_v1_1.md`
|
||||
Expected: ≥2 matches (§11.5 and §13.2 bodies updated).
|
||||
|
||||
---
|
||||
|
||||
## Task 5: CLAUDE.md v1.87 → v1.88 via `/claude-md-management:claude-md-improver`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify (through skill): `CLAUDE.md` (header, §2 «Animation default stack» строка, §5 п.12, §9 «История версий»)
|
||||
|
||||
CLAUDE.md §5 п.10 is a hard-rule: direct Edit/Write on CLAUDE.md is forbidden. Must use `/claude-md-management:claude-md-improver` skill.
|
||||
|
||||
- [ ] **Step 1: Invoke claude-md-improver skill with task description**
|
||||
|
||||
Use the `Skill` tool with:
|
||||
|
||||
- skill: `claude-md-management:claude-md-improver`
|
||||
- args: paste the task description below
|
||||
|
||||
Task description for skill:
|
||||
|
||||
```
|
||||
Target file: CLAUDE.md (repository root)
|
||||
Spec: docs/superpowers/specs/2026-05-12-remove-r15-motion-restrictions-design.md
|
||||
|
||||
Apply these 4 targeted updates atomically:
|
||||
|
||||
1. Header (top of file, version line) — bump CLAUDE.md v1.87 → v1.88 with changelog entry summarizing R15 motion-runtime removal (12.05.2026). Connection: PSR_v1 v1.7 → v2.0, Tooling v1.15 → v1.16, Pravila v1.10 → v1.11. Conscious rollback of v1.83 audited construction. Reason: user decision 12.05.2026; framer-motion remains technical block (React-only peerDep), other motion-runtime libs (motion-v/gsap/anime/lottie/popmotion/@motionone/dom) unconditionally permitted.
|
||||
|
||||
2. §2 "Стек проекта" — find row containing "Animation default stack" (currently states: "motion-v / framer-motion / gsap / anime.js / lottie-web — **не установлены** и условно разрешены только по R15.2 (4 триггера)..."). Replace the motion-runtime regulatory phrasing with: "Default motion stack (рекомендация, не hard-rule): Vue native `<Transition>` / `<TransitionGroup>` + Vuetify transitions (`v-fade`, `v-slide-y`, `v-scale`, `v-expand`, `v-dialog-transition`) + CSS `@keyframes` + `prefers-reduced-motion` + View Transitions API (Chrome 111+ / Safari 18+). Motion-runtime библиотеки (motion-v, gsap, anime.js, lottie-web, popmotion, @motionone/dom) разрешены к установке без обоснования. `framer-motion` — technical block (React-only peerDep, не работает в Vue физически)."
|
||||
|
||||
3. §5 п.12 — currently full paragraph about motion runtime hard-запрет referencing R15.1 / R15.2 / R0.6 п.11 / framer-motion / motion-v + denylist. REPLACE entire paragraph with single-line marker preserving numbering:
|
||||
"12. **Резерв.** Был «не устанавливать motion runtime библиотеки без прохождения R15.2». Снят 12.05.2026 (CLAUDE.md v1.88 + PSR_v1 v2.0). Подробности — docs/CHANGELOG_claude_md.md."
|
||||
|
||||
4. §9 "История версий" — add v1.88 entry at top of list (or appropriate location preserving descending date order):
|
||||
"v1.88 от 12.05.2026 — снятие R15 motion-runtime restrictions per user decision. Conscious rollback v1.83 audited construction (10.05.2026). §5 п.12 → маркер 'Резерв (снят 12.05.2026)'; §2 'Animation default stack' переписана с regulatory denylist на guidance recommendation; cross-refs §0 на PSR_v1 v2.0+ / Pravila v1.11+ / Tooling v1.16+. framer-motion — technical block (peerDep react+react-dom), не regulatory rule. Связано: PSR_v1 v1.7 → v2.0, Tooling v1.15 → v1.16, Pravila v1.10 → v1.11, CHANGELOG_claude_md.md entry + MEMORY sync. Через /claude-md-management:claude-md-improver + manual Edit для PSR_v1/Tooling/Pravila."
|
||||
|
||||
Do not touch §5 п.10 (claude-md-management hard-rule — still in force) or §5 п.11 (Superpowers hard rule — still in force). Do not renumber items §5 п.1–§5 п.11.
|
||||
|
||||
Do not commit yet — atomic commit happens later in T9 after all 5 files are ready.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify CLAUDE.md was updated**
|
||||
|
||||
Run: `Grep -n "v1.88" CLAUDE.md` — expect ≥1 match.
|
||||
Run: `Grep -n "Резерв" CLAUDE.md` — expect ≥1 match in §5 п.12 area.
|
||||
Run: `Grep -n "Default motion stack (рекомендация" CLAUDE.md` — expect ≥1 match in §2 area.
|
||||
Run: `Grep -n "motion runtime библиотеки" CLAUDE.md` — verify regulatory phrasing removed.
|
||||
|
||||
- [ ] **Step 3: Fallback if skill couldn't apply all 4 updates**
|
||||
|
||||
If `/claude-md-management:claude-md-improver` reports any update couldn't be applied (e.g., regex didn't match) — re-invoke the skill with corrected wording. Do NOT use direct Edit tool on CLAUDE.md (violation of §5 п.10). If skill repeatedly fails — stop and ask user.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: CHANGELOG_claude_md.md — add v1.88 entry
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/CHANGELOG_claude_md.md` (insert new entry at top, before existing v1.83 entry).
|
||||
|
||||
- [ ] **Step 1: Re-Read line range 1–10**
|
||||
|
||||
Read: `docs/CHANGELOG_claude_md.md` lines 1–10. Confirm entries are in descending chronological order, latest on top.
|
||||
|
||||
- [ ] **Step 2: Insert v1.88 entry above v1.83 entry (line 8)**
|
||||
|
||||
Use Edit tool. Insert new italic-block before the line starting `*CLAUDE.md v1.83 от 10.05.2026.`:
|
||||
|
||||
```
|
||||
*CLAUDE.md v1.88 от 12.05.2026. Изменения v1.88: **Снятие R15 motion-runtime restrictions** по решению заказчика 12.05.2026 ("сними все запреты на использование framer motion"). Через `superpowers:brainstorming` → 3 варианта (A узкая правка R15.2 / B полная отмена R15 / C one-strike trigger) → выбран B (вопреки рекомендации A: "доверяю team's choice motion-runtime без regulatory guardrail"). **Conscious rollback** v1.83 audited construction (10.05.2026: R15 двухуровневая motion-конструкция была введена через brainstorming → 3 варианта → "двухуровневый" подтверждение заказчика; v2.0 — это namesake rollback). **5 файлов изменены (atomic commit):** PSR_v1 v1.7 → v2.0 (удалено R15 целиком: R15.1 framer-motion hard-запрет + R15.2 motion-v 4 условия + R15.3 default стойка + R15.4 проверка триггера + R15.5 hard-запрет дублирования + R15.6 live-override + R15.7 расширение на gsap/anime/lottie; удалено R0.6 п.11 animation runtime hard-стоп; удалены R8 три тай-брейкера motion; удалена R11.6 иерархия motion-источников; удалены R13 пять строк motion-сценариев; финальная формула и свойства свода переформулированы; шапка v1.7 → v2.0 major bump). Pravila v1.10 → v1.11 (§11.5/§13.2 счётчик «16 правил R0–R15» → «15 правил R0–R14»; §13.9 + §13.10 cross-ref на PSR_v1 «v1.6» → «v2.0»; §13.10 НЕ удалено — оно про R14 UPM/21st pipeline, не R15). Tooling Прил. Н v1.15 → v1.16 (§9.2 reformulated из regulatory denylist в technical guidance: motion-v + gsap + anime.js + lottie-web + popmotion + @motionone/dom → ✅ разрешено без обоснования; framer-motion + react-spring → ❌ technical block, peerDep react+react-dom, не regulatory rule). **CLAUDE.md этот файл:** §2 «Animation default stack» переписан с regulatory denylist на guidance recommendation; §5 п.12 → маркер «Резерв. Был "не устанавливать motion runtime библиотеки без R15.2". Снят 12.05.2026.» (нумерация §5 п.1–11 сохранена, чтобы cross-refs в memory `feedback_environment.md` / `feedback_plugin_paired_stack.md` не сломать); §0 cross-refs обновлены (Pravila v1.10 → v1.11, PSR_v1 v1.7 → v2.0, Tooling v1.15 → v1.16); §9 «История версий» — entry v1.88. **MEMORY sync:** `memory/feedback_plugin_paired_stack.md` обновлён (v1.7 → v2.0; «R15 motion двухуровневый» → historical context); `MEMORY.md` index hook обновлён. **0 изменений в коде проекта** (`resources/js/`, `app/`, `db/` нетронуты). **0 npm install** (motion-v и др. в `package.json` не попадают; их установка теперь разрешена, но не делается этим коммитом). **0 schema changes**. **Plan:** docs/superpowers/plans/2026-05-12-remove-r15-motion-restrictions.md. **Spec:** docs/superpowers/specs/2026-05-12-remove-r15-motion-restrictions-design.md. Через `/claude-md-management:claude-md-improver` (для CLAUDE.md) + manual Edit (для PSR_v1, Tooling, Pravila). v1.87→v1.88.*
|
||||
|
||||
```
|
||||
|
||||
(Keep the new entry as a single paragraph in italic, matching the style of existing entries.)
|
||||
|
||||
- [ ] **Step 3: Verify file integrity**
|
||||
|
||||
Run: `head -3 docs/CHANGELOG_claude_md.md` — first three non-empty lines should start with v1.88.
|
||||
Run: `Grep -c "v1.88" docs/CHANGELOG_claude_md.md` — expect ≥1.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: MEMORY sync — update memory index + feedback file
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `C:\Users\Administrator\.claude\projects\c---------------------crm-------------\memory\MEMORY.md` (1 line)
|
||||
- Modify: `C:\Users\Administrator\.claude\projects\c---------------------crm-------------\memory\feedback_plugin_paired_stack.md` (~5 lines)
|
||||
|
||||
- [ ] **Step 1: Re-Read MEMORY.md index entry for plugin paired stack**
|
||||
|
||||
Use Read tool (full path with backslashes for Windows). Find line containing «Plugin paired stack pattern + расширенный пул UPM/21st».
|
||||
|
||||
Expected current content (approximately): `- [Plugin paired stack pattern + расширенный пул UPM/21st + R15 motion + tier-структура](feedback_plugin_paired_stack.md) — ...`
|
||||
|
||||
- [ ] **Step 2: Edit MEMORY.md — update hook to mention R15 removal**
|
||||
|
||||
Replace the existing «R15 motion» phrasing with «R15 motion (снят 12.05.2026 в PSR_v1 v2.0)» in the MEMORY.md index line.
|
||||
|
||||
Update version refs in the hook line (likely `**v1.7**, 16 правил R0–R15`) → `**v2.0**, 15 правил R0–R14`.
|
||||
|
||||
- [ ] **Step 3: Edit feedback_plugin_paired_stack.md frontmatter**
|
||||
|
||||
In `name:` field (line 2), update `**v1.7**, 16 правил R0–R15, ~33 закрытых конфликта` → `**v2.0**, 15 правил R0–R14, ~33 закрытых конфликта (R15 снят 12.05.2026 в v2.0)`.
|
||||
|
||||
In `description:` (line 3) update any `v1.7` → `v2.0`; preserve historical narrative ABOUT R15's introduction in v1.4 as point-in-time observation.
|
||||
|
||||
- [ ] **Step 4: Edit feedback_plugin_paired_stack.md body — add v2.0 rollback context**
|
||||
|
||||
Find the section heading "Ключевые ссылки (v1.6/v1.10/v1.14/v1.85):" (or similar latest snapshot). Update version refs:
|
||||
|
||||
- `Plugin_stack_rules_v1.md **v1.6**` → `**v2.0**`
|
||||
- `Pravila_raboty_Claude_v1_1.md **v1.10**` → `**v1.11**`
|
||||
- `CLAUDE.md **v1.85**` → `**v1.88**`
|
||||
- `Tooling_v8_3.md **v1.14**` → `**v1.16**`
|
||||
|
||||
Find the section heading "**R15 motion двухуровневый (новое 10.05.2026):**" — add at end of that paragraph:
|
||||
|
||||
```
|
||||
**Снято 12.05.2026 в PSR_v1 v2.0** (conscious rollback по решению заказчика; framer-motion переведён из regulatory hard-запрета в technical-only guidance). motion-v + gsap + anime.js + lottie-web + popmotion + @motionone/dom — теперь безусловно разрешены. Default motion stack (Vue native + Vuetify + CSS + View Transitions) — guidance, не hard-rule. См. CLAUDE.md v1.88 §2 и Tooling §9.2.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Verify memory updates**
|
||||
|
||||
Run: `Grep -n "R15 motion" memory/MEMORY.md` and `memory/feedback_plugin_paired_stack.md` — should find updated references mentioning «снят 12.05.2026».
|
||||
|
||||
(On Windows path containing Cyrillic: use full path with backslashes via Grep tool's `path` parameter pointing at the projects/.../memory dir.)
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Semantic grep verification — find any orphaned R15 refs
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read-only verification across project tree.
|
||||
|
||||
- [ ] **Step 1: Grep for live R15 regulatory refs**
|
||||
|
||||
Run:
|
||||
|
||||
```
|
||||
Grep pattern "R15\.[1-9]|R15\.[01][0-9]" path "docs/" type "md" output_mode "content" -n true
|
||||
```
|
||||
|
||||
Expected: only inside changelog entries (v1.4, v1.5, v1.6, v1.7, v2.0 historical narratives). NO matches inside R0/R1.../R14 rule bodies, NO matches in main rule narrative.
|
||||
|
||||
If any match remains in a rule body — investigate. Either it's a forgotten edit (fix), or a legitimate historical mention inside a different document's changelog (leave).
|
||||
|
||||
- [ ] **Step 2: Grep for «motion-runtime hard-запрет» / «framer-motion hard-запрет»**
|
||||
|
||||
Run:
|
||||
|
||||
```
|
||||
Grep pattern "motion-runtime hard-запрет|framer-motion hard-запрет" path "docs/" output_mode "content" -n true
|
||||
```
|
||||
|
||||
Plus same for `CLAUDE.md`.
|
||||
|
||||
Expected: 0 matches in live text. Only historical changelog narratives might contain «hard-запрет» wording — verify each.
|
||||
|
||||
- [ ] **Step 3: Grep for stale version refs**
|
||||
|
||||
Run:
|
||||
|
||||
```
|
||||
Grep pattern "PSR_v1 v1\.7|v1\.7 PSR_v1|Plugin_stack_rules_v1.*v1\.7" path "docs/ CLAUDE.md" output_mode "content" -n true
|
||||
```
|
||||
|
||||
Expected: 0 live refs (all must be at v2.0+ or explicitly historical).
|
||||
|
||||
Same for Pravila v1.10 → expect 0 live refs (all v1.11+ or historical).
|
||||
Same for Tooling v1.15 → expect 0 live refs (all v1.16+ or historical).
|
||||
Same for CLAUDE.md v1.87 → expect 0 live refs (all v1.88+ or historical).
|
||||
|
||||
- [ ] **Step 4: Cross-ref reciprocity check**
|
||||
|
||||
For each pair of documents:
|
||||
|
||||
- PSR_v1 v2.0 mentions CLAUDE.md v1.88+, Pravila v1.11+, Tooling v1.16+? ✓
|
||||
- CLAUDE.md v1.88 mentions PSR_v1 v2.0+, Pravila v1.11+, Tooling v1.16+? ✓
|
||||
- Tooling v1.16 mentions PSR_v1 v2.0+, CLAUDE.md v1.88+, Pravila v1.11+? ✓
|
||||
- Pravila v1.11 mentions PSR_v1 v2.0 in §11.5/§13.2? ✓
|
||||
|
||||
Use `Grep -n` on each document's header (lines 1–15) to verify.
|
||||
|
||||
- [ ] **Step 5: README sync check (drive-by)**
|
||||
|
||||
Run:
|
||||
|
||||
```
|
||||
Grep pattern "R15|motion-runtime" path "README.md docs/README_АРХИВ_v8_5.md" output_mode "content" -n true
|
||||
```
|
||||
|
||||
If matches exist with regulatory phrasing — flag for follow-up commit (out of scope for this plan; create a TODO note for engineer/user).
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Atomic commit + lefthook + push + PR
|
||||
|
||||
**Files:**
|
||||
|
||||
- All modified files staged together.
|
||||
|
||||
- [ ] **Step 1: Review git diff before staging**
|
||||
|
||||
Run: `git diff --stat`
|
||||
Expected: changes in 5–7 files:
|
||||
|
||||
- `CLAUDE.md`
|
||||
- `docs/Plugin_stack_rules_v1.md`
|
||||
- `docs/Tooling_v8_3.md`
|
||||
- `docs/Pravila_raboty_Claude_v1_1.md`
|
||||
- `docs/CHANGELOG_claude_md.md`
|
||||
- `memory/MEMORY.md`
|
||||
- `memory/feedback_plugin_paired_stack.md`
|
||||
|
||||
If unexpected files appear — investigate.
|
||||
|
||||
Spot-check: `git diff CLAUDE.md` shows §5 п.12 → маркер; `git diff docs/Plugin_stack_rules_v1.md` shows R15 deletion ~85 lines.
|
||||
|
||||
- [ ] **Step 2: Stage all 7 files explicitly (avoid `git add -A`)**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add CLAUDE.md \
|
||||
docs/Plugin_stack_rules_v1.md \
|
||||
docs/Tooling_v8_3.md \
|
||||
docs/Pravila_raboty_Claude_v1_1.md \
|
||||
docs/CHANGELOG_claude_md.md
|
||||
```
|
||||
|
||||
Memory files (`memory/MEMORY.md`, `memory/feedback_plugin_paired_stack.md`) are under the user's `~/.claude/projects/.../memory/` path (Windows). They are NOT in the repo working tree — they live in a different parent dir. Verify with `git status` whether memory paths appear in the repo. **If they do** (i.e., memory dir is symlinked or copied into repo) — stage them too. **If they don't** (normal case, memory is per-user/per-machine) — they're outside the repo, sync them separately and they don't go into the commit.
|
||||
|
||||
- [ ] **Step 3: Atomic commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
chore(rules): remove R15 motion-runtime restrictions (PSR_v1 v2.0)
|
||||
|
||||
Conscious rollback of v1.83 audited construction per user decision
|
||||
12.05.2026. R15 PSR_v1 section deleted entirely; framer-motion remains
|
||||
technical block (React-only peerDep), no longer regulatory rule.
|
||||
|
||||
Affected:
|
||||
- PSR_v1 v1.7 → v2.0 (R15, R0.6 п.11, R8 motion, R11.6, R13 motion rows
|
||||
removed; finale + properties reformulated)
|
||||
- CLAUDE.md v1.87 → v1.88 (§5 п.12 → marker; §2 motion stack → guidance)
|
||||
- Tooling v1.15 → v1.16 (§9.2 reformulated; framer-motion + react-spring
|
||||
marked as technical block, not regulatory)
|
||||
- Pravila v1.10 → v1.11 (§11.5/§13.2 counts updated; §13.9/§13.10 cross-ref
|
||||
bumps; §13.10 NOT deleted — it governs R14 UPM/21st pipeline, not R15)
|
||||
- CHANGELOG_claude_md.md — v1.88 entry
|
||||
|
||||
Brainstormed via superpowers:brainstorming. Planned via
|
||||
superpowers:writing-plans. Executed via /claude-md-management:claude-md-improver
|
||||
+ manual Edit.
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-12-remove-r15-motion-restrictions-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-12-remove-r15-motion-restrictions.md
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Verify lefthook passed all hooks**
|
||||
|
||||
If a hook fails:
|
||||
|
||||
- **lychee broken link** — fix the markdown link, re-stage, NEW commit (do NOT amend)
|
||||
- **markdownlint error** — fix the .md, re-stage, NEW commit
|
||||
- **gitleaks leak** — investigate (motion-related docs should have no secrets; if false-positive, evaluate and add to gitleaks allowlist with caution)
|
||||
- **larastan / pint / pest** — not applicable (no PHP touched); if hooks try to run on PHP files because of changed file list — investigate hook config
|
||||
|
||||
Expected: all hooks pass, commit lands clean.
|
||||
|
||||
Run: `git log --oneline -3` to verify HEAD = atomic commit.
|
||||
|
||||
- [ ] **Step 5: Push branch**
|
||||
|
||||
Run: `git push -u origin rules/remove-r15`
|
||||
Expected: branch pushed to GitHub.
|
||||
|
||||
- [ ] **Step 6: Create PR with `gh pr create`**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
gh pr create --base main --head rules/remove-r15 --title "chore(rules): remove R15 motion-runtime restrictions (PSR_v1 v2.0)" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
|
||||
Conscious rollback of v1.83 audited construction per user decision 12.05.2026 ("сними все запреты на использование framer motion").
|
||||
|
||||
R15 PSR_v1 section removed entirely. motion-v / gsap / anime.js / lottie-web / popmotion / @motionone/dom — unconditionally permitted. framer-motion / react-spring remain **technical block** (React-only peerDep on react+react-dom), not regulatory rule — `npm install framer-motion` would proceed with peer-warn but `import { motion } from 'framer-motion'` in `.vue` files would runtime crash.
|
||||
|
||||
## Files changed
|
||||
|
||||
- `docs/Plugin_stack_rules_v1.md` v1.7 → v2.0 — R15 section deleted, R0.6 п.11 deleted, R8 motion tie-breakers deleted, R11.6 motion hierarchy deleted, R13 motion rows deleted, finale + properties reformulated.
|
||||
- `CLAUDE.md` v1.87 → v1.88 — §5 п.12 → marker preserving numbering, §2 Animation default stack → guidance recommendation.
|
||||
- `docs/Tooling_v8_3.md` v1.15 → v1.16 — §9.2 reformulated from regulatory denylist into technical guidance.
|
||||
- `docs/Pravila_raboty_Claude_v1_1.md` v1.10 → v1.11 — §11.5 + §13.2 counts updated; §13.9 + §13.10 cross-refs bumped. §13.10 NOT deleted (governs R14 UPM/21st pipeline, not R15 — spec design section 2.4 contained misreading; correction in v1.11 changelog).
|
||||
- `docs/CHANGELOG_claude_md.md` — v1.88 entry.
|
||||
|
||||
## Spec / Plan
|
||||
|
||||
- Spec: [docs/superpowers/specs/2026-05-12-remove-r15-motion-restrictions-design.md](docs/superpowers/specs/2026-05-12-remove-r15-motion-restrictions-design.md)
|
||||
- Plan: [docs/superpowers/plans/2026-05-12-remove-r15-motion-restrictions.md](docs/superpowers/plans/2026-05-12-remove-r15-motion-restrictions.md)
|
||||
|
||||
## Verification
|
||||
|
||||
- [x] Lefthook all hooks passed (gitleaks 0, lychee 0 broken, markdownlint clean)
|
||||
- [x] Semantic grep — 0 live R15 refs in rule bodies (only historical in changelog entries)
|
||||
- [x] Cross-ref reciprocity verified between 4 normative docs
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No code touched (`resources/js/`, `app/`, `db/`, `tests/`).
|
||||
- No npm install (motion-v et al. now permitted but not installed).
|
||||
- No schema changes.
|
||||
- `framer-motion` remains technical block — installing it would not break the rule base, but it would not work in Vue.
|
||||
|
||||
## Test plan
|
||||
|
||||
- [ ] Visual review of diff
|
||||
- [ ] Verify §5 п.12 in CLAUDE.md reads "Резерв. Был ... Снят 12.05.2026 ..."
|
||||
- [ ] Verify R15 section absent from PSR_v1 v2.0 rule body
|
||||
- [ ] Verify §13.10 still present in Pravila v1.11 (governs R14, not R15)
|
||||
- [ ] (Optional) `npm install motion-v --save-dev --dry-run` does not trigger any project-level rule check failure
|
||||
|
||||
🤖 Generated with [Claude Code](https://claude.com/claude-code)
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Return PR URL to user**
|
||||
|
||||
Echo the URL printed by `gh pr create`. Plan execution complete.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**1. Spec coverage:**
|
||||
|
||||
- Spec §2.1 (PSR_v1 changes) → Task 2 ✓
|
||||
- Spec §2.2 (CLAUDE.md changes) → Task 5 (via skill) ✓
|
||||
- Spec §2.3 (Tooling changes) → Task 3 ✓
|
||||
- Spec §2.4 (Pravila changes) → Task 4 (with correction: §13.10 NOT deleted, it's R14-linked not R15-linked — verified during plan exploration) ✓
|
||||
- Spec §4 (MEMORY sync, CHANGELOG) → Tasks 6 + 7 ✓
|
||||
- Spec §5 (verification) → Task 8 + Task 9 step 4 ✓
|
||||
- Spec §6 (atomic, branch, channel, commit, PR) → Tasks 1, 9 ✓
|
||||
|
||||
**2. Placeholder scan:** No TBD/TODO; all wording explicit. One judgment call documented (memory files in/out of repo, T9 step 2) with engineer instruction to verify via `git status`.
|
||||
|
||||
**3. Type consistency:** Version refs consistent across all task descriptions (PSR_v1 v1.7 → v2.0, CLAUDE.md v1.87 → v1.88, Tooling v1.15 → v1.16, Pravila v1.10 → v1.11). File paths consistent (forward-slash repo-relative). Spec design assumption about §13.10 being R15-linked is corrected inline in Task 4 plus surfaced in CHANGELOG entry T6.
|
||||
|
||||
**4. Risk notes for engineer:**
|
||||
|
||||
- Line numbers in this plan are based on plan-writing-time Read of files. After ANY edit (especially T2 deletions), subsequent line numbers shift. **Always Re-Read affected ranges before next edit.**
|
||||
- `superpowers:executing-plans` and `superpowers:subagent-driven-development` both work; subagent-driven recommended for review checkpoints between tasks (especially T2 which is large).
|
||||
- If `/claude-md-management:claude-md-improver` cannot match a regex on CLAUDE.md (e.g., due to whitespace) — re-invoke with refined wording. Do NOT fall back to direct Edit (§5 п.10 hard-rule).
|
||||
@@ -1,763 +0,0 @@
|
||||
# CTO-19 Lucide icon migration — 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:** Закрыть CTO-19 ⏸ через установку `lucide-vue-next` и регистрацию custom Vuetify IconSet с 78-entry mapping `mdi-*` → Lucide components. Views НЕ touched.
|
||||
|
||||
**Architecture:** Single core change в [`app/resources/js/plugins/vuetify.ts`](../../../app/resources/js/plugins/vuetify.ts) — добавление `icons` config с custom `IconSet`, где `component` function принимает Vuetify icon prop (`mdi-plus`/etc.) и рендерит Lucide component через `h()`. Fallback `HelpCircle` для unmapped. 51 Vue/TS файл с `icon="mdi-*"` invariant.
|
||||
|
||||
**Tech Stack:** Vue 3.5 + Vuetify 3.12 + `lucide-vue-next` (~0.460.0+). Vite tree-shaking → ~25-30 KB gzip bundle delta.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-13-cto-19-lucide-icon-migration-design.md](../specs/2026-05-13-cto-19-lucide-icon-migration-design.md) (approved 13.05.2026 path α).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Responsibility |
|
||||
|---|---|
|
||||
| `app/package.json` | +1 dep `lucide-vue-next` |
|
||||
| `app/package-lock.json` | auto-generated `npm install` |
|
||||
| `app/resources/js/plugins/vuetify.ts` | +icons config (~80 строк добавлений: Lucide imports, lucideMap 78 entries, liderraLucideSet IconSet, registration в `createVuetify`) |
|
||||
| `docs/Открытые_вопросы_v8_3.md` | CTO-19 §3 строка ⏸ → ✅; Сводка §0 counters (CTO 17 ✅ → 18 ✅ / 1 ⏸ → 0 ⏸); header v1.82 → v1.83 + changelog; footer v1.83 |
|
||||
| `CLAUDE.md` | §0 row «Открытые вопросы» v1.82 → v1.83 |
|
||||
| Memory `reference_archive.md` + `MEMORY.md` index | version refs sync v1.82 → v1.83 (post-push) |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Install lucide-vue-next
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/package.json` (+1 dependency)
|
||||
- Modify: `app/package-lock.json` (auto)
|
||||
|
||||
- [ ] **Step 1.1: Install package**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app"
|
||||
npm install lucide-vue-next
|
||||
```
|
||||
|
||||
Expected output: `added 1 package` (или `added N packages` если есть transitive). Exit 0.
|
||||
|
||||
- [ ] **Step 1.2: Verify installed**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app"
|
||||
grep -A1 lucide-vue-next package.json
|
||||
```
|
||||
|
||||
Expected: `"lucide-vue-next": "^0.460.X"` (где X — actual version) в `"dependencies"` секции.
|
||||
|
||||
- [ ] **Step 1.3: Snapshot baseline tests (PRE-modification)**
|
||||
|
||||
Зафиксировать baseline ДО изменения vuetify.ts:
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app"
|
||||
./vendor/bin/pest --parallel --recreate-databases 2>&1 | tail -3
|
||||
```
|
||||
|
||||
Expected: `{"tool":"pest","result":"passed","tests":742,"passed":739,"assertions":2243,"duration_ms":~55000,"skipped":3}`.
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app"
|
||||
npm run test:vue 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: `Test Files 88 passed (88) | Tests 683 passed | 3 skipped (686)`.
|
||||
|
||||
**NB:** не commit'ить package.json одиночным коммитом — Task 2 объединит с vuetify.ts в один atomic commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update vuetify.ts — register Lucide IconSet
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/plugins/vuetify.ts` (lines 1-75 → расширяется до ~155 строк)
|
||||
|
||||
- [ ] **Step 2.1: Read current vuetify.ts полностью**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация"
|
||||
cat app/resources/js/plugins/vuetify.ts
|
||||
```
|
||||
|
||||
Зафиксировать существующую структуру (theme, defaults block) — НЕ менять.
|
||||
|
||||
- [ ] **Step 2.2: Add Lucide imports + lucideMap + IconSet**
|
||||
|
||||
Edit `app/resources/js/plugins/vuetify.ts`:
|
||||
|
||||
Заменить первые 3 строки imports:
|
||||
|
||||
```ts
|
||||
// @ts-expect-error vuetify/styles — CSS-импорт без d.ts
|
||||
import 'vuetify/styles';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import type { ThemeDefinition } from 'vuetify';
|
||||
```
|
||||
|
||||
На:
|
||||
|
||||
```ts
|
||||
// @ts-expect-error vuetify/styles — CSS-импорт без d.ts
|
||||
import 'vuetify/styles';
|
||||
import { h, type Component } from 'vue';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import type { ThemeDefinition, IconSet, IconProps } from 'vuetify';
|
||||
import {
|
||||
Activity, AlertCircle, AlertTriangle, Archive, ArrowDown, ArrowLeft, ArrowRight, ArrowRightLeft,
|
||||
ArrowUp, Bell, BellOff, Camera, Check, CheckCircle, ChevronRight, CircleStop, Clock, Code,
|
||||
Columns3, Copy, CreditCard, Download, Edit, Eye, EyeOff, FilterX, FileText, FlaskConical,
|
||||
Folder, Folders, Globe, HelpCircle, Key, KeyRound, LayoutDashboard, List, LogOut, Mail,
|
||||
Megaphone, MessageSquare, MessageSquareText, MoreVertical, Pause, Pencil, Phone, Play, Plus,
|
||||
PlusCircle, Puzzle, ReceiptText, RefreshCw, RotateCcw, RotateCw, RussianRuble, Save, Search,
|
||||
Settings, Shield, ShieldCheck, ShieldOff, Tag, Trash2, User, UserCheck, UserCog, UserPlus,
|
||||
Users, Wallet, Webhook, X, XCircle,
|
||||
} from 'lucide-vue-next';
|
||||
```
|
||||
|
||||
Затем добавить ПЕРЕД `export const vuetify = createVuetify({` (после блока ThemeDefinition):
|
||||
|
||||
```ts
|
||||
/**
|
||||
* Liderra Lucide IconSet (CTO-19 closure, v1.83).
|
||||
* Spec: docs/superpowers/specs/2026-05-13-cto-19-lucide-icon-migration-design.md
|
||||
*
|
||||
* Maps 78 mdi-* semantic-ID strings (used across 51 Vue/TS files in resources/js/)
|
||||
* to Lucide Vue components for rendering. Views НЕ touched — mdi-* строки остаются
|
||||
* как semantic identifiers, рендерятся через Lucide stroke-based SVG icons.
|
||||
*
|
||||
* CLAUDE.md §2 «Иконки: Lucide» — бренд-spec compliance.
|
||||
*/
|
||||
const lucideMap: Record<string, Component> = {
|
||||
'mdi-account-arrow-right-outline': UserCheck,
|
||||
'mdi-account-group-outline': Users,
|
||||
'mdi-account-outline': User,
|
||||
'mdi-account-plus-outline': UserPlus,
|
||||
'mdi-account-switch': UserCog,
|
||||
'mdi-alert-circle': AlertCircle,
|
||||
'mdi-alert-circle-outline': AlertCircle,
|
||||
'mdi-alert-outline': AlertTriangle,
|
||||
'mdi-api': Webhook,
|
||||
'mdi-archive': Archive,
|
||||
'mdi-arrow-down': ArrowDown,
|
||||
'mdi-arrow-left': ArrowLeft,
|
||||
'mdi-arrow-up': ArrowUp,
|
||||
'mdi-autorenew': RotateCw,
|
||||
'mdi-bell-off-outline': BellOff,
|
||||
'mdi-bell-outline': Bell,
|
||||
'mdi-bullhorn-outline': Megaphone,
|
||||
'mdi-camera': Camera,
|
||||
'mdi-cash-plus': PlusCircle,
|
||||
'mdi-chart-box-outline': LayoutDashboard,
|
||||
'mdi-check': Check,
|
||||
'mdi-check-circle': CheckCircle,
|
||||
'mdi-check-circle-outline': CheckCircle,
|
||||
'mdi-chevron-right': ChevronRight,
|
||||
'mdi-clock-check-outline': Clock,
|
||||
'mdi-clock-outline': Clock,
|
||||
'mdi-close': X,
|
||||
'mdi-close-circle': XCircle,
|
||||
'mdi-cog-outline': Settings,
|
||||
'mdi-comment-outline': MessageSquare,
|
||||
'mdi-content-copy': Copy,
|
||||
'mdi-content-save-outline': Save,
|
||||
'mdi-credit-card-outline': CreditCard,
|
||||
'mdi-currency-rub': RussianRuble,
|
||||
'mdi-delete-outline': Trash2,
|
||||
'mdi-dots-vertical': MoreVertical,
|
||||
'mdi-download': Download,
|
||||
'mdi-email-outline': Mail,
|
||||
'mdi-eye': Eye,
|
||||
'mdi-eye-off': EyeOff,
|
||||
'mdi-eye-outline': Eye,
|
||||
'mdi-file-pdf-box': FileText,
|
||||
'mdi-filter-off': FilterX,
|
||||
'mdi-folder-multiple-outline': Folders,
|
||||
'mdi-folder-outline': Folder,
|
||||
'mdi-format-list-bulleted': List,
|
||||
'mdi-key': Key,
|
||||
'mdi-lock-reset': KeyRound,
|
||||
'mdi-logout': LogOut,
|
||||
'mdi-magnify': Search,
|
||||
'mdi-message-text': MessageSquareText,
|
||||
'mdi-pause': Pause,
|
||||
'mdi-pencil': Pencil,
|
||||
'mdi-pencil-outline': Pencil,
|
||||
'mdi-phone': Phone,
|
||||
'mdi-play': Play,
|
||||
'mdi-plus': Plus,
|
||||
'mdi-plus-circle-outline': PlusCircle,
|
||||
'mdi-progress-clock': Clock,
|
||||
'mdi-pulse': Activity,
|
||||
'mdi-puzzle-outline': Puzzle,
|
||||
'mdi-receipt-text-check-outline': ReceiptText,
|
||||
'mdi-refresh': RefreshCw,
|
||||
'mdi-restore': RotateCcw,
|
||||
'mdi-shield-account-outline': ShieldCheck,
|
||||
'mdi-shield-key': Shield,
|
||||
'mdi-shield-lock-outline': ShieldCheck,
|
||||
'mdi-shield-off': ShieldOff,
|
||||
'mdi-stop-circle-outline': CircleStop,
|
||||
'mdi-swap-horizontal': ArrowRightLeft,
|
||||
'mdi-tag-arrow-right': Tag,
|
||||
'mdi-test-tube': FlaskConical,
|
||||
'mdi-trash-can-outline': Trash2,
|
||||
'mdi-view-column-outline': Columns3,
|
||||
'mdi-view-dashboard-outline': LayoutDashboard,
|
||||
'mdi-wallet-outline': Wallet,
|
||||
'mdi-web': Globe,
|
||||
'mdi-xml': Code,
|
||||
};
|
||||
|
||||
const liderraLucideSet: IconSet = {
|
||||
component: (props: IconProps) => {
|
||||
const Icon = lucideMap[String(props.icon)] || HelpCircle;
|
||||
return h(Icon, { size: 20, strokeWidth: 1.75 });
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Затем в `createVuetify({...})` call, ПОСЛЕ блока `theme: { ... }` и ПЕРЕД блоком `defaults: { ... }`, добавить:
|
||||
|
||||
```ts
|
||||
icons: {
|
||||
defaultSet: 'liderra',
|
||||
sets: { liderra: liderraLucideSet },
|
||||
},
|
||||
```
|
||||
|
||||
**Финальная структура файла:**
|
||||
|
||||
```
|
||||
1: import 'vuetify/styles';
|
||||
2: import { h, type Component } from 'vue';
|
||||
3: import { createVuetify } from 'vuetify';
|
||||
4: import type { ThemeDefinition, IconSet, IconProps } from 'vuetify';
|
||||
5-12: import { Activity, AlertCircle, ... } from 'lucide-vue-next';
|
||||
14-32: theme liderraForest (UNCHANGED)
|
||||
34-110: lucideMap (78 entries) + liderraLucideSet
|
||||
112-155: createVuetify({ theme, icons, defaults })
|
||||
```
|
||||
|
||||
- [ ] **Step 2.3: Type-check**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app"
|
||||
npm run type-check 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: `0 errors`. Если есть ошибки про unused imports — удалить unused Lucide components из import statement.
|
||||
|
||||
- [ ] **Step 2.4: Run Vitest**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app"
|
||||
npm run test:vue 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: `Test Files 88 passed (88) | Tests 683 passed | 3 skipped (686)`. Идентично pre-modification baseline.
|
||||
|
||||
Если есть failures — falsify 3 гипотезы:
|
||||
|
||||
1. **H1**: Lucide import name неверен (имя компонента у Lucide отличается) → grep test failure для component name → проверить в `lucide-vue-next/dist/index.d.ts`.
|
||||
2. **H2**: IconSet API mismatch (Vuetify ожидает другой shape) → проверить Vuetify docs / type definition.
|
||||
3. **H3**: Test stub mismatch (Vitest stubs `<v-icon>` per test setup) → проверить тест setup в `app/vitest.config.ts` / `app/tests/setup.ts`.
|
||||
|
||||
Falsify каждую перед фиксом.
|
||||
|
||||
- [ ] **Step 2.5: Vite build**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app"
|
||||
npm run build 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: `✓ built in ~2-3s`, exit 0. App.js / chunks slightly bigger (~25-30 KB gzip Lucide chunk).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Visual smoke на 5 ключевых views (Playwright MCP)
|
||||
|
||||
**Files:** (no file changes — verification only)
|
||||
|
||||
- [ ] **Step 3.1: Start dev server**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app"
|
||||
npm run dev &
|
||||
```
|
||||
|
||||
Wait until `Local: http://127.0.0.1:5173/` появится в output.
|
||||
|
||||
- [ ] **Step 3.2: Login через Playwright MCP**
|
||||
|
||||
Navigate to `http://127.0.0.1:5173/login`. Fill email `admin@demo.local` / password `password`. Click «Войти».
|
||||
|
||||
- [ ] **Step 3.3: Visual smoke /dashboard**
|
||||
|
||||
Navigate to `http://127.0.0.1:5173/dashboard`. Take screenshot. Verify visually:
|
||||
|
||||
- KPI-cards: icons видны (mdi-cash-plus → PlusCircle, mdi-account-group-outline → Users etc.)
|
||||
- Notification bell видна (mdi-bell-outline → Bell)
|
||||
- Action buttons имеют glyph'ы.
|
||||
|
||||
Acceptance: 0 пустых icon mounts.
|
||||
|
||||
- [ ] **Step 3.4: Visual smoke /projects**
|
||||
|
||||
Navigate to `http://127.0.0.1:5173/projects`. Screenshot. Verify:
|
||||
|
||||
- Search input magnify (mdi-magnify → Search)
|
||||
- Clearable X (mdi-close-circle → XCircle)
|
||||
- Action menu MoreVertical (mdi-dots-vertical)
|
||||
- Status pill icons
|
||||
|
||||
Acceptance: 0 пустых icon mounts.
|
||||
|
||||
- [ ] **Step 3.5: Visual smoke /settings**
|
||||
|
||||
Navigate to `http://127.0.0.1:5173/settings`. Tab через 8 tabs. Verify:
|
||||
|
||||
- Tab icons (если есть)
|
||||
- Save buttons (mdi-content-save-outline → Save)
|
||||
- Security cards icons (Key, Shield, ShieldCheck)
|
||||
- Eye toggle (mdi-eye / mdi-eye-off → Eye/EyeOff)
|
||||
|
||||
Acceptance: 0 пустых icon mounts.
|
||||
|
||||
- [ ] **Step 3.6: Visual smoke /admin/billing**
|
||||
|
||||
Navigate to `http://127.0.0.1:5173/admin/billing`. Verify:
|
||||
|
||||
- Sidebar navigation icons
|
||||
- Table action icons
|
||||
- Dropdown chevrons (mdi-chevron-right → ChevronRight)
|
||||
- CreditCard / Wallet visible
|
||||
|
||||
Acceptance: 0 пустых icon mounts.
|
||||
|
||||
- [ ] **Step 3.7: Visual smoke /no-such-404**
|
||||
|
||||
Navigate to `http://127.0.0.1:5173/no-such-404`. Verify ErrorView icons (если есть в illustration). Acceptance: page renders, 0 console errors related to icon resolution.
|
||||
|
||||
- [ ] **Step 3.8: Stop dev server**
|
||||
|
||||
```bash
|
||||
# Если запущен через background — найти и убить
|
||||
ps -ef | grep -i "vite" | grep -v grep
|
||||
# kill <PID>
|
||||
```
|
||||
|
||||
- [ ] **Step 3.9: A11y re-scan (axe-core CDN inject через Playwright)**
|
||||
|
||||
Re-run scan на `/dashboard` + `/admin/billing` per quirk 70 pattern (CDN inject + `setTimeout 500ms` + `axe.run()`). Acceptance: 0 новых violations по iconography rules (`aria-hidden-focus`, `image-alt`).
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Confirm «закрываем CTO-19» from user
|
||||
|
||||
**Files:** (no file changes — gating question)
|
||||
|
||||
- [ ] **Step 4.1: Ask user via AskUserQuestion**
|
||||
|
||||
Pravila §2.2 / §7.1 — закрытие CTO-19 в реестре требует explicit «закрываем» от заказчика. Сформулировать через `AskUserQuestion`:
|
||||
|
||||
```
|
||||
question: "CTO-19 ⏸ → ✅ — закрываем? Visual smoke на 5 views прошёл; иконки рендерятся через Lucide; tests baseline match."
|
||||
options:
|
||||
- "Да, закрываем CTO-19 как ✅"
|
||||
- "Нет, отложить closure до дополнительной проверки"
|
||||
- "Откатить migration"
|
||||
```
|
||||
|
||||
- [ ] **Step 4.2: Записать ответ**
|
||||
|
||||
Если ответ ≠ «Да, закрываем» — НЕ переходить к Task 5/6/7. Возврат к user для дополнительных действий.
|
||||
|
||||
Если «Да, закрываем» — proceed к Task 5.
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Registry v1.82 → v1.83 — CTO-19 ✅ closure + Сводка §0 update
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/Открытые_вопросы_v8_3.md` (CTO-19 строка §3, Сводка §0 row CTO, Итого row, header version, footer version)
|
||||
|
||||
- [ ] **Step 5.1: Locate CTO-19 entry**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация"
|
||||
grep -n "CTO-19" docs/Открытые_вопросы_v8_3.md | head -3
|
||||
```
|
||||
|
||||
Expected: 1 match строки в section §3 «CTO / архитектор» (line ~2266 после v1.82 bump, +offset от v1.82 inserts).
|
||||
|
||||
- [ ] **Step 5.2: Edit CTO-19 entry — ⏸ → ✅**
|
||||
|
||||
Edit `docs/Открытые_вопросы_v8_3.md`:
|
||||
|
||||
Заменить (значение `⏸ **CTO-19**` start of entry):
|
||||
|
||||
```
|
||||
| ⏸ **CTO-19** | **Иконочная система портала не подключена — все `mdi-*` рендерятся пустыми.**
|
||||
```
|
||||
|
||||
На:
|
||||
|
||||
```
|
||||
| ✅ **CTO-19** | **Иконочная система — Lucide migration через Vuetify custom IconSet (закрыто 13.05.2026).** В `app/resources/js/plugins/vuetify.ts` добавлен custom `IconSet` (`liderraLucideSet`) с 78-entry mapping `mdi-*` → Lucide components. Все 51 Vue/TS файл с `icon="mdi-*"` остаются untouched — mdi-имена работают как semantic-ID для Lucide рендеринга. `npm i lucide-vue-next` selective tree-shakable imports ~25-30 KB gzip. CLAUDE.md §2 «Иконки: Lucide» — бренд-spec compliance. **Spec:** docs/superpowers/specs/2026-05-13-cto-19-lucide-icon-migration-design.md. **Plan:** docs/superpowers/plans/2026-05-13-cto-19-lucide-icon-migration.md. **Прежний контент:** Vuetify-конфиг создавал `<i class="mdi-...">` (default MDI), но CSS-пакет `@mdi/font` в `package.json` отсутствовал → `document.fonts` не содержит `Material Design Icons`, `::before content: none`. Затронуты все места с `prepend-icon`/`append-icon`/`icon`/`clearable` в Vue-views: «+ Создать проект» (mdi-plus → Plus), поле поиска (mdi-magnify → Search), clearable v-select/v-text-field (mdi-close-circle → XCircle), notification bell, dropdown chevron'ы. Локальный workaround клиентский для clearable-крестика в `ProjectsView.vue` (`::after content '✕'`) применён 12.05.2026 — может быть убран в отдельной post-CTO-19 hygiene-task. | Закрыт 13.05.2026 |
|
||||
```
|
||||
|
||||
- [ ] **Step 5.3: Update Сводка §0 row CTO**
|
||||
|
||||
Edit `docs/Открытые_вопросы_v8_3.md`:
|
||||
|
||||
Заменить:
|
||||
|
||||
```
|
||||
| CTO / архитектор | **18** | **17** (CTO-1..16 + CTO-20 v1.81 ICU collation) | 0 | **1** (CTO-19 v1.79 ⏸ иконочная система `@mdi/font`) | 0 | 0 | 1 [?] |
|
||||
```
|
||||
|
||||
На:
|
||||
|
||||
```
|
||||
| CTO / архитектор | **18** | **18** (CTO-1..16 + CTO-19 v1.83 Lucide migration + CTO-20 v1.81 ICU collation) | 0 | 0 | 0 | 0 | 0 |
|
||||
```
|
||||
|
||||
- [ ] **Step 5.4: Update Сводка §0 Итого row**
|
||||
|
||||
Edit `docs/Открытые_вопросы_v8_3.md`:
|
||||
|
||||
Заменить:
|
||||
|
||||
```
|
||||
| **Итого продуктовых** | **87** | **70 ✅** | **5 🟦** | **12 ⏸** | **1 P0** | **5 P1** | **3 P2** + 2 P3 (Биз-29, Биз-30) + 1 P2 [?] (CTO-19, priority TBD) |
|
||||
```
|
||||
|
||||
На:
|
||||
|
||||
```
|
||||
| **Итого продуктовых** | **87** | **71 ✅** | **5 🟦** | **11 ⏸** | **1 P0** | **5 P1** | **3 P2** + 2 P3 (Биз-29, Биз-30) |
|
||||
```
|
||||
|
||||
- [ ] **Step 5.5: Update «Истинные P0-блокеры»**
|
||||
|
||||
В блоке "Истинные P0-блокеры" — обновить дату: «на 13.05.2026 (после v1.82)» → «на 13.05.2026 (после v1.83)». Контент сохранить.
|
||||
|
||||
- [ ] **Step 5.6: Bump header v1.82 → v1.83**
|
||||
|
||||
Заменить на line 5 первый блок:
|
||||
|
||||
```
|
||||
**Версия:** 1.82 от 13.05.2026 (day) — **Catch-up bump v1.77 → v1.82** после обнаружения hidden inconsistency
|
||||
```
|
||||
|
||||
На:
|
||||
|
||||
```
|
||||
**Версия:** 1.83 от 13.05.2026 (day, +1) — **CTO-19 ✅ closed** через Lucide migration: `npm i lucide-vue-next` + custom Vuetify IconSet (`liderraLucideSet`) в `app/resources/js/plugins/vuetify.ts` с 78-entry mapping `mdi-*` → Lucide components. Views (51 файл) untouched. Спец [docs/superpowers/specs/2026-05-13-cto-19-lucide-icon-migration-design.md](superpowers/specs/2026-05-13-cto-19-lucide-icon-migration-design.md) + plan [docs/superpowers/plans/2026-05-13-cto-19-lucide-icon-migration.md](superpowers/plans/2026-05-13-cto-19-lucide-icon-migration.md). Сводка §0 update: CTO **18/17✅/1⏸ → 18/18✅/0⏸**; Итого продуктовых **87/70✅/12⏸ → 87/71✅/11⏸**. Регрессия: Pest 742/739/0/3 ✅ + Vitest 88 files / 683 passed + 3 skipped + Vite build exit 0 + bundle Lucide +~25-30 KB gzip. CLAUDE.md §2 «Иконки: Lucide» — бренд-spec compliance achieved. Заказчик 13.05.2026: «E. CTO-19 fix — иконочная система» + «ii. Правильный — Lucide migration» + явное «закрываем» на Task 4. Path α (aliases-only). Path (i) @mdi/font REJECTED (250 KB CSS, против бренда). Path β rename strings REJECTED (большой diff 51 файл, не нужен). Через `superpowers:brainstorming` → `superpowers:writing-plans` → `superpowers:executing-plans` + manual Edit. Предыдущая v1.82 — catch-up bump.
|
||||
```
|
||||
|
||||
Затем добавить **NEW** «Что изменилось в v1.83 относительно v1.82:» block перед существующим «Что изменилось v1.82 относительно v1.77»:
|
||||
|
||||
```
|
||||
**Что изменилось в v1.83 относительно v1.82:**
|
||||
|
||||
- **CTO-19 ⏸ → ✅ closed** через Lucide migration. Custom Vuetify IconSet (`liderraLucideSet`) в `app/resources/js/plugins/vuetify.ts`. 78-entry mapping `mdi-*` semantic-ID → Lucide components (Plus, Search, Bell, AlertCircle, etc.). 51 Vue/TS файл с `icon="mdi-*"` invariant. `npm i lucide-vue-next` (~25-30 KB gzip tree-shakable).
|
||||
- **Сводка §0 counter update**: CTO 17✅ → 18✅, 1⏸ → 0⏸; Итого 70✅ → 71✅, 12⏸ → 11⏸. P2 [?] (CTO-19 priority TBD) убрано.
|
||||
- **Регрессия зелёная**: Pest --parallel 742/739/0/3 ✅, Vitest 88 files / 683 passed + 3 skipped, Vite build exit 0, lychee 248 OK, gitleaks 0.
|
||||
- **Brand alignment**: CLAUDE.md §2 «Иконки: Lucide» — теперь visually реализован (был declaratory только в спеке).
|
||||
|
||||
---
|
||||
|
||||
```
|
||||
|
||||
- [ ] **Step 5.7: Update footer**
|
||||
|
||||
Заменить на line ~2810 (после v1.82 bump):
|
||||
|
||||
```
|
||||
*Версия: 1.82 от 13.05.2026 (day) — Catch-up bump v1.77 → v1.82 после обнаружения hidden inconsistency
|
||||
```
|
||||
|
||||
На:
|
||||
|
||||
```
|
||||
*Версия: 1.83 от 13.05.2026 (day, +1) — CTO-19 ✅ closed через Lucide migration. `npm i lucide-vue-next` + Vuetify custom IconSet с 78-entry mapping. Views untouched (51 файл invariant). Сводка §0: CTO 17✅ → 18✅, 0⏸; Итого 70✅ → 71✅, 11⏸. Brand alignment CLAUDE.md §2. Регрессия Pest 742/Vitest 683/Vite 2.67s/lychee 248 OK/gitleaks 0. Через `superpowers:brainstorming` → `superpowers:writing-plans` → `superpowers:executing-plans`. Path α (aliases-only); paths (i)/(β) REJECTED. Предыдущая v1.82 — catch-up.*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: CLAUDE.md §0 sync — registry version v1.82 → v1.83
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `CLAUDE.md` (line ~20 — §0 «Открытые вопросы» row)
|
||||
|
||||
- [ ] **Step 6.1: Read current row**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация"
|
||||
grep -n "Открытые_вопросы_v8_3.md" CLAUDE.md | head -1
|
||||
```
|
||||
|
||||
Зафиксировать текущую строку (v1.82 row from registry catch-up commit `9bc0419`).
|
||||
|
||||
- [ ] **Step 6.2: Edit row — bump v1.82 → v1.83**
|
||||
|
||||
Edit `CLAUDE.md`:
|
||||
|
||||
Заменить:
|
||||
|
||||
```
|
||||
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (**v1.82 от 13.05.2026 (day) — Catch-up bump** v1.77 → v1.82 после hidden inconsistency: header заявлял v1.77, footer накопил v1.78/v1.79/v1.80/v1.81. Содержит v1.78 Plan 4 +7 Биз-25..31 ⏸ (section ## 14), v1.79 CTO-19 ⏸ иконочная система, v1.80 CTO-20 ⏸ ICU collation, v1.81 CTO-20 ✅ closed, v1.82 — post-Plan-4 context (Plan 5 frontend / Quiet Luxury / Q.DEFER closures audit-internal / R15 merge — не Q-items). **Сводка §0 после v1.82: 87 продуктовых / 70 ✅ / 5 🟦 / 12 ⏸ / 1 P0 + 5 P1 + 3 P2 + 2 P3 + 1 P2 [?]**. **Регрессия: Pest --parallel 742/739/0/3 / Vitest 88 files / 683 passed + 3 skipped / Vite build 2.67s / lychee 248 OK / gitleaks 0 (367 commits)**. v1.77 baseline — Sprint 4 «Audit tail» (Pest 421 / Vitest 416). Section ## 13 collision fixed: Plan 4 → ## 14, Аудит C сохраняется ## 13) |
|
||||
```
|
||||
|
||||
На:
|
||||
|
||||
```
|
||||
| Открытые вопросы | [docs/Открытые_вопросы_v8_3.md](docs/Открытые_вопросы_v8_3.md) (**v1.83 от 13.05.2026 (day, +1) — CTO-19 ✅ closed** через Lucide migration: `npm i lucide-vue-next` + custom Vuetify IconSet с 78-entry `mdi-*` → Lucide mapping. CLAUDE.md §2 «Иконки: Lucide» бренд-spec compliance achieved. Spec/plan в docs/superpowers/. **Сводка §0: 87 продуктовых / 71 ✅ / 5 🟦 / 11 ⏸ / 1 P0 + 5 P1 + 3 P2 + 2 P3**. v1.82 baseline — Catch-up bump v1.77 → v1.82. v1.77 — Sprint 4 «Audit tail» (Pest 421 / Vitest 416). Section ## 13 collision fixed: Plan 4 → ## 14, Аудит C ## 13) |
|
||||
```
|
||||
|
||||
**NB CLAUDE.md §5 п.10:** прямой Edit per approved plan rationale «registry version sync» (single-line update, не content authoring). Зафиксировать в commit message.
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Verification — full pre-commit lefthook + test baseline
|
||||
|
||||
**Files:** (verification only)
|
||||
|
||||
- [ ] **Step 7.1: Stage files**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация"
|
||||
git add app/package.json app/package-lock.json app/resources/js/plugins/vuetify.ts docs/Открытые_вопросы_v8_3.md CLAUDE.md
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: 5 файлов в staged area (M все 5).
|
||||
|
||||
- [ ] **Step 7.2: Full pre-commit hook run через `git commit`**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация"
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(icons): CTO-19 ✅ closed via Lucide migration (Vuetify custom IconSet)
|
||||
|
||||
Закрывает CTO-19 ⏸ из реестра v1.79 — иконочная система портала
|
||||
была не подключена (`@mdi/font` отсутствовал в `package.json`,
|
||||
все `mdi-*` рендерились пустыми).
|
||||
|
||||
PATH α (aliases-only, approved заказчиком 13.05.2026 через brainstorming):
|
||||
— `npm i lucide-vue-next` (~25-30 KB gzip tree-shakable bundle)
|
||||
— `app/resources/js/plugins/vuetify.ts`: +icons config c `liderraLucideSet`
|
||||
custom IconSet и 78-entry `lucideMap` (`mdi-plus` → Plus, `mdi-magnify`
|
||||
→ Search, `mdi-bell-outline` → Bell, ... всего 78 entries)
|
||||
— Fallback `HelpCircle` для unmapped
|
||||
— 51 Vue/TS файл с `icon="mdi-*"` НЕ touched — mdi-имена работают как
|
||||
semantic-ID для Lucide рендеринга
|
||||
|
||||
CLAUDE.md §2 «Иконки: Lucide» — бренд-spec compliance achieved.
|
||||
|
||||
Реестр:
|
||||
— CTO-19 ⏸ → ✅ closed (явное «закрываем» от заказчика на Task 4)
|
||||
— Сводка §0 CTO: 17✅/1⏸ → 18✅/0⏸
|
||||
— Сводка §0 Итого: 70✅/12⏸ → 71✅/11⏸
|
||||
— Header v1.82 → v1.83 + новый changelog block
|
||||
— Footer v1.83 (match header)
|
||||
|
||||
CLAUDE.md §0 row «Открытые вопросы» — v1.82 → v1.83 sync. Прямой
|
||||
Edit per approved plan rationale «registry version sync», не content
|
||||
authoring (CLAUDE.md §5 п.10).
|
||||
|
||||
Spec: docs/superpowers/specs/2026-05-13-cto-19-lucide-icon-migration-design.md
|
||||
Plan: docs/superpowers/plans/2026-05-13-cto-19-lucide-icon-migration.md
|
||||
|
||||
Path (i) `npm i @mdi/font` REJECTED (250 KB CSS, против бренда).
|
||||
Path β rename all `mdi-*` strings REJECTED (большой diff 51 файл).
|
||||
|
||||
Регрессия зелёная:
|
||||
— Pest --parallel 742/739/0/3 ✅ (после --recreate-databases)
|
||||
— Vitest 88 files / 683 passed + 3 skipped (baseline match)
|
||||
— Vite build exit 0
|
||||
— lychee 248 OK / 0 errors
|
||||
— gitleaks 0 leaks
|
||||
|
||||
Memory updates (отдельный шаг, git-untracked):
|
||||
— reference_archive.md description (registry v1.82 → v1.83)
|
||||
— MEMORY.md index line 7 sync
|
||||
|
||||
Через `superpowers:brainstorming` → `superpowers:writing-plans` →
|
||||
`superpowers:executing-plans` + ручной Edit (vuetify.ts / реестр).
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Expected: lefthook prints `gitleaks ✅ + markdownlint ✅ + cspell ✅` (possibly +eslint-vue/larastan если staged JS/TS файлы). Exit 0.
|
||||
|
||||
Если cspell упадёт на новых словах (например, lucide component names в комментариях) — добавить в `cspell-words.txt`, re-stage, re-commit.
|
||||
|
||||
Если eslint-vue упадёт на vuetify.ts unused import — удалить unused, re-commit.
|
||||
|
||||
Если markdownlint упадёт на registry edit — auto-fix или manual fix per output, re-commit.
|
||||
|
||||
- [ ] **Step 7.3: Post-commit verification**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация"
|
||||
git log -1 --stat
|
||||
git status
|
||||
```
|
||||
|
||||
Expected: commit visible, working tree clean.
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Push + pre-push hooks
|
||||
|
||||
**Files:** (push only)
|
||||
|
||||
- [ ] **Step 8.1: Push to origin**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация"
|
||||
git push origin plan5-frontend-projects 2>&1
|
||||
```
|
||||
|
||||
Expected: lefthook pre-push prints `gitleaks-full-history ✅` (369 commits / +1 from this push) + `lychee-links ✅` (~248 OK). Push success: `9bc0419..<new-sha> plan5-frontend-projects -> plan5-frontend-projects`.
|
||||
|
||||
- [ ] **Step 8.2: Verify remote sync**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация"
|
||||
git rev-parse HEAD
|
||||
git rev-parse origin/plan5-frontend-projects
|
||||
git rev-list --left-right --count plan5-frontend-projects...origin/main
|
||||
```
|
||||
|
||||
Expected: local HEAD == remote HEAD. Counters: 116 ahead / 0 behind (или similar +1 от 115).
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Memory updates (post-push, git-untracked)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `C:\Users\Administrator\.claude\projects\c---------------------crm-------------\memory\reference_archive.md`
|
||||
- Modify: `C:\Users\Administrator\.claude\projects\c---------------------crm-------------\memory\MEMORY.md`
|
||||
|
||||
- [ ] **Step 9.1: Update reference_archive.md description**
|
||||
|
||||
Edit `memory/reference_archive.md`:
|
||||
|
||||
Заменить в description (line 3) — registry version reference:
|
||||
|
||||
```
|
||||
реестр **v1.82** (13.05.2026 day catch-up bump: v1.77→v1.82 после hidden inconsistency header vs footer-trail v1.78/v1.79/v1.80/v1.81 + post-Plan-4 context; section ## 13 collision fix Plan 4 → ## 14; Сводка §0 counters update 78→87 продуктовых / 4→12 ⏸ / 3→5 P1 / 0→3 P2; Pravila §2.2 строго: Биз-25..31 + CTO-19 остаются ⏸)
|
||||
```
|
||||
|
||||
На:
|
||||
|
||||
```
|
||||
реестр **v1.83** (13.05.2026 day +1: CTO-19 ✅ closed через Lucide migration — `npm i lucide-vue-next` + Vuetify custom IconSet c 78-entry `mdi-*`→Lucide mapping; views untouched. CLAUDE.md §2 brand-spec compliance. Сводка §0: CTO 18✅/0⏸ / Итого 71✅/11⏸. v1.82 — catch-up bump v1.77→v1.82)
|
||||
```
|
||||
|
||||
- [ ] **Step 9.2: Update MEMORY.md index line 7**
|
||||
|
||||
Edit `memory/MEMORY.md` line 7 (reference_archive description):
|
||||
|
||||
Заменить:
|
||||
|
||||
```
|
||||
**реестр v1.82** (catch-up bump: v1.78 Plan 4 +7 Биз-25..31 ⏸ / v1.79 CTO-19 ⏸ / v1.80-v1.81 CTO-20 cycle / v1.82 post-Plan-4 context; section ## 13 collision fix Plan 4→## 14; Сводка §0 87 продуктовых / 70 ✅ / 12 ⏸)
|
||||
```
|
||||
|
||||
На:
|
||||
|
||||
```
|
||||
**реестр v1.83** (13.05.2026 day +1: CTO-19 ✅ closed via Lucide migration; Сводка §0 87 продуктовых / 71 ✅ / 11 ⏸; CLAUDE.md §2 brand-spec compliance achieved; v1.82 baseline — catch-up bump)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Final verification — invoke superpowers:verification-before-completion
|
||||
|
||||
**Files:** (gating verification only)
|
||||
|
||||
- [ ] **Step 10.1: Invoke skill**
|
||||
|
||||
Invoke `superpowers:verification-before-completion` через Skill tool.
|
||||
|
||||
- [ ] **Step 10.2: Run fresh verification suite**
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация"
|
||||
echo "=== HEAD sync ===" && git rev-parse HEAD && git rev-parse origin/plan5-frontend-projects
|
||||
echo "=== ahead/behind ===" && git rev-list --left-right --count plan5-frontend-projects...origin/main
|
||||
echo "=== registry version ===" && head -5 docs/Открытые_вопросы_v8_3.md | grep Версия | head -c 100
|
||||
echo "=== footer version ===" && tail -5 docs/Открытые_вопросы_v8_3.md | grep "^\*Версия" | head -c 100
|
||||
echo "=== Сводка Итого ===" && grep "Итого продуктовых" docs/Открытые_вопросы_v8_3.md | head -c 200
|
||||
echo "=== CTO-19 status ===" && grep -A1 "CTO-19" docs/Открытые_вопросы_v8_3.md | head -3
|
||||
echo "=== CLAUDE.md ref ===" && grep "Открытые_вопросы_v8_3.md" CLAUDE.md | head -c 200
|
||||
echo "=== vuetify.ts has IconSet ===" && grep -c "liderraLucideSet" app/resources/js/plugins/vuetify.ts
|
||||
echo "=== lucide-vue-next installed ===" && grep -c "lucide-vue-next" app/package.json
|
||||
```
|
||||
|
||||
Expected (все pass):
|
||||
|
||||
- HEAD == remote HEAD
|
||||
- 116 ahead / 0 behind
|
||||
- header v1.83 + footer v1.83 match
|
||||
- Итого продуктовых: 87 / 71 ✅ / 5 🟦 / 11 ⏸
|
||||
- CTO-19 строка начинается с `✅ **CTO-19**`
|
||||
- CLAUDE.md row содержит «v1.83»
|
||||
- vuetify.ts grep `liderraLucideSet` count: 2+ (один в definition, один в `sets:`)
|
||||
- package.json grep `lucide-vue-next` count: 1+
|
||||
|
||||
- [ ] **Step 10.3: Final report to user**
|
||||
|
||||
Report (per Pravila §3.2) — concise summary:
|
||||
|
||||
```
|
||||
CTO-19 ✅ closed via Lucide migration. Реестр v1.82 → v1.83.
|
||||
|
||||
— vuetify.ts: liderraLucideSet IconSet с 78-entry mdi-* → Lucide map
|
||||
— bundle: +~25-30 KB gzip (tree-shaken Lucide selective imports)
|
||||
— views untouched (51 файл с icon="mdi-*" invariant)
|
||||
— регрессия: Pest 742/Vitest 683/Vite OK/lychee 248 OK/gitleaks 0
|
||||
— Сводка §0: CTO 18/18✅/0⏸; Итого 87/71✅/11⏸
|
||||
|
||||
Branch HEAD: <new-sha> (116 ahead / 0 behind origin/main).
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
Per writing-plans skill — quick fresh-eyes check после written plan.
|
||||
|
||||
**1. Spec coverage:** Spec §2.1-2.5 (architecture) → Task 1+2. Spec §3 (bundle) → Task 1.3 baseline + Task 2.5 build. Spec §4 (testing) → Task 2.4 + Task 3 + Task 10.2. Spec §5 (risks) → addressed inline (Step 2.4 hypothesis falsification, Step 7.2 cspell/eslint fallback). Spec §6 (closure plan) → Task 4 + Task 5 + Task 6 + Task 9. ✅ All sections covered.
|
||||
|
||||
**2. Placeholder scan:** Lucide component names в Step 2.2 import — все listed; mapping table 78 entries — все listed. No TBDs. Commit messages — fully drafted. ✅
|
||||
|
||||
**3. Type consistency:** `liderraLucideSet` (Step 2.2 definition) → used в `sets: { liderra: liderraLucideSet }` (Step 2.2 createVuetify call) → verified in Task 10.2 grep (count 2+). ✅
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-05-13-cto-19-lucide-icon-migration.md`. Two execution options:
|
||||
|
||||
**1. Subagent-Driven (recommended)** — `superpowers:subagent-driven-development` dispatches fresh subagent per task, review between tasks, fast iteration.
|
||||
|
||||
**2. Inline Execution** — `superpowers:executing-plans` execute tasks in this session с batch checkpoints для review.
|
||||
|
||||
**Which approach?**
|
||||
@@ -1,313 +0,0 @@
|
||||
# Test Quality: Fix Pest P1 Quirks (#62 + #72) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Fix 4 failing Pest P1 tests — quirk #62 (3 sequential failures: `LookupsTest` + `ProjectExtensionsTest` see accumulated DB state) and quirk #72 (1 parallel failure: `CsvReconcileJobTest` Redis session race).
|
||||
|
||||
**Architecture:** Two independent fixes, two commits. **Quirk #62:** one-line addition to `app/composer.json` scripts — `@php artisan migrate:fresh --env=testing` before `@php artisan test`, ensuring `liderra_testing` starts clean on every sequential run. **Quirk #72:** narrow `Bus::fake([RouteSupplierLeadJob::class])` in `CsvReconcileJobTest.php` (was `Bus::fake()` — ALL jobs) + bind an anonymous `RefreshSupplierSessionJob` mock that calls `putSupplierSession()` in its `handle()` — makes the race-window DB recovery deterministic instead of silently swallowing the session-null exception.
|
||||
|
||||
**Tech Stack:** PHP 8.3 + Laravel 13, Pest 4, Redis (Memurai localhost:6379), PostgreSQL 16 (`liderra_testing` DB), `DatabaseTransactions` trait (all Feature tests).
|
||||
|
||||
**Reference:** Audit #2 findings Phase 2 P1-01 (quirk #62) + P1-02 (quirk #72). Root-cause notes in `memory/feedback_environment.md` quirks #62 and #72.
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
- **Modify:** `app/composer.json` — `scripts.test` array, add `migrate:fresh` step
|
||||
- **Modify:** `app/tests/Feature/Supplier/CsvReconcileJobTest.php` — `beforeEach` block + use-import
|
||||
|
||||
---
|
||||
|
||||
## Root Cause Summary (read before touching code)
|
||||
|
||||
### Quirk #62 — stale DB across `composer test` sessions
|
||||
|
||||
`composer test` runs `php artisan test` (sequential) with **no** `migrate:fresh`. `DatabaseTransactions` rolls back within each test but not if a session crashes mid-test or leaves orphaned data. After multiple sessions, tables like `users`, `projects` accumulate rows that persist between runs.
|
||||
|
||||
`LookupsTest.php:31` expects `2` active managers for a fresh tenant but reads 1465 (prior runs). `ProjectExtensionsTest.php:89` expects `Project::activeOnDay(1)->count() === 1` but RLS sees rows from prior tenants whose context leaked via `SET app.current_tenant_id` (not `SET LOCAL`).
|
||||
|
||||
**Fix:** add `@php artisan migrate:fresh --env=testing @no_additional_args` before `@php artisan test` in `composer.json`. This is identical in spirit to what `composer test:parallel` already does (`--recreate-databases`). Adds ~3s per run — acceptable.
|
||||
|
||||
### Quirk #72 — Redis session wiped between `putSupplierSession()` and SUT read
|
||||
|
||||
In `--parallel` mode, all workers share Redis DB+prefix. `CsvReconcileJobTest::beforeEach` calls `putSupplierSession()` (sets `supplier:session`). But `SyncSupplierProjectsJobTest::afterEach:36`, `CleanupInactiveSupplierProjectsJobTest::afterEach:30`, and `SupplierSessionRefreshCommandTest::afterEach:18` all call `Cache::store('redis')->forget('supplier:session')`. A concurrent `afterEach` can wipe the key between our `put` and the SUT's `loadSession()` read.
|
||||
|
||||
When the key is missing, `SupplierPortalClient::loadSession()` calls `dispatch_sync(app(RefreshSupplierSessionJob::class))`. Under current `Bus::fake()` (all jobs), the `dispatch_sync` is silently swallowed — no `handle()` called, session stays null → `SupplierAuthException("Session refresh failed: cache still empty")`.
|
||||
|
||||
**Fix:** switch to `Bus::fake([RouteSupplierLeadJob::class])` (only fake the job we assert on) + bind an anonymous mock for `RefreshSupplierSessionJob` that calls `putSupplierSession()` in `handle()`. Now if the race fires, `dispatch_sync($mock)` is NOT intercepted by the partial fake, the real dispatcher calls `$mock->handle()` → session re-seeded → `loadSession()` succeeds.
|
||||
|
||||
---
|
||||
|
||||
## Task A: Fix quirk #62 — `migrate:fresh` in `composer test`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/composer.json` (lines 58–61)
|
||||
|
||||
- [ ] **A.1: Apply the fix**
|
||||
|
||||
In `app/composer.json`, change:
|
||||
|
||||
```json
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi @no_additional_args",
|
||||
"@php artisan test"
|
||||
],
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```json
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi @no_additional_args",
|
||||
"@php artisan migrate:fresh --env=testing @no_additional_args",
|
||||
"@php artisan test"
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **A.2: Run `composer test` — first pass**
|
||||
|
||||
Run (cwd = `app/`):
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация/app" && composer test
|
||||
```
|
||||
|
||||
Expected: migration output (`Dropped all tables...`, `Running migrations...`, `0 errors`) then test run `742 passed` (0 failed).
|
||||
|
||||
If failures appear: read the failure output carefully — they must be unrelated to quirk #62 (which would be `LookupsTest` / `ProjectExtensionsTest`). Fix before continuing.
|
||||
|
||||
- [ ] **A.3: Run `composer test` — second pass (regression guard)**
|
||||
|
||||
Run immediately after A.2 with no other DB changes:
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация/app" && composer test
|
||||
```
|
||||
|
||||
Expected: same `742 passed` — proves `migrate:fresh` prevents accumulation on repeated runs.
|
||||
|
||||
- [ ] **A.4: Commit**
|
||||
|
||||
```bash
|
||||
git add "c:/моя/проекты/портал crm/Документация/app/composer.json"
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(tests): add migrate:fresh to composer test — close quirk #62
|
||||
|
||||
Sequential `composer test` had no DB reset between sessions; data accumulated
|
||||
in liderra_testing across runs, causing LookupsTest (expected 2 managers, got
|
||||
1465) and ProjectExtensionsTest (activeOnDay count inflated) to fail.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Expected: lefthook pre-commit passes (gitleaks 0 / markdownlint 0 / cspell 0). Show full pre-commit output.
|
||||
|
||||
---
|
||||
|
||||
## Task B: Fix quirk #72 — partial `Bus::fake` + session mock in `CsvReconcileJobTest`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/tests/Feature/Supplier/CsvReconcileJobTest.php` (lines 1–57)
|
||||
|
||||
- [ ] **B.1: Add the use-import for `RefreshSupplierSessionJob`**
|
||||
|
||||
Current import block (lines 1–22) ends at:
|
||||
|
||||
```php
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
```
|
||||
|
||||
Add one line after it:
|
||||
|
||||
```php
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
```
|
||||
|
||||
File head after edit:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Exceptions\Supplier\SupplierTransientException;
|
||||
use App\Jobs\RouteSupplierLeadJob;
|
||||
use App\Jobs\Supplier\CsvReconcileJob;
|
||||
use App\Jobs\Supplier\RefreshSupplierSessionJob;
|
||||
use App\Mail\CsvDriftAlertMail;
|
||||
use App\Models\SupplierLead;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\SupplierCsvParser;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\Concerns\SharesSupplierPdo;
|
||||
```
|
||||
|
||||
- [ ] **B.2: Replace `beforeEach` block**
|
||||
|
||||
Current `beforeEach` (lines 48–57):
|
||||
|
||||
```php
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
Bus::fake();
|
||||
// NB: NOT Cache::store('redis')->flush() — flush wipes session keys belonging to
|
||||
// OTHER parallel tests (cross-pollution). Just forget our reserved keys + re-put.
|
||||
Cache::store('redis')->forget('supplier:csv_reconcile');
|
||||
putSupplierSession();
|
||||
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
|
||||
config(['services.supplier.alert_email' => 'ops@liderra.ru']);
|
||||
});
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```php
|
||||
beforeEach(function () {
|
||||
Mail::fake();
|
||||
// Partial fake: only RouteSupplierLeadJob is intercepted (what we assert on).
|
||||
// RefreshSupplierSessionJob must NOT be faked — it must run our mock below
|
||||
// so that loadSession() can recover if a concurrent afterEach wipes the session.
|
||||
Bus::fake([RouteSupplierLeadJob::class]);
|
||||
// Bind a mock that re-puts the session when dispatch_sync triggers it during a race.
|
||||
// handle() has no params — no DI injection needed.
|
||||
app()->bind(RefreshSupplierSessionJob::class, fn () => new class {
|
||||
public function handle(): void
|
||||
{
|
||||
putSupplierSession();
|
||||
}
|
||||
});
|
||||
// NB: NOT Cache::store('redis')->flush() — flush wipes session keys belonging to
|
||||
// OTHER parallel tests (cross-pollution). Just forget our reserved keys + re-put.
|
||||
Cache::store('redis')->forget('supplier:csv_reconcile');
|
||||
putSupplierSession();
|
||||
config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']);
|
||||
config(['services.supplier.alert_email' => 'ops@liderra.ru']);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **B.3: Verify assertions are still correct**
|
||||
|
||||
The two assertions in the file must still work:
|
||||
|
||||
- Line 116 (test "matches existing leads"): `Bus::assertNothingDispatched();`
|
||||
With `Bus::fake([RouteSupplierLeadJob::class])`, `assertNothingDispatched()` checks only faked jobs (RouteSupplierLeadJob). If no drift → no RouteJob dispatched → ✅.
|
||||
|
||||
- Line 157 (test "drift 10%"): `Bus::assertDispatched(RouteSupplierLeadJob::class, 1);`
|
||||
RouteSupplierLeadJob IS faked → intercepted + recorded → `assertDispatched` passes ✅.
|
||||
|
||||
No changes needed to assertions. If you see `BusFake::assertNothingDispatched()` failing, it means a `RouteSupplierLeadJob` was unexpectedly dispatched — investigate the SUT, not the test setup.
|
||||
|
||||
- [ ] **B.4: Run `composer test:parallel` — first pass**
|
||||
|
||||
Run (cwd = project root Документация/):
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация" && composer --working-dir=app test:parallel
|
||||
```
|
||||
|
||||
Expected: `742 passed, 3 skipped` (0 failed). The 3 skipped are the pre-existing `@skip` marks unrelated to this fix.
|
||||
|
||||
If `CsvReconcileJobTest` still fails: run in isolation to diagnose:
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация/app" && php vendor/bin/pest tests/Feature/Supplier/CsvReconcileJobTest.php --parallel -v
|
||||
```
|
||||
|
||||
Check: (1) Is the session key wiped? (2) Is `RefreshSupplierSessionJob::handle()` being called? Add temporary `\Log::debug('mock handle called');` and check `storage/logs/laravel.log`.
|
||||
|
||||
- [ ] **B.5: Run `composer test:parallel` — two more times (race guard)**
|
||||
|
||||
Run twice more in quick succession:
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация/app" && composer test:parallel && composer test:parallel
|
||||
```
|
||||
|
||||
Expected: both pass `742/739/3sk`. If either fails on `CsvReconcileJobTest`, the race window is still open — escalate to user with timing details.
|
||||
|
||||
- [ ] **B.6: Commit**
|
||||
|
||||
```bash
|
||||
git add "c:/моя/проекты/портал crm/Документация/app/tests/Feature/Supplier/CsvReconcileJobTest.php"
|
||||
git commit -m "$(cat <<'EOF'
|
||||
fix(tests): Bus::fake partial + session mock — close quirk #72
|
||||
|
||||
CsvReconcileJobTest used Bus::fake() (all jobs), silencing dispatch_sync of
|
||||
RefreshSupplierSessionJob when a parallel afterEach wiped supplier:session.
|
||||
Now: Bus::fake([RouteSupplierLeadJob::class]) + anonymous mock that re-puts
|
||||
the session in handle(), making race-window recovery deterministic.
|
||||
|
||||
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
Expected: lefthook pre-commit passes. Show full output.
|
||||
|
||||
---
|
||||
|
||||
## Task C: Pre-push + Push
|
||||
|
||||
- [ ] **C.1: Run lefthook pre-push**
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация" && npx lefthook run pre-push
|
||||
```
|
||||
|
||||
Expected: gitleaks 0 leaks / lychee 0 broken. Show full output.
|
||||
|
||||
If gitleaks fails → inspect with `./bin/gitleaks.exe detect` to identify the offending commit. Do NOT bypass with `--no-verify`.
|
||||
|
||||
If lychee fails → fix the broken link in the commit that introduced it, make a new fixup commit.
|
||||
|
||||
- [ ] **C.2: Push**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Expected: exit 0. Show full output.
|
||||
|
||||
- [ ] **C.3: Verify remote HEAD**
|
||||
|
||||
```bash
|
||||
git log origin/main --oneline -5
|
||||
```
|
||||
|
||||
Expected: top 2 commits are Task A commit (`fix(tests): add migrate:fresh`) and Task B commit (`fix(tests): Bus::fake partial`).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
| Requirement | Coverage |
|
||||
|---|---|
|
||||
| Quirk #62: 3 sequential P1 tests fixed | Task A — `migrate:fresh` added to `composer test` ✅ |
|
||||
| Quirk #72: 1 parallel P1 test fixed | Task B — partial fake + session mock ✅ |
|
||||
| `composer test` passes (first + second run) | A.2 + A.3 ✅ |
|
||||
| `composer test:parallel` passes (×3 runs) | B.4 + B.5 ✅ |
|
||||
| `Bus::assertNothingDispatched()` still works | B.3 analysis ✅ |
|
||||
| `Bus::assertDispatched(RouteSupplierLeadJob)` still works | B.3 analysis ✅ |
|
||||
| No `Bus::fake()` silently hiding `RefreshSupplierSessionJob` | B.2 partial fake ✅ |
|
||||
| pre-commit clean | A.4 + B.6 (show output) ✅ |
|
||||
| pre-push clean | C.1 (show output) ✅ |
|
||||
| No `--no-verify` / hook bypass | rule enforced throughout ✅ |
|
||||
| No PD / tokens in commits | composer.json + test file — no sensitive data ✅ |
|
||||
| Two atomic commits (one per quirk) | A.4 + B.6 ✅ |
|
||||
|
||||
**Placeholder scan:** no TBD/TODO/«implement later». All steps contain concrete code.
|
||||
|
||||
**Type consistency:** `RefreshSupplierSessionJob` import added at B.1 before use at B.2. `RouteSupplierLeadJob` already imported at line 6. Anonymous mock `handle(): void` matches `RefreshSupplierSessionJob::handle(PlaywrightBridge $bridge): void` signature — DI injection not triggered because `handle()` has no params in mock.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,685 +0,0 @@
|
||||
# Q.DEFER.003 sub-B + sub-C — Coverage Debt Closure Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development for execution (user picked Subagent-Driven precedent). Steps use checkbox (`- [ ]`) syntax.
|
||||
|
||||
**Goal:** Закрыть Q.DEFER.003 sub-B (security UI cards 28-0% coverage) + sub-C (router guards 33% Stmts / 7% Funcs coverage) добавлением own-spec файлов для 3 карточек и расширением router.spec.ts integration scenarios.
|
||||
|
||||
**Architecture:** TDD per task. Vitest @vue/test-utils mount + Pinia + Vuetify plugins, `vi.mock('../../resources/js/api/auth')` для изоляции от HTTP. Router tests расширяют existing router.spec.ts (62 lines, 5 tests) integration cases на beforeEach guard branches (authenticated user → guestOnly redirect / passing requiresAuth / catch-all 404 / admin redirect).
|
||||
|
||||
**Tech Stack:** Vue 3.5 + Vuetify 3.12 + Vue Router 4.6 + Pinia 3.0 + Vitest 4.1 + @vue/test-utils + vi.mock.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create (test files):**
|
||||
|
||||
- `app/tests/Frontend/ChangePasswordCard.spec.ts` — мин ~3 specs (static placeholder)
|
||||
- `app/tests/Frontend/RecoveryCodesCard.spec.ts` — ~6-8 specs (dialog flow + auth-gated visibility)
|
||||
- `app/tests/Frontend/TwoFactorCard.spec.ts` — ~7-10 specs (setup wizard 3-step + disable flow + error states)
|
||||
|
||||
**Modify (test):**
|
||||
|
||||
- `app/tests/Frontend/router.spec.ts` — добавить 5 integration tests на guard branches
|
||||
|
||||
**Modify (memory):**
|
||||
|
||||
- `memory/MEMORY.md` — sync project_state line с new test count
|
||||
- `memory/project_full_audit_2026-05-12.md` — Q.DEFER.003 closure status
|
||||
|
||||
**NO production code changes** — pure test additions.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: ChangePasswordCard.spec.ts (placeholder coverage)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/tests/Frontend/ChangePasswordCard.spec.ts`
|
||||
|
||||
Component `app/resources/js/components/settings/security/ChangePasswordCard.vue` — 17 lines, чистый static placeholder: `<v-card>` с заголовком «Пароль» + description «Последняя смена: 12.04.2026 (26 дней назад)» + `<v-btn>` «Сменить пароль» без логики.
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
import ChangePasswordCard from '../../resources/js/components/settings/security/ChangePasswordCard.vue';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
describe('ChangePasswordCard (Q.DEFER.003 sub-B)', () => {
|
||||
const factory = () => mount(ChangePasswordCard, { global: { plugins: [vuetify] } });
|
||||
|
||||
it('renders heading «Пароль»', () => {
|
||||
const wrapper = factory();
|
||||
expect(wrapper.find('h3').text()).toBe('Пароль');
|
||||
});
|
||||
|
||||
it('shows last-change hint text', () => {
|
||||
const wrapper = factory();
|
||||
expect(wrapper.text()).toContain('Последняя смена: 12.04.2026');
|
||||
expect(wrapper.text()).toContain('26 дней назад');
|
||||
});
|
||||
|
||||
it('renders «Сменить пароль» button with lock-reset icon', () => {
|
||||
const wrapper = factory();
|
||||
const btn = wrapper.find('button');
|
||||
expect(btn.exists()).toBe(true);
|
||||
expect(btn.text()).toContain('Сменить пароль');
|
||||
// prepend-icon mdi-lock-reset → check Vuetify renders i.mdi-lock-reset
|
||||
expect(wrapper.find('.mdi-lock-reset').exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/ChangePasswordCard.spec.ts --reporter=verbose`
|
||||
Expected: 3 PASS (compoent already exists — spec is coverage-only, no implementation change).
|
||||
|
||||
If FAIL — investigate (icon class might differ — check Vuetify rendering).
|
||||
|
||||
- [ ] **Step 3: Run full Vitest для regression check**
|
||||
|
||||
Run: `cd app && npx vitest run 2>&1 | tail -10`
|
||||
Expected: 86 files (85 → +1) / 663 passed (660 → +3) / 3 skipped / 0 failed.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/tests/Frontend/ChangePasswordCard.spec.ts
|
||||
git commit -m "test(security): Q.DEFER.003 sub-B — ChangePasswordCard 3 own-spec tests
|
||||
|
||||
Placeholder card (17 lines, static UI) — add minimal coverage for heading,
|
||||
last-change hint, and button rendering. Closes coverage debt от 0% Stmts."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: RecoveryCodesCard.spec.ts
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/tests/Frontend/RecoveryCodesCard.spec.ts`
|
||||
|
||||
Component `app/resources/js/components/settings/security/RecoveryCodesCard.vue:13-87` — auth-gated card: visible только при `auth.user?.totp_enabled === true`; имеет dialog flow regenerate с password input + API call + display new 8 codes.
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
vi.mock('../../resources/js/api/auth', () => ({
|
||||
twoFactorRegenerateRecoveryCodes: vi.fn(),
|
||||
}));
|
||||
|
||||
import RecoveryCodesCard from '../../resources/js/components/settings/security/RecoveryCodesCard.vue';
|
||||
import * as authApi from '../../resources/js/api/auth';
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
describe('RecoveryCodesCard (Q.DEFER.003 sub-B)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const factory = (totpEnabled: boolean) => {
|
||||
const wrapper = mount(RecoveryCodesCard, {
|
||||
global: { plugins: [createPinia(), vuetify], stubs: { VDialog: true } },
|
||||
});
|
||||
// Set auth state after mount
|
||||
const auth = useAuthStore();
|
||||
auth.user = {
|
||||
id: 1,
|
||||
email: 'test@demo.local',
|
||||
full_name: 'Test User',
|
||||
totp_enabled: totpEnabled,
|
||||
timezone: 'UTC',
|
||||
} as unknown as ReturnType<typeof useAuthStore>['user'];
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
it('does NOT render button when 2FA disabled (auth.user.totp_enabled=false)', () => {
|
||||
const wrapper = factory(false);
|
||||
expect(wrapper.find('[data-testid="regen-codes-btn"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('renders button when 2FA enabled', async () => {
|
||||
const wrapper = factory(true);
|
||||
await flushPromises();
|
||||
const btn = wrapper.find('[data-testid="regen-codes-btn"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
expect(btn.text()).toContain('Перегенерировать резервные коды');
|
||||
});
|
||||
|
||||
it('clicking regen-codes-btn opens dialog (sets regenOpen)', async () => {
|
||||
const wrapper = factory(true);
|
||||
await flushPromises();
|
||||
await wrapper.find('[data-testid="regen-codes-btn"]').trigger('click');
|
||||
// VDialog is stubbed — check internal state via component instance
|
||||
expect((wrapper.vm as unknown as { regenOpen: boolean }).regenOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('confirmRegen() with valid password fetches 8 codes and clears password', async () => {
|
||||
const newCodes = ['AAAA-1111', 'BBBB-2222', 'CCCC-3333', 'DDDD-4444', 'EEEE-5555', 'FFFF-6666', 'GGGG-7777', 'HHHH-8888'];
|
||||
(authApi.twoFactorRegenerateRecoveryCodes as ReturnType<typeof vi.fn>).mockResolvedValue({ recovery_codes: newCodes });
|
||||
const wrapper = factory(true);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
regenOpen: boolean;
|
||||
regenPassword: string;
|
||||
regenCodes: string[];
|
||||
regenError: string;
|
||||
confirmRegen: () => Promise<void>;
|
||||
};
|
||||
vm.regenOpen = true;
|
||||
vm.regenPassword = 'correctPass';
|
||||
await vm.confirmRegen();
|
||||
await flushPromises();
|
||||
expect(vm.regenCodes).toEqual(newCodes);
|
||||
expect(vm.regenPassword).toBe('');
|
||||
expect(vm.regenError).toBe('');
|
||||
expect(authApi.twoFactorRegenerateRecoveryCodes).toHaveBeenCalledWith('correctPass');
|
||||
});
|
||||
|
||||
it('confirmRegen() with invalid password sets error and keeps regenCodes empty', async () => {
|
||||
(authApi.twoFactorRegenerateRecoveryCodes as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('401'));
|
||||
const wrapper = factory(true);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
regenPassword: string;
|
||||
regenCodes: string[];
|
||||
regenError: string;
|
||||
confirmRegen: () => Promise<void>;
|
||||
};
|
||||
vm.regenPassword = 'wrongPass';
|
||||
await vm.confirmRegen();
|
||||
await flushPromises();
|
||||
expect(vm.regenError).toBe('Неверный пароль.');
|
||||
expect(vm.regenCodes).toEqual([]);
|
||||
});
|
||||
|
||||
it('closeRegen() clears codes and closes dialog', async () => {
|
||||
const wrapper = factory(true);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as {
|
||||
regenOpen: boolean;
|
||||
regenCodes: string[];
|
||||
closeRegen: () => void;
|
||||
};
|
||||
vm.regenOpen = true;
|
||||
vm.regenCodes = ['AAAA-1111'];
|
||||
vm.closeRegen();
|
||||
expect(vm.regenOpen).toBe(false);
|
||||
expect(vm.regenCodes).toEqual([]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it passes**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/RecoveryCodesCard.spec.ts --reporter=verbose`
|
||||
Expected: 6 PASS.
|
||||
|
||||
If a test fails — investigate. Possible issues:
|
||||
|
||||
- `VDialog` stub might not be необходим (Vuetify тёлепортит overlay в `document.body`); use stubs config from memory Plan 5 frontend learning.
|
||||
- `auth.user` typing — может potребоваться `as any` cast.
|
||||
|
||||
- [ ] **Step 3: Full Vitest regression**
|
||||
|
||||
Run: `cd app && npx vitest run 2>&1 | tail -10`
|
||||
Expected: 87 files / 669 passed / 3 skipped / 0 failed (663 + 6).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/tests/Frontend/RecoveryCodesCard.spec.ts
|
||||
git commit -m "test(security): Q.DEFER.003 sub-B — RecoveryCodesCard 6 own-spec tests
|
||||
|
||||
Coverage uplift от 28% to 70%+ (auth-gated visibility / dialog flow /
|
||||
confirmRegen success+error / closeRegen reset). vi.mock authApi
|
||||
для изоляции; VDialog stub'аем для DOM unit-test (избегаем teleport)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: TwoFactorCard.spec.ts
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/tests/Frontend/TwoFactorCard.spec.ts`
|
||||
|
||||
Component `app/resources/js/components/settings/security/TwoFactorCard.vue:1-218` — самый сложный: 3-step setup wizard (`setupStep: 'init' | 'confirm' | 'codes'`) с API calls `twoFactorInit` + `twoFactorConfirm`; отдельный disable flow с password.
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { createPinia, setActivePinia } from 'pinia';
|
||||
import { createVuetify } from 'vuetify';
|
||||
|
||||
vi.mock('../../resources/js/api/auth', () => ({
|
||||
twoFactorInit: vi.fn(),
|
||||
twoFactorConfirm: vi.fn(),
|
||||
twoFactorDisable: vi.fn(),
|
||||
}));
|
||||
|
||||
import TwoFactorCard from '../../resources/js/components/settings/security/TwoFactorCard.vue';
|
||||
import * as authApi from '../../resources/js/api/auth';
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
|
||||
const vuetify = createVuetify();
|
||||
|
||||
type CardVm = {
|
||||
setupOpen: boolean;
|
||||
setupStep: 'init' | 'confirm' | 'codes';
|
||||
setupSecret: string;
|
||||
setupQrUrl: string;
|
||||
setupCode: string;
|
||||
setupRecoveryCodes: string[];
|
||||
setupError: string;
|
||||
setupBusy: boolean;
|
||||
disableOpen: boolean;
|
||||
disablePassword: string;
|
||||
disableError: string;
|
||||
openSetup: () => Promise<void>;
|
||||
confirmSetup: () => Promise<void>;
|
||||
closeSetup: () => void;
|
||||
confirmDisable: () => Promise<void>;
|
||||
};
|
||||
|
||||
describe('TwoFactorCard (Q.DEFER.003 sub-B)', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const factory = (totpEnabled: boolean) => {
|
||||
const wrapper = mount(TwoFactorCard, {
|
||||
global: { plugins: [createPinia(), vuetify], stubs: { VDialog: true } },
|
||||
});
|
||||
const auth = useAuthStore();
|
||||
auth.user = {
|
||||
id: 1,
|
||||
email: 'test@demo.local',
|
||||
full_name: 'Test User',
|
||||
totp_enabled: totpEnabled,
|
||||
timezone: 'UTC',
|
||||
} as unknown as ReturnType<typeof useAuthStore>['user'];
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
it('shows «Включить 2FA» button when 2FA disabled', async () => {
|
||||
const wrapper = factory(false);
|
||||
await flushPromises();
|
||||
const btn = wrapper.find('[data-testid="enable-2fa-btn"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
expect(btn.text()).toContain('Включить 2FA');
|
||||
});
|
||||
|
||||
it('shows «Отключить 2FA» button when 2FA enabled', async () => {
|
||||
const wrapper = factory(true);
|
||||
await flushPromises();
|
||||
const btn = wrapper.find('[data-testid="disable-2fa-btn"]');
|
||||
expect(btn.exists()).toBe(true);
|
||||
expect(btn.text()).toContain('Отключить 2FA');
|
||||
});
|
||||
|
||||
it('shows «включена» chip when 2FA enabled', async () => {
|
||||
const wrapper = factory(true);
|
||||
await flushPromises();
|
||||
expect(wrapper.text()).toContain('включена');
|
||||
});
|
||||
|
||||
it('openSetup() initializes wizard and transitions init→confirm with QR secret', async () => {
|
||||
(authApi.twoFactorInit as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
secret: 'JBSWY3DPEHPK3PXP',
|
||||
qr_url: 'otpauth://totp/test',
|
||||
});
|
||||
const wrapper = factory(false);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as CardVm;
|
||||
await vm.openSetup();
|
||||
await flushPromises();
|
||||
expect(vm.setupOpen).toBe(true);
|
||||
expect(vm.setupStep).toBe('confirm');
|
||||
expect(vm.setupSecret).toBe('JBSWY3DPEHPK3PXP');
|
||||
expect(vm.setupQrUrl).toBe('otpauth://totp/test');
|
||||
expect(authApi.twoFactorInit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('confirmSetup() valid code → step=codes + 8 recovery codes + auth.totp_enabled=true', async () => {
|
||||
const codes = ['A1', 'A2', 'A3', 'A4', 'A5', 'A6', 'A7', 'A8'];
|
||||
(authApi.twoFactorConfirm as ReturnType<typeof vi.fn>).mockResolvedValue({ recovery_codes: codes });
|
||||
const wrapper = factory(false);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as CardVm;
|
||||
vm.setupCode = '123456';
|
||||
await vm.confirmSetup();
|
||||
await flushPromises();
|
||||
expect(vm.setupStep).toBe('codes');
|
||||
expect(vm.setupRecoveryCodes).toEqual(codes);
|
||||
const auth = useAuthStore();
|
||||
expect(auth.user?.totp_enabled).toBe(true);
|
||||
expect(authApi.twoFactorConfirm).toHaveBeenCalledWith('123456');
|
||||
});
|
||||
|
||||
it('confirmSetup() invalid code → setupError set, step stays confirm', async () => {
|
||||
(authApi.twoFactorConfirm as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('422'));
|
||||
const wrapper = factory(false);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as CardVm;
|
||||
vm.setupStep = 'confirm';
|
||||
vm.setupCode = '999999';
|
||||
await vm.confirmSetup();
|
||||
await flushPromises();
|
||||
expect(vm.setupError).toBe('Неверный код. Проверьте время на устройстве.');
|
||||
expect(vm.setupStep).toBe('confirm');
|
||||
});
|
||||
|
||||
it('closeSetup() resets all setup state', async () => {
|
||||
const wrapper = factory(false);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as CardVm;
|
||||
vm.setupOpen = true;
|
||||
vm.setupSecret = 'SECRET';
|
||||
vm.setupQrUrl = 'url';
|
||||
vm.setupCode = '123';
|
||||
vm.closeSetup();
|
||||
expect(vm.setupOpen).toBe(false);
|
||||
expect(vm.setupSecret).toBe('');
|
||||
expect(vm.setupQrUrl).toBe('');
|
||||
expect(vm.setupCode).toBe('');
|
||||
});
|
||||
|
||||
it('confirmDisable() valid password → auth.totp_enabled=false + dialog closes', async () => {
|
||||
(authApi.twoFactorDisable as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
|
||||
const wrapper = factory(true);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as CardVm;
|
||||
vm.disableOpen = true;
|
||||
vm.disablePassword = 'correctPass';
|
||||
await vm.confirmDisable();
|
||||
await flushPromises();
|
||||
const auth = useAuthStore();
|
||||
expect(auth.user?.totp_enabled).toBe(false);
|
||||
expect(vm.disableOpen).toBe(false);
|
||||
expect(vm.disablePassword).toBe('');
|
||||
expect(authApi.twoFactorDisable).toHaveBeenCalledWith('correctPass');
|
||||
});
|
||||
|
||||
it('confirmDisable() invalid password → disableError set, totp stays enabled', async () => {
|
||||
(authApi.twoFactorDisable as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('401'));
|
||||
const wrapper = factory(true);
|
||||
await flushPromises();
|
||||
const vm = wrapper.vm as unknown as CardVm;
|
||||
vm.disablePassword = 'wrongPass';
|
||||
await vm.confirmDisable();
|
||||
await flushPromises();
|
||||
expect(vm.disableError).toBe('Неверный пароль.');
|
||||
const auth = useAuthStore();
|
||||
expect(auth.user?.totp_enabled).toBe(true);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/TwoFactorCard.spec.ts --reporter=verbose`
|
||||
Expected: 9 PASS.
|
||||
|
||||
- [ ] **Step 3: Full Vitest regression**
|
||||
|
||||
Run: `cd app && npx vitest run 2>&1 | tail -10`
|
||||
Expected: 88 files / 678 passed / 3 skipped / 0 failed (669 + 9).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/tests/Frontend/TwoFactorCard.spec.ts
|
||||
git commit -m "test(security): Q.DEFER.003 sub-B — TwoFactorCard 9 own-spec tests
|
||||
|
||||
Coverage uplift от 28% to 80%+: enable button visibility / disable button
|
||||
visibility / chip status / setup wizard openSetup→confirm→codes / invalid
|
||||
code error / disable flow valid+invalid password / closeSetup state reset.
|
||||
vi.mock authApi для 3 endpoint'ов (init/confirm/disable)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: router.spec.ts integration tests (sub-C)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/tests/Frontend/router.spec.ts:62` — append 5 integration tests.
|
||||
|
||||
Existing 5 tests verify route definition. Add tests на guard branches (lines 283-302 of router/index.ts):
|
||||
|
||||
- `to.meta.requiresAuth && !auth.isAuthenticated` → redirect /login (existing)
|
||||
- `to.meta.guestOnly && auth.isAuthenticated` → redirect /dashboard (NEW)
|
||||
- requiresAuth + authenticated → passes (NEW)
|
||||
- catch-all `/:pathMatch(.*)*` → 404 (NEW)
|
||||
- /admin → redirect /admin/tenants (NEW)
|
||||
- /reset/:token → resolves with token param (NEW)
|
||||
|
||||
- [ ] **Step 1: Augment router.spec.ts**
|
||||
|
||||
Edit `app/tests/Frontend/router.spec.ts` — изменить existing `vi.mock` для `me()` (теперь conditional rejection), append 5 tests after existing «гость, идущий на /dashboard» (line 61).
|
||||
|
||||
```typescript
|
||||
// Existing vi.mock — refactor to support both resolved and rejected mock per test
|
||||
let meResolves = false;
|
||||
let meResolvedUser: { id: number; email: string; full_name: string; totp_enabled: boolean; timezone: string } | null = null;
|
||||
|
||||
vi.mock('../../resources/js/api/auth', () => ({
|
||||
me: vi.fn(() => {
|
||||
if (meResolves && meResolvedUser) {
|
||||
return Promise.resolve({ user: meResolvedUser });
|
||||
}
|
||||
return Promise.reject(new Error('not authenticated'));
|
||||
}),
|
||||
login: vi.fn(),
|
||||
register: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}));
|
||||
```
|
||||
|
||||
И добавить tests после line 61:
|
||||
|
||||
```typescript
|
||||
it('authenticated user attempting /login (guestOnly) → redirected to /dashboard', async () => {
|
||||
meResolves = true;
|
||||
meResolvedUser = { id: 1, email: 'test@demo.local', full_name: 'Test', totp_enabled: false, timezone: 'UTC' };
|
||||
// Force re-init authInitialized — workaround: reset by calling fetchMe through guard
|
||||
// (router module-level authInitialized flag persists between tests; new router instance
|
||||
// requires module re-import. Use direct push instead.)
|
||||
await router.push('/login');
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard');
|
||||
});
|
||||
|
||||
it('authenticated user navigating to /dashboard passes requiresAuth guard', async () => {
|
||||
meResolves = true;
|
||||
meResolvedUser = { id: 1, email: 'test@demo.local', full_name: 'Test', totp_enabled: false, timezone: 'UTC' };
|
||||
await router.push('/dashboard');
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.path).toBe('/dashboard');
|
||||
expect(router.currentRoute.value.name).toBe('dashboard');
|
||||
});
|
||||
|
||||
it('unknown path /no-such-path resolves to 404 ErrorView', async () => {
|
||||
await router.push('/no-such-path-xyz');
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.name).toBe('not-found');
|
||||
expect(router.currentRoute.value.meta.errorCode).toBe('404');
|
||||
expect(router.currentRoute.value.meta.layout).toBe('error');
|
||||
});
|
||||
|
||||
it('/admin redirects to /admin/tenants', async () => {
|
||||
meResolves = true;
|
||||
meResolvedUser = { id: 1, email: 'test@demo.local', full_name: 'Test', totp_enabled: false, timezone: 'UTC' };
|
||||
await router.push('/admin');
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.path).toBe('/admin/tenants');
|
||||
expect(router.currentRoute.value.name).toBe('admin-tenants');
|
||||
});
|
||||
|
||||
it('/reset/:token resolves with token param exposed', async () => {
|
||||
await router.push('/reset/abc123-token-xyz');
|
||||
await router.isReady();
|
||||
expect(router.currentRoute.value.name).toBe('reset-password');
|
||||
expect(router.currentRoute.value.params.token).toBe('abc123-token-xyz');
|
||||
});
|
||||
```
|
||||
|
||||
**IMPORTANT NOTE:** `authInitialized` flag — module-level in router/index.ts:281; once `fetchMe()` ran on first router.push, it's `true` для всех subsequent tests. This means subsequent tests' auth state must be modified via direct `auth.user = {...}` mutation, NOT через meResolves flag (which only fires first time). Refactor test setup if needed: import `useAuthStore` and set state directly in beforeEach for authenticated-user tests.
|
||||
|
||||
Adjusted approach (more robust):
|
||||
|
||||
```typescript
|
||||
import { useAuthStore } from '../../resources/js/stores/auth';
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
// Note: authInitialized module-level flag persists; first test will trigger fetchMe.
|
||||
// For tests requiring authenticated state, set auth.user directly after setActivePinia.
|
||||
});
|
||||
|
||||
// In authenticated tests:
|
||||
const auth = useAuthStore();
|
||||
auth.user = { id: 1, email: 'test@demo.local', ... } as unknown as ReturnType<typeof useAuthStore>['user'];
|
||||
```
|
||||
|
||||
This bypasses the fetchMe path. May need to combine с meResolves для first test (which still triggers fetchMe).
|
||||
|
||||
- [ ] **Step 2: Run target spec**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/router.spec.ts --reporter=verbose`
|
||||
Expected: 10 PASS (5 existing + 5 new). Если fail на «authenticated user → /dashboard» — investigate authInitialized flag persistence.
|
||||
|
||||
- [ ] **Step 3: Full Vitest regression**
|
||||
|
||||
Run: `cd app && npx vitest run 2>&1 | tail -10`
|
||||
Expected: 88 files / 683 passed / 3 skipped / 0 failed (678 + 5).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add app/tests/Frontend/router.spec.ts
|
||||
git commit -m "test(router): Q.DEFER.003 sub-C — 5 integration tests for guard branches
|
||||
|
||||
Coverage uplift router/index.ts от 33% Stmts / 7% Funcs к ~85% Funcs:
|
||||
- authenticated /login (guestOnly) → /dashboard redirect
|
||||
- authenticated /dashboard passes requiresAuth
|
||||
- /no-such-path → 404 catch-all
|
||||
- /admin → /admin/tenants redirect
|
||||
- /reset/:token param exposure
|
||||
|
||||
Refactored vi.mock me() для conditional resolve/reject per test."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Coverage verification (optional sanity check)
|
||||
|
||||
**Files:** read-only. Output — Vitest --coverage report.
|
||||
|
||||
- [ ] **Step 1: Run Vitest --coverage**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run --coverage 2>&1 | tail -40
|
||||
```
|
||||
|
||||
Expected: coverage report showing:
|
||||
|
||||
- `ChangePasswordCard.vue`: 100% (single button + h3)
|
||||
- `RecoveryCodesCard.vue`: 70%+ (от 28%)
|
||||
- `TwoFactorCard.vue`: 80%+ (от 28%)
|
||||
- `router/index.ts` Funcs: 80%+ (от 7%)
|
||||
|
||||
Если coverage не уходит вверх — investigate test files правильно exercise functions.
|
||||
|
||||
- [ ] **Step 2: Document in plan**
|
||||
|
||||
Append actual coverage delta numbers в этот файл (или в blocked.md если open Q-item) для closure status.
|
||||
|
||||
- [ ] **Step 3: NO COMMIT** — pure verification.
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Memory + push (closure)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `memory/MEMORY.md`
|
||||
- Modify: `memory/project_full_audit_2026-05-12.md` (Q.DEFER.003 sub-B+C closure)
|
||||
|
||||
- [ ] **Step 1: Update MEMORY.md project_state description**
|
||||
|
||||
Replace `Vitest 85 files / 660/3 skipped` line с new count после Tasks 1-4 (88 files / 683 passed / 3 skipped).
|
||||
|
||||
- [ ] **Step 2: Update Q.DEFER.003 status в project_full_audit memory**
|
||||
|
||||
Add closure line после existing Q.DEFER.003 mention: "✅ sub-B+C CLOSED 13.05.2026 day — security cards (Change/Recovery/TwoFactor) + router integration tests added."
|
||||
|
||||
- [ ] **Step 3: Update blocked.md Q.DEFER.003 section (audit file)**
|
||||
|
||||
Modify `docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md` — sub-B status «Sub-B (security cards 28%) ... remain DEFERRED» → «✅ sub-B+C CLOSED 13.05.2026 day (commits TBD)».
|
||||
|
||||
- [ ] **Step 4: Pre-push hooks + push**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md
|
||||
git commit -m "docs(audit): Q.DEFER.003 sub-B+C CLOSED 13.05.2026 — security cards + router guards"
|
||||
git push origin plan5-frontend-projects
|
||||
```
|
||||
|
||||
Expected hooks: gitleaks-full-history 0 leaks; lychee 0 errors; pint/larastan skip (no PHP changes); eslint-vue skip (only .spec.ts staged — check if eslint runs on test files); pest skip (no PHP changes).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist
|
||||
|
||||
- [ ] Spec coverage: 3 security cards + router — все 4 task'а закрыты?
|
||||
- [ ] No regression: Vitest baseline preserved (660 + 23 new = 683).
|
||||
- [ ] No production code changed — pure test additions + memory + docs.
|
||||
- [ ] vue-tsc clean (no new type errors).
|
||||
- [ ] ESLint clean (test files conform to existing patterns).
|
||||
- [ ] Commits атомарные: 1 task = 1 commit (4 spec commits + 1 docs commit).
|
||||
|
||||
---
|
||||
|
||||
## Известные риски
|
||||
|
||||
1. **Pinia store reactivity в Vitest:** v-mock auth-store через direct `auth.user = {...}` assignment может не trigger reactive watchers. Workaround: use `setActivePinia(createPinia())` в `beforeEach`, mount component AFTER setting auth state. Если test fails с «button не reactive» — switch to mounting AFTER auth state set.
|
||||
|
||||
2. **`authInitialized` module-level flag** в router/index.ts:281 — persistent между tests в same module. Может cause test order dependence. Workaround: для tests требующих re-init, либо direct `auth.user = ...` (bypass fetchMe), либо принять что first test fires fetchMe и last 4 don't.
|
||||
|
||||
3. **VDialog stub** (per memory Plan 5 frontend learning) — `stubs: { VDialog: true }` нужен чтобы dialog content рендерился inline без teleport. Если test fails on `wrapper.find('[data-testid="regen-dialog"]')` — добавить stub.
|
||||
|
||||
4. **Vuetify плагины в test setup** — autoImport может потребоваться. Existing AdminSupplierPricesView.spec.ts line 13 использует `createVuetify()` без auto-import — verify это работает для VBtn/VCard/VDialog/VTextField/VSwitch.
|
||||
|
||||
5. **TwoFactorCard `auth.user = { ...auth.user, totp_enabled: true }`** (lines 56, 80) — Pinia state mutation pattern. Tests должны verify через `useAuthStore().user?.totp_enabled` после async confirmSetup/confirmDisable.
|
||||
|
||||
---
|
||||
|
||||
## Estimated effort
|
||||
|
||||
- Task 1 (ChangePasswordCard): 15 мин (placeholder, тривиальный)
|
||||
- Task 2 (RecoveryCodesCard): 30 мин (dialog flow + auth-gated)
|
||||
- Task 3 (TwoFactorCard): 45 мин (самый сложный — 3-step wizard + disable)
|
||||
- Task 4 (router integration): 30 мин (vi.mock refactor + 5 tests)
|
||||
- Task 5 (coverage verify): 10 мин
|
||||
- Task 6 (memory + push): 10 мин
|
||||
|
||||
**Total: ~2 часа 20 минут.**
|
||||
@@ -1,793 +0,0 @@
|
||||
# Q.DEFER.004 — A11y residual patterns 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:** Закрыть три a11y-паттерна, оставшиеся после Q.DEFER.002 sub-B batch-фикса: (A) DealsTable VDataTable `show-select` 6 bulk-checkbox без aria-label, (B) AdminSupplierPricesView 9 form-input без aria-label, (C) v-tooltip eager-mount artifact (документировать, не код).
|
||||
|
||||
**Architecture:** Используем Vuetify 3.12 typed slots `header.data-table-select` + `item.data-table-select` (DealsTable) и `:aria-label` binding на v-text-field/v-switch (AdminSupplierPricesView). Vitest specs проверяют наличие aria-label через DOM query; Playwright + axe-core CDN inject верифицирует **0 violations** на live-страницах. Для (C) — документирующее изменение в `docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md` и `findings.md`.
|
||||
|
||||
**Tech Stack:** Vue 3.5 + Vuetify 3.12 + TypeScript + Vitest 4.1 + @vue/test-utils + Playwright MCP + axe-core 4.10 (CDN inject).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Modify (production code):**
|
||||
|
||||
- `app/resources/js/components/deals/DealsTable.vue` — добавить два slot-override для bulk-checkboxes с aria-label.
|
||||
- `app/resources/js/views/admin/AdminSupplierPricesView.vue` — добавить `:aria-label` на 3× v-text-field + 3× v-switch + 3× cost_rub v-text-field (по факту это 3 input × 3 supplier rows = 9 элементов).
|
||||
|
||||
**Modify (test code):**
|
||||
|
||||
- `app/tests/Frontend/AdminSupplierPricesView.spec.ts` — добавить spec на проверку aria-label на 9 элементах.
|
||||
- `app/tests/Frontend/DealsTable.spec.ts` — **новый файл**, проверка bulk-checkbox aria-label через `show-select` slot override.
|
||||
|
||||
**Modify (docs):**
|
||||
|
||||
- `docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md` — Q.DEFER.004 пометить closed (A + B fixed, C documented as known Vuetify-internal limitation).
|
||||
- `docs/superpowers/audits/2026-05-12-portal-full-audit-findings.md` — закрыть finding в Phase rollup.
|
||||
|
||||
**Memory:**
|
||||
|
||||
- `memory/project_full_audit_2026-05-12.md` — обновить description + content (Q.DEFER.004 closure status).
|
||||
- `memory/MEMORY.md` — sync description строки.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: DealsTable show-select aria-label slots
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/deals/DealsTable.vue:54-74` (vue template — VDataTable + новые slots между `@click:row` и закрывающим `</v-data-table>`).
|
||||
- Create: `app/tests/Frontend/DealsTable.spec.ts`.
|
||||
|
||||
- [ ] **Step 1: Write failing test** (Vitest spec, mount DealsTable, проверить bulk-checkbox aria-label).
|
||||
|
||||
Создать `app/tests/Frontend/DealsTable.spec.ts`:
|
||||
|
||||
```typescript
|
||||
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';
|
||||
|
||||
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: 2,
|
||||
name: 'Петров П.',
|
||||
phone: '+7 (916) 100-00-02',
|
||||
statusSlug: 'new',
|
||||
project: 'B1 call',
|
||||
manager: { initials: 'AD', name: 'Admin' },
|
||||
cost: 1500,
|
||||
receivedMinutesAgo: 30,
|
||||
},
|
||||
];
|
||||
|
||||
describe('DealsTable a11y (Q.DEFER.004 sub-A)', () => {
|
||||
it('select-all header checkbox has aria-label', () => {
|
||||
const wrapper = 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);
|
||||
});
|
||||
|
||||
it('each row checkbox has aria-label referencing deal name', () => {
|
||||
const wrapper = 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);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/DealsTable.spec.ts --reporter=verbose`
|
||||
Expected: 2 FAIL — selectors not found (default Vuetify-rendered checkboxes без aria-label).
|
||||
|
||||
- [ ] **Step 3: Add VDataTable slots override в DealsTable.vue**
|
||||
|
||||
Edit `app/resources/js/components/deals/DealsTable.vue` — между line 124 (`</template>` для `item.receivedMinutesAgo`) и line 125 (`</v-data-table>`):
|
||||
|
||||
```vue
|
||||
<template #header.data-table-select="{ allSelected, selectAll, someSelected }">
|
||||
<v-checkbox-btn
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected && !allSelected"
|
||||
aria-label="Выбрать все сделки"
|
||||
@update:model-value="selectAll"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template
|
||||
#item.data-table-select="{ isSelected, toggleSelect, internalItem, item }: { isSelected: (i: unknown) => boolean; toggleSelect: (i: unknown) => void; internalItem: unknown; item: MockDeal }"
|
||||
>
|
||||
<v-checkbox-btn
|
||||
:model-value="isSelected(internalItem)"
|
||||
:aria-label="`Выбрать сделку «${item.name}»`"
|
||||
@update:model-value="toggleSelect(internalItem)"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run vue-tsc to verify slot signatures type-check**
|
||||
|
||||
Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json 2>&1 | head -30`
|
||||
Expected: 0 errors related to DealsTable.vue (baseline 0).
|
||||
|
||||
- [ ] **Step 5: Run Vitest to verify a11y tests pass**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/DealsTable.spec.ts --reporter=verbose`
|
||||
Expected: 2 PASS.
|
||||
|
||||
- [ ] **Step 6: Run full Vitest to verify no regression**
|
||||
|
||||
Run: `cd app && npx vitest run --reporter=default 2>&1 | tail -30`
|
||||
Expected: 85 files / 659 passed (+2) / 3 skipped / 0 failed.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/deals/DealsTable.vue app/tests/Frontend/DealsTable.spec.ts
|
||||
git commit -m "fix(a11y): Q.DEFER.004 sub-A — DealsTable show-select bulk-checkbox aria-label
|
||||
|
||||
VDataTable show-select prop генерировал unlabeled checkbox per row + select-all
|
||||
header — axe-core критичная label violation (6 nodes на demo seed).
|
||||
|
||||
Override через Vuetify 3.12 typed slots:
|
||||
- header.data-table-select → aria-label='Выбрать все сделки'
|
||||
- item.data-table-select → aria-label='Выбрать сделку «{{name}}»' (per row)
|
||||
|
||||
Test coverage: tests/Frontend/DealsTable.spec.ts (2 specs)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: AdminSupplierPricesView form inputs aria-label
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/admin/AdminSupplierPricesView.vue:6-31` (3 templates с v-text-field/v-switch).
|
||||
- Modify: `app/tests/Frontend/AdminSupplierPricesView.spec.ts` (добавить 4-й spec).
|
||||
|
||||
- [ ] **Step 1: Write failing test** (расширить существующий spec).
|
||||
|
||||
Edit `app/tests/Frontend/AdminSupplierPricesView.spec.ts` — добавить после строки 60:
|
||||
|
||||
```typescript
|
||||
it('each input/switch has explicit aria-label combining supplier name + field role', async () => {
|
||||
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
// 3 suppliers × 3 fields = 9 controls
|
||||
const expectedLabels = [
|
||||
'Cost (₽) для B1 — Сайты и Звонки',
|
||||
'Quality для B1 — Сайты и Звонки',
|
||||
'Active для B1 — Сайты и Звонки',
|
||||
'Cost (₽) для B2 — SMS',
|
||||
'Quality для B2 — SMS',
|
||||
'Active для B2 — SMS',
|
||||
'Cost (₽) для B3 — SMS',
|
||||
'Quality для B3 — SMS',
|
||||
'Active для B3 — SMS',
|
||||
];
|
||||
for (const label of expectedLabels) {
|
||||
const node = wrapper.find(`[aria-label="${label}"]`);
|
||||
expect(node.exists(), `aria-label="${label}" not found`).toBe(true);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/AdminSupplierPricesView.spec.ts --reporter=verbose`
|
||||
Expected: PASS×3 (existing) + FAIL×1 (new — aria-label not present).
|
||||
|
||||
- [ ] **Step 3: Apply :aria-label на 3 templates в AdminSupplierPricesView.vue**
|
||||
|
||||
Edit `app/resources/js/views/admin/AdminSupplierPricesView.vue` — `<template #[`item.cost_rub`]>`:
|
||||
|
||||
```vue
|
||||
<template #[`item.cost_rub`]="{ item }">
|
||||
<v-text-field
|
||||
v-model="item.cost_rub"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="plain"
|
||||
:aria-label="`Cost (₽) для ${item.name}`"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
Same pattern для `item.quality_score`:
|
||||
|
||||
```vue
|
||||
<template #[`item.quality_score`]="{ item }">
|
||||
<v-text-field
|
||||
v-model="item.quality_score"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="9.99"
|
||||
density="compact"
|
||||
hide-details
|
||||
variant="plain"
|
||||
:aria-label="`Quality для ${item.name}`"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
И `item.is_active`:
|
||||
|
||||
```vue
|
||||
<template #[`item.is_active`]="{ item }">
|
||||
<v-switch
|
||||
v-model="item.is_active"
|
||||
hide-details
|
||||
inset
|
||||
density="compact"
|
||||
:aria-label="`Active для ${item.name}`"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run Vitest to verify a11y test passes**
|
||||
|
||||
Run: `cd app && npx vitest run tests/Frontend/AdminSupplierPricesView.spec.ts --reporter=verbose`
|
||||
Expected: 4 PASS, 0 FAIL.
|
||||
|
||||
- [ ] **Step 5: Run full Vitest to verify no regression**
|
||||
|
||||
Run: `cd app && npx vitest run --reporter=default 2>&1 | tail -20`
|
||||
Expected: 85 files / 660 passed (+1) / 3 skipped / 0 failed.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/admin/AdminSupplierPricesView.vue app/tests/Frontend/AdminSupplierPricesView.spec.ts
|
||||
git commit -m "fix(a11y): Q.DEFER.004 sub-B — AdminSupplierPricesView 9 inputs aria-label
|
||||
|
||||
3 supplier rows × 3 form controls (cost_rub v-text-field +
|
||||
quality_score v-text-field + is_active v-switch) = 9 nodes без label —
|
||||
axe-core критичная label violation.
|
||||
|
||||
Fix: :aria-label='\${field} для \${supplier.name}' (e.g. 'Cost (₽) для B1 — Сайты и Звонки').
|
||||
|
||||
Test coverage: AdminSupplierPricesView.spec.ts 4-й spec проверяет все 9 ожидаемых
|
||||
aria-label через DOM query."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: v-tooltip artifact — документирующее изменение
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md:179-198` (Q.DEFER.004 заголовок + sub-C documentation).
|
||||
- Modify: `docs/superpowers/audits/2026-05-12-portal-full-audit-findings.md` (closure note в Phase rollup, если есть).
|
||||
|
||||
- [ ] **Step 1: Verify через Playwright MCP + axe-core, что v-tooltip artifact реально only-library-level**
|
||||
|
||||
Сценарий: запустить dev server (если не запущен), перейти на /admin/tenants авторизованным, заинжектить axe-core CDN, запустить scan, проверить что aria-tooltip-name violation указывает только на Vuetify-internal `<div role="tooltip">` без пользовательского контента и что у соответствующего button уже есть aria-label.
|
||||
|
||||
Если запуск Playwright недоступен (servers off) — пропустить шаг, опираться на findings из Q.DEFER.002 sub-B (там уже задокументировано: "button already has aria-label → effective a11y покрытие OK").
|
||||
|
||||
- [ ] **Step 2: Обновить blocked.md Q.DEFER.004 status**
|
||||
|
||||
Edit `docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md` — заменить заголовок секции `### Q.DEFER.004` блоком:
|
||||
|
||||
```markdown
|
||||
### Q.DEFER.004 — ✅ CLOSED 2026-05-13 — A+B fixed via slots/aria-label, C documented as Vuetify-internal known limitation
|
||||
|
||||
**Closure summary (13.05.2026 day session):** sub-A + sub-B fixed (commits `<TBD-A>` + `<TBD-B>`). sub-C — Vuetify v-tooltip eager-mount artifact задокументирован как **known library-level limitation**, не application defect (button уже имеет explicit `aria-label`, effective a11y покрытие OK; axe-core complains только о Vuetify-internal `<div role="tooltip">` pre-emptively созданном без content).
|
||||
|
||||
**Sub-A details:** DealsTable.vue — Vuetify 3.12 typed slots `header.data-table-select` + `item.data-table-select` override; 6 bulk-checkboxes теперь имеют aria-label «Выбрать все сделки» + «Выбрать сделку «<name>»». Test coverage: tests/Frontend/DealsTable.spec.ts (2 specs).
|
||||
|
||||
**Sub-B details:** AdminSupplierPricesView.vue — `:aria-label` binding на 3× v-text-field (cost_rub) + 3× v-text-field (quality_score) + 3× v-switch (is_active) = 9 elements. Pattern: «<Field> для <supplier.name>» (e.g. «Cost (₽) для B1 — Сайты и Звонки»). Test coverage: AdminSupplierPricesView.spec.ts 4-й spec.
|
||||
|
||||
**Sub-C status:** Vuetify v-tooltip создаёт `<div role="tooltip" id="v-tooltip-v-N">` pre-emptively (до показа), без content. axe-core reports aria-tooltip-name serious violation. **Application-level mitigation уже на месте:** activator button (TenantsTable.vue:89) имеет `:aria-label="Войти как клиент (impersonation) для ${item.name}"`. Workaround «убрать v-tooltip и использовать HTML title» — теряем Vuetify styling + теряем tooltip activator behavior; не оправдано ради cosmetic violation. **Upstream fix tracking:** оставить как known issue до Vuetify 3.13+ (если содержит fix), либо до polish-sprint post-MVP.
|
||||
|
||||
---
|
||||
|
||||
(оригинальное содержание Q.DEFER.004 ниже — оставить как historical context)
|
||||
|
||||
**Из:** Q.DEFER.002 sub-B audit-pass 12.05.2026 ночь.
|
||||
...
|
||||
```
|
||||
|
||||
(полностью заменить только заголовок + closure summary, оставить historical context A/B/C body как был).
|
||||
|
||||
- [ ] **Step 3: Обновить findings.md (если у Q.DEFER.004 есть отдельная строка)**
|
||||
|
||||
Run: `grep -n "Q.DEFER.004" docs/superpowers/audits/2026-05-12-portal-full-audit-findings.md`
|
||||
|
||||
Если найдено — добавить статус-метку «✅ CLOSED 2026-05-13» рядом с упоминанием. Если нет — пропустить.
|
||||
|
||||
- [ ] **Step 4: Commit (документация)**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md docs/superpowers/audits/2026-05-12-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): Q.DEFER.004 CLOSED — sub-A+B fixed, sub-C documented as Vuetify-internal known limitation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Re-axe verify через Playwright + axe-core CDN inject
|
||||
|
||||
**Files:** read-only verification, не git-tracked. Артефакты — scrennshots в `audit-screens/q-defer-004/` опционально.
|
||||
|
||||
- [ ] **Step 1: Запустить dev server, авторизоваться, заинжектить axe-core**
|
||||
|
||||
Servers: Laravel `http://127.0.0.1:8000` + Vite Vite-port (5173+). Если уже работают — skip startup.
|
||||
|
||||
Через Playwright MCP:
|
||||
|
||||
1. `browser_navigate` → `http://127.0.0.1:8000/login`
|
||||
2. `browser_fill_form` → email=`admin@demo.local`, password=`password`
|
||||
3. `browser_click` → submit
|
||||
4. `browser_navigate` → `http://127.0.0.1:8000/deals`
|
||||
5. `browser_evaluate`:
|
||||
|
||||
```javascript
|
||||
await (async () => {
|
||||
const s = document.createElement('script');
|
||||
s.src = 'https://cdn.jsdelivr.net/npm/axe-core@4.10.0/axe.min.js';
|
||||
document.head.appendChild(s);
|
||||
await new Promise((r) => (s.onload = r));
|
||||
const r = await window.axe.run();
|
||||
return r.violations.filter((v) => v.id === 'label').map((v) => ({ nodes: v.nodes.length, target: v.nodes[0]?.target }));
|
||||
})();
|
||||
```
|
||||
|
||||
Expected: `label` violations on `/deals` related to data-table-select: **0** (после Task 1).
|
||||
|
||||
- [ ] **Step 2: Same scan на /admin/supplier-prices**
|
||||
|
||||
Через Playwright MCP:
|
||||
|
||||
1. `browser_navigate` → `http://127.0.0.1:8000/admin/supplier-prices`
|
||||
2. `browser_evaluate` (тот же axe.run() snippet).
|
||||
|
||||
Expected: `label` violations: **0** (после Task 2).
|
||||
|
||||
- [ ] **Step 3: Same scan на /admin/tenants для подтверждения v-tooltip status**
|
||||
|
||||
Через Playwright MCP:
|
||||
|
||||
1. `browser_navigate` → `http://127.0.0.1:8000/admin/tenants`
|
||||
2. `browser_evaluate` тот же snippet с фильтром `r.violations.filter((v) => v.id === 'aria-tooltip-name')`.
|
||||
|
||||
Expected: 1 violation **с подтверждением что target — Vuetify-internal `div[role="tooltip"]`**, button has aria-label (verifiable из same DOM). Это документирует sub-C — НЕ application defect.
|
||||
|
||||
- [ ] **Step 4: Записать результаты в blocked.md sub-C verification log**
|
||||
|
||||
Edit `docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md` Q.DEFER.004 — добавить блок «Verification 2026-05-13»:
|
||||
|
||||
```markdown
|
||||
**Verification 2026-05-13:** axe-core 4.10 CDN inject на 3 страницах:
|
||||
- `/deals` (post sub-A fix): 0 `label` violations on data-table-select ✅
|
||||
- `/admin/supplier-prices` (post sub-B fix): 0 `label` violations on form inputs ✅
|
||||
- `/admin/tenants` (sub-C): 1 `aria-tooltip-name` violation на Vuetify-internal `div[role="tooltip"]`, button[aria-label] активатор a11y-OK — documented limitation.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit verification log**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md
|
||||
git commit -m "docs(audit): Q.DEFER.004 — Playwright+axe-core verification 2026-05-13"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Memory + final verification
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `memory/project_full_audit_2026-05-12.md` (description + content).
|
||||
- Modify: `memory/MEMORY.md` (sync description line).
|
||||
|
||||
- [ ] **Step 1: Update memory project_full_audit_2026-05-12.md**
|
||||
|
||||
Add closure line for Q.DEFER.004 в content (преамбула / Q-items list):
|
||||
|
||||
```
|
||||
- Q.DEFER.004 ✅ CLOSED 2026-05-13 — sub-A (DealsTable show-select slots) + sub-B (AdminSupplierPricesView 9 inputs aria-label) fixed via Vuetify 3.12 slots + :aria-label binding. sub-C (v-tooltip eager-mount) documented as Vuetify-internal known limitation, button activator has aria-label.
|
||||
```
|
||||
|
||||
Update frontmatter `description` если есть «Q.DEFER.004 new» — заменить на «Q.DEFER.004 closed».
|
||||
|
||||
- [ ] **Step 2: Update MEMORY.md description line for that memory**
|
||||
|
||||
Run: `grep -n "project_full_audit_2026-05-12" memory/MEMORY.md`
|
||||
Edit ту строку — заменить «Q.DEFER.004 new» на «Q.DEFER.004 closed».
|
||||
|
||||
- [ ] **Step 3: Run full pre-commit suite for сanity**
|
||||
|
||||
```bash
|
||||
cd app && npx vitest run 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: 85 files / 660 passed / 3 skipped / 0 failed.
|
||||
|
||||
```bash
|
||||
cd app && composer test 2>&1 | tail -10
|
||||
```
|
||||
|
||||
Expected: Pest baseline preserved (742 / 736 sequential / 739 parallel / 3 skipped, no new failures).
|
||||
|
||||
- [ ] **Step 4: Pre-push hooks (lefthook)**
|
||||
|
||||
```bash
|
||||
lefthook run pre-push 2>&1 | tail -40
|
||||
```
|
||||
|
||||
Expected: gitleaks-full-history OK + lychee OK + 0 leaks.
|
||||
|
||||
- [ ] **Step 5: Push to origin/plan5-frontend-projects**
|
||||
|
||||
```bash
|
||||
git push origin plan5-frontend-projects
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Final commit summary**
|
||||
|
||||
Verify branch state:
|
||||
|
||||
```bash
|
||||
git log --oneline -10 plan5-frontend-projects
|
||||
git rev-list --count origin/main..plan5-frontend-projects
|
||||
```
|
||||
|
||||
Expected: 4+ new commits ahead beyond `a5e99ba`, branch tip = последний commit от Task 5.
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist (Self-Review)
|
||||
|
||||
После всех 5 Task — ручной checklist:
|
||||
|
||||
- [ ] **Spec coverage:** A (DealsTable 6 nodes) + B (9 inputs) + C (v-tooltip documented) — все три закрыты?
|
||||
- [ ] **No regression:** Pest 739/736 parallel/sequential preserved; Vitest baseline + 3 new specs; lychee 0 broken; gitleaks 0; markdownlint untracked-design.md only.
|
||||
- [ ] **Verified via live a11y scan:** Playwright + axe-core 4.10 на /deals + /admin/supplier-prices + /admin/tenants подтверждают 0 `label` violations на (A) + (B), 1 documented `aria-tooltip-name` на (C).
|
||||
- [ ] **Memory + Q-registry sync:** blocked.md Q.DEFER.004 ✅ CLOSED, memory project_full_audit_2026-05-12 updated.
|
||||
- [ ] **All commits pushed:** `git push origin plan5-frontend-projects` succeed; new tip recorded.
|
||||
|
||||
---
|
||||
|
||||
## Известные риски
|
||||
|
||||
1. **DealsTable.spec.ts mount без store:** компонент не имеет direct store dependency (только pure props), Pinia mount не требуется. Если test после первого запуска fails с «store undefined» — изучить Vuetify 3.12 selection state internals и при необходимости добавить `vuetify` plugin с явным config. **Не блокер.**
|
||||
2. **Vuetify 3.12 slot signature drift:** если `internalItem` API изменился между minor versions — vue-tsc Step 4 поймает; fallback — использовать только `item: MockDeal` + `isSelected: (item) => boolean` без `internalItem`. **Не блокер.**
|
||||
3. **Playwright servers (Task 4):** если 8000+5175 уже work — skip startup; если нет — запуск отдельной командой `php artisan serve` + `npm run dev` (background), затем продолжать. **Не блокер.**
|
||||
4. **C — Vuetify upstream:** если в Vuetify 3.13+ появится fix v-tooltip eager-mount — закрыть sub-C через `composer update vuetify` (не в скоупе этого плана).
|
||||
|
||||
---
|
||||
|
||||
## Estimated effort
|
||||
|
||||
- Task 1 (DealsTable slots): 25 мин
|
||||
- Task 2 (AdminSupplierPrices aria-label): 20 мин
|
||||
- Task 3 (v-tooltip documentation): 10 мин
|
||||
- Task 4 (Playwright + axe verify): 15 мин
|
||||
- Task 5 (memory + push): 10 мин
|
||||
|
||||
**Total: ~80 минут.**
|
||||
|
||||
---
|
||||
|
||||
## Addendum 2026-05-13 — Q.DEFER.004 part 2 (Vuetify aria deep workaround)
|
||||
|
||||
**Trigger:** Task 4 (commit `e79fe95`) выявил, что Tasks 1+2 source-fixes (`:aria-label` prop на `<v-checkbox-btn>` / `<v-text-field>` / `<v-switch>`) **не пробивают Vuetify 3.12 render layer** — native `<input>` остаётся без accessible name. Vitest tests 4/4 проходят в jsdom (видимо, jsdom не симулирует Vuetify VSelectionControl attrs split), но axe-core 4.10 в Chromium показывает 6+9=15 critical `label` violations.
|
||||
|
||||
**Decision (user 13.05.2026):** «Investigate Vuetify deep workarounds» — попробовать рабочий pattern.
|
||||
|
||||
**Hypothesized fix (highest probability):** Vuetify `<v-checkbox-btn>` / `<v-text-field>` / `<v-switch>` имеют prop `label` который генерирует реальный `<label for="${input.id}">${label}</label>` element. axe-core корректно ассоциирует через `<label for>` → input accessible name. CSS visually-hidden class (clip-path/position absolute) скрывает текстуально, но preserve в accessibility tree. Это стандартный screen-reader-only pattern.
|
||||
|
||||
**Alternative patterns if hypothesis fails:**
|
||||
|
||||
- Native `<input>` в slot override (bypass Vuetify rendering)
|
||||
- Explicit `<label class="sr-only" :for="id">` wrap (требует binding на auto-generated id)
|
||||
- aria-labelledby reference на existing row name cell (Cleanest semantically, но требует ID-binding)
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Investigation — Vuetify VSelectionControl aria forwarding
|
||||
|
||||
**Files:** read-only. Output: prototype/recommendation summary inline в этом плане ниже Task 6.
|
||||
|
||||
- [ ] **Step 1: Read Vuetify 3.12 v-checkbox-btn / v-text-field / v-switch implementation**
|
||||
|
||||
Через `mcp__plugin_context7_context7__query-docs` либо browsing Vuetify GitHub source `vuetifyjs/vuetify@v3.12.x`. Цель: verify что `label` prop производит `<label for="...">` element в shadow DOM. Document slot-architecture (VSelectionControl base for checkbox/switch).
|
||||
|
||||
- [ ] **Step 2: Prototype в minimal Vitest spec**
|
||||
|
||||
Создать temporary `app/tests/Frontend/_a11yProbe.spec.ts` (delete after Task 6 closes). Mount 3 variants:
|
||||
|
||||
- A: `<v-checkbox-btn label="Test" />` — verify `<label for="id">Test</label>` exists.
|
||||
- B: `<v-checkbox-btn aria-label="Test" />` (existing approach) — verify it does/doesn't reach input.
|
||||
- C: native `<input type="checkbox" aria-label="Test" />` (control).
|
||||
|
||||
Run via `cd app && npx vitest run tests/Frontend/_a11yProbe.spec.ts`.
|
||||
|
||||
- [ ] **Step 3: Live Playwright + axe prototype**
|
||||
|
||||
Servers уже работают. Через Playwright MCP открыть простую страницу с прототипом (можно использовать существующий /deals или /admin/supplier-prices с быстрым inline-change). Inject axe-core, проверить `label` violation статус для каждого варианта.
|
||||
|
||||
- [ ] **Step 4: Document findings + recommended pattern в этом файле**
|
||||
|
||||
Замени этот блок (lines starting от «#### Task 6 — Findings:») на actual results table + recommended pattern (`label` prop + sr-only CSS class).
|
||||
|
||||
#### Task 6 — Findings: (to be filled by subagent)
|
||||
|
||||
`<заполнить subagent'ом после Step 4>`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Re-fix DealsTable bulk-checkbox accessible name
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/deals/DealsTable.vue:127-141` (2 slot overrides).
|
||||
- Modify: `app/tests/Frontend/DealsTable.spec.ts:38-58` (test selectors — query `<label>` association, not `[aria-label]` attribute).
|
||||
|
||||
- [ ] **Step 1: Apply working pattern в slots**
|
||||
|
||||
Based на Task 6 рекомендацию, скорее всего:
|
||||
|
||||
```vue
|
||||
<template #header.data-table-select="{ allSelected, selectAll, someSelected }">
|
||||
<v-checkbox-btn
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected && !allSelected"
|
||||
label="Выбрать все сделки"
|
||||
class="sr-only-label"
|
||||
@update:model-value="selectAll"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #item.data-table-select="{ isSelected, toggleSelect, internalItem, item }">
|
||||
<v-checkbox-btn
|
||||
:model-value="isSelected(internalItem)"
|
||||
:label="`Выбрать сделку «${(item as MockDeal).name}»`"
|
||||
class="sr-only-label"
|
||||
@update:model-value="() => toggleSelect(internalItem)"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
И в `<style scoped>`:
|
||||
|
||||
```css
|
||||
.sr-only-label :deep(.v-label) {
|
||||
position: absolute !important;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update Vitest spec selectors на real-DOM accessibility check**
|
||||
|
||||
Заменить `wrapper.find('input[type="checkbox"][aria-label="..."]')` на query `<label>` association:
|
||||
|
||||
```typescript
|
||||
it('select-all header checkbox has accessible name via <label> association', () => {
|
||||
const wrapper = mount(...);
|
||||
const labels = wrapper.findAll('label');
|
||||
const headerLabel = labels.find((l) => l.text() === 'Выбрать все сделки');
|
||||
expect(headerLabel?.exists()).toBe(true);
|
||||
const forAttr = headerLabel?.attributes('for');
|
||||
expect(forAttr).toBeTruthy();
|
||||
const input = wrapper.find(`#${forAttr}`);
|
||||
expect(input.exists()).toBe(true);
|
||||
expect(input.element.tagName).toBe('INPUT');
|
||||
});
|
||||
```
|
||||
|
||||
(аналогично для row checkboxes).
|
||||
|
||||
- [ ] **Step 3: Run Vitest на DealsTable.spec.ts**
|
||||
|
||||
Expected 2 PASS (после update).
|
||||
|
||||
- [ ] **Step 4: Run full Vitest для no-regression check**
|
||||
|
||||
Expected: 85 / 660 / 3 / 0.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/components/deals/DealsTable.vue app/tests/Frontend/DealsTable.spec.ts
|
||||
git commit -m "fix(a11y): Q.DEFER.004 sub-A part 2 — DealsTable use Vuetify label prop + sr-only CSS
|
||||
|
||||
Task 4 verification revealed aria-label НЕ propagates to native <input> через
|
||||
v-checkbox-btn (Vuetify silently drops). Switch к Vuetify-native pattern:
|
||||
:label prop generates <label for=id> element + custom CSS visually-hides текст.
|
||||
|
||||
axe-core accessible-name теперь видит association через <label for>.
|
||||
|
||||
Tests updated к real-DOM accessibility check (label association, not aria-label attr)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Re-fix AdminSupplierPricesView accessible names
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/views/admin/AdminSupplierPricesView.vue` (3 templates — replace `:aria-label` с `:label` prop + add CSS class).
|
||||
- Modify: `app/tests/Frontend/AdminSupplierPricesView.spec.ts:62-81` (4-й spec — same DOM check update).
|
||||
|
||||
- [ ] **Step 1: Apply working pattern в 3 templates**
|
||||
|
||||
```vue
|
||||
<template #[`item.cost_rub`]="{ item }">
|
||||
<v-text-field
|
||||
v-model="item.cost_rub"
|
||||
type="number" step="0.01" min="0"
|
||||
density="compact" hide-details variant="plain"
|
||||
:label="`Cost (₽) для ${item.name}`"
|
||||
class="sr-only-label"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
И аналогично для `quality_score` (label="Quality для ${name}") + `is_active` (v-switch label="Active для ${name}").
|
||||
|
||||
CSS блок (`<style scoped>`):
|
||||
|
||||
```css
|
||||
.sr-only-label :deep(.v-label),
|
||||
.sr-only-label :deep(.v-field-label) {
|
||||
position: absolute !important;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
```
|
||||
|
||||
(Двойной селектор — `.v-label` для v-switch + `.v-field-label` для v-text-field; точные имена verify в Task 6 prototype.)
|
||||
|
||||
- [ ] **Step 2: Update Vitest spec на real-DOM accessibility check**
|
||||
|
||||
Аналогично Task 7 Step 2 — query `<label>` element с text contents + verify `for` attribute matches input ID.
|
||||
|
||||
```typescript
|
||||
it('each input/switch has accessible name via <label> association', async () => {
|
||||
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
const expectedTexts = [
|
||||
'Cost (₽) для B1 — Сайты и Звонки',
|
||||
// ... 9 total
|
||||
];
|
||||
for (const text of expectedTexts) {
|
||||
const labels = wrapper.findAll('label');
|
||||
const found = labels.find((l) => l.text() === text);
|
||||
expect(found?.exists(), `label with text "${text}" not found`).toBe(true);
|
||||
const forAttr = found?.attributes('for');
|
||||
expect(forAttr).toBeTruthy();
|
||||
const input = wrapper.find(`#${forAttr}`);
|
||||
expect(input.exists(), `input #${forAttr} for "${text}" not found`).toBe(true);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run target spec**
|
||||
|
||||
Expected: 4 PASS.
|
||||
|
||||
- [ ] **Step 4: Full Vitest**
|
||||
|
||||
Expected: 85 / 660 / 3 / 0.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add app/resources/js/views/admin/AdminSupplierPricesView.vue app/tests/Frontend/AdminSupplierPricesView.spec.ts
|
||||
git commit -m "fix(a11y): Q.DEFER.004 sub-B part 2 — AdminSupplierPrices use Vuetify label prop + sr-only CSS
|
||||
|
||||
Task 4 verification revealed :aria-label НЕ propagates to native <input> через
|
||||
v-text-field / v-switch. Switch к Vuetify-native :label prop: generates real <label
|
||||
for=id> element, ассоциация преобразуется в accessible name на input через axe-core.
|
||||
|
||||
CSS sr-only hides текст visually (variant='plain' density='compact' preserve).
|
||||
|
||||
Tests updated к real-DOM accessibility check (label association, not aria-label attr)."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Re-verify через Playwright + axe-core
|
||||
|
||||
Same protocol as original Task 4, focus только на `/deals` + `/admin/supplier-prices`.
|
||||
|
||||
- [ ] **Step 1: Playwright MCP — login → /deals → axe scan**
|
||||
|
||||
Expected: `labelViolations: []` (или filter out DevIndexOverlay false-positives `[dev-only DevIndexOverlay]`).
|
||||
|
||||
- [ ] **Step 2: → /admin/supplier-prices → axe scan**
|
||||
|
||||
Expected: `labelViolations: []`.
|
||||
|
||||
- [ ] **Step 3: Update blocked.md Q.DEFER.004 closure block — replace verification block 2026-05-13 с new результатами**
|
||||
|
||||
Заменить old «Verification 2026-05-13» block с честным сообщением о failure → новый block с successful re-verification:
|
||||
|
||||
```markdown
|
||||
**Verification 2026-05-13 (part 2, post-Vuetify-workaround):**
|
||||
- `/deals`: 0 `label` violations critical ✅
|
||||
- `/admin/supplier-prices`: 0 `label` violations critical ✅
|
||||
- `/admin/tenants` (sub-C): 1 `aria-tooltip-name` serious — Vuetify-internal, documented (unchanged).
|
||||
|
||||
Закрытие реальное: sub-A + sub-B via Vuetify `label` prop + sr-only CSS pattern. Tests updated к accessibility-name DOM check (не `[aria-label]` attribute).
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/audits/2026-05-12-portal-full-audit-blocked.md
|
||||
git commit -m "docs(audit): Q.DEFER.004 — re-verification 2026-05-13 confirms sub-A+B actually closed"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Memory + push (заменяет original Task 5)
|
||||
|
||||
Same protocol as Task 5, дополнительно сохранить **quirk 71** в `memory/feedback_environment.md`:
|
||||
|
||||
```
|
||||
- Quirk 71: Vuetify 3.12 v-checkbox-btn / v-text-field / v-switch **silently drop**
|
||||
`aria-label` prop — не propagated на native <input>. Vitest jsdom rendering tolerates
|
||||
this (selector `input[aria-label="..."]` somehow matches), но real Chromium DOM показывает
|
||||
axe-core critical `label` violation. **Workaround:** use Vuetify `label` prop +
|
||||
CSS visually-hidden (.v-label / .v-field-label position absolute clip). Source:
|
||||
Q.DEFER.004 Task 4 finding 2026-05-13.
|
||||
```
|
||||
|
||||
Update `MEMORY.md` description line для `feedback_environment.md`:
|
||||
|
||||
- было: «...последний bump: post-audit continuation 12.05.2026 ночь, +квирки 69-70...»
|
||||
- стало: «...последний bump: 13.05.2026 +квирк 71 (Vuetify aria-label drops + Vitest jsdom false-pass)...»
|
||||
|
||||
И обновить `memory/project_full_audit_2026-05-12.md` с Q.DEFER.004 фактическим closure status.
|
||||
|
||||
Затем `git push origin plan5-frontend-projects`.
|
||||
-279
@@ -1,279 +0,0 @@
|
||||
# Quirk #77 — Pest --parallel Unique Key Collision Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Document Pest --parallel `ProjectBulkActionsTest::rejects_bulk_when_scope_filter_captures_more_than_500_projects` collision как новый quirk #77 в memory `feedback_environment.md` + extend `pest-parallel-debugger` subagent с 5-th hypothesis.
|
||||
|
||||
**Architecture:** 2 deliverables — (a) Memory quirk entry following established pattern (quirks 70-76 structure: symptom / root cause / why / how to apply); (b) Subagent system prompt extension (new hypothesis section + updated diagnostic pipeline + updated output template).
|
||||
|
||||
**Tech Stack:** Markdown edits, no code changes. Memory file lives outside repo (`C:/Users/Administrator/.claude/projects/...`). Subagent file in repo at `.claude/agents/pest-parallel-debugger.md`.
|
||||
|
||||
**Discovery context:** Task 8 of completion plan ([docs/superpowers/plans/2026-05-13-claude-automation-completion-plan.md](2026-05-13-claude-automation-completion-plan.md) §Task 8) — Pest baseline regression check discovered new flake in `ProjectBulkActionsTest`. Subagent applied `superpowers:systematic-debugging` (3 hypotheses, 2 falsified, 1 confirmed). NOT a regression from feat/claude-automation work (`f454e95` audit-2 commit zero PHP code touched). Pre-existing flake surfaced during baseline run.
|
||||
|
||||
**Evidence captured (this session):**
|
||||
|
||||
- [`db/schema.sql:836`](../../../db/schema.sql#L836) — `UNIQUE (tenant_id, name)` constraint on `projects` table
|
||||
- [`app/database/factories/ProjectFactory.php:23`](../../../app/database/factories/ProjectFactory.php#L23) — `'name' => fake()->words(3, true)` (Faker Lorem provider, default English locale ~100 words → ~1M combos)
|
||||
- [`app/tests/Pest.php:18`](../../../app/tests/Pest.php#L18) — `// ->use(RefreshDatabase::class)` (RefreshDatabase commented; DatabaseTransactions implicit)
|
||||
- [`app/tests/Feature/Api/ProjectBulkActionsTest.php:194-206`](../../../app/tests/Feature/Api/ProjectBulkActionsTest.php#L194-L206) — test creates 501 projects in single tenant via `Project::factory()->for($tenant)->count(501)->create()`
|
||||
|
||||
**Subagent raw output (Task 8) reproducible signature:**
|
||||
|
||||
```text
|
||||
{"tool":"pest","result":"failed","tests":742,"passed":738,"assertions":2240,"duration_ms":~32000,"errors":1,
|
||||
"error_details":[{
|
||||
"test":"P\\Tests\\Feature\\Api\\ProjectBulkActionsTest::__pest_evaluable_it_rejects_bulk_when_scope_filter_captures_more_than_500_projects",
|
||||
"file":"\\app\\vendor\\laravel\\framework\\src\\Illuminate\\Database\\Connection.php",
|
||||
"line":426,
|
||||
"message":"SQLSTATE[23505]: Unique violation: 7 ОШИБКА: повторяющееся значение ключа нарушает ограничение уникальности \"projects_tenant_id_name_key\"\nDETAIL: Ключ \"(tenant_id, name)=(<id>, <faker-name>)=(11795, eius animi qui)\" уже существует."
|
||||
}],"skipped":3}
|
||||
```
|
||||
|
||||
Tenant_id varied between runs (11795 → 11850, ~50 apart suggesting per-worker auto-increment offset). Sequential isolation: 14/14 ✅.
|
||||
|
||||
**Spec/source:** Discovered empirically. No formal spec — quirk documentation is the spec.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Файл | Действие |
|
||||
|---|---|
|
||||
| `C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/feedback_environment.md` | Append quirk #77 entry (following existing #70-76 pattern); **NOT in repo, не commit'ить** |
|
||||
| `C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/MEMORY.md` line 5 (feedback_environment reference) | Bump «76 квирков» → «77 квирков», добавить #77 в summary list |
|
||||
| `.claude/agents/pest-parallel-debugger.md` (в repo на feat/claude-automation OR в работе после merge на main) | Extend subagent system prompt: +Quirk 77 in known-quirks section, +Hypothesis 5 в diagnostic pipeline, +Hypothesis 5 в output template |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Draft quirk #77 entry для memory `feedback_environment.md`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/feedback_environment.md` (append before final blank line)
|
||||
|
||||
- [ ] **Step 1.1: Find anchor position**
|
||||
|
||||
```bash
|
||||
grep -n "^\*\*76\.\|^\*\*77\.\|^---$" "C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/feedback_environment.md" | tail -5
|
||||
```
|
||||
|
||||
Identify line после last existing quirk #76 (or wherever pattern continues — file may have grown).
|
||||
|
||||
- [ ] **Step 1.2: Compose quirk #77 entry**
|
||||
|
||||
Follow established pattern (quirks 70-76 structure: bold-numbered title, then date + context, then Why, then How to apply):
|
||||
|
||||
```markdown
|
||||
**77. Pest --parallel deterministic unique-key collision на `projects(tenant_id, name)` (13.05.2026 day +1, Task 8 baseline check finding).** `vendor/bin/pest --parallel --recreate-databases` reproducibly fails 738/742 на `ProjectBulkActionsTest::rejects_bulk_when_scope_filter_captures_more_than_500_projects` (file `app/tests/Feature/Api/ProjectBulkActionsTest.php:194-206`). Signature: `SQLSTATE[23505] projects_tenant_id_name_key — (tenant_id, name)=(<id>, "<faker-name>")`. Tenant_id varies between runs (11795, 11850 — ~50 apart suggesting per-worker auto-increment); name from Faker Lorem provider `fake()->words(3, true)` (~100 default English words → ~1M 3-word combos). Test creates 501 projects in single tenant via `Project::factory()->for($tenant)->count(501)->create()`. Sequential isolation `vendor/bin/pest tests/Feature/Api/ProjectBulkActionsTest.php` passes 14/14. **Deterministic** in --parallel, **passing** sequential.
|
||||
**Why:** Root cause partial: collision on `(tenant_id, name)` UNIQUE (`db/schema.sql:836`). Birthday paradox math для 501 samples из ~1M combos дает ~12.5% per-test failure probability — НЕ deterministic. Reproducible-in-parallel-but-not-sequential pattern suggests interaction между worker processes (shared Faker seed via PHP global state? shared autoload init? Eloquent factory caching?). Full RCA pending — required deeper investigation beyond 41-second subagent diagnostic run (Task 8). NOT a regression from any specific commit — verified `git show f454e95 --stat` (audit-2 docs commit zero PHP touched), and `ProjectBulkActionsTest.php` last modification was commit `64d8dae` (feat: scope.filter resolver + 500-limit guard) without subsequent code changes triggering the flake.
|
||||
**How to apply:** (1) Treat as **known parallel-only flake** — sequential `vendor/bin/pest tests/Feature/Api/ProjectBulkActionsTest.php` always 14/14 ✅. (2) При baseline regression check после feature-merge — accept 738/742 как baseline (vs «pure» 742/739/0/3 memory baseline from earlier sessions when flake didn't surface); rerun sequential для isolation confirm. (3) Long-term fix candidates (когда позволяет окружение): seed Faker explicitly с unique-per-test seeds (`fake()->seed($testId)`), OR add `->unique()` to factory name generation (`fake()->unique()->words(3, true)`), OR migrate Pest.php к `RefreshDatabase::class` (currently commented line 18) которое force-recreates DB per-test instead of TX rollback. (4) При появлении similar collision flakes на других `(tenant_id, name)` или похожих UNIQUE constraints — этот же класс quirk, same hypothesis.
|
||||
```
|
||||
|
||||
- [ ] **Step 1.3: Append to memory file**
|
||||
|
||||
Use Edit tool с anchor (last existing quirk text), append new entry **before** any trailing whitespace/blank lines.
|
||||
|
||||
- [ ] **Step 1.4: Verify (no commit — memory files outside repo)**
|
||||
|
||||
```bash
|
||||
grep -c "^\*\*77\." "C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/feedback_environment.md"
|
||||
```
|
||||
|
||||
Expected: `1`.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Update `MEMORY.md` summary line для feedback_environment
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/MEMORY.md` line 5
|
||||
|
||||
- [ ] **Step 2.1: Bump count + add summary**
|
||||
|
||||
**OLD line 5:**
|
||||
|
||||
```markdown
|
||||
- [Особенности окружения](feedback_environment.md) — Windows Server 2022 + 76 квирков (последний bump: **13.05.2026 day +1 +квирки 74-76**): #76 docs/superpowers/plans/ relative paths требуют `../../../` для app/ references (lychee strict semantics; CTO-19 fixup `f6e1e64`); #75 Vuetify-internal default mdi-* names НЕ покрыты простым grep user code — нужно прочёсывать `node_modules/vuetify/lib/iconsets/mdi*` тоже (CTO-19 Task 2.b extension +25 entries → 103 total); #74 `npm install` требует `--legacy-peer-deps` из-за Histoire 1.0.0-beta.1 peerDep conflict; #73 Pest --parallel cumulative state расширился на long sessions без `--recreate-databases`. **Полный список 1-76 в файле**
|
||||
```
|
||||
|
||||
**NEW line 5:**
|
||||
|
||||
```markdown
|
||||
- [Особенности окружения](feedback_environment.md) — Windows Server 2022 + 77 квирков (последний bump: **13.05.2026 day +1 +квирк 77**): #77 Pest --parallel deterministic unique-key collision на `projects(tenant_id, name)` в `ProjectBulkActionsTest::rejects_bulk_when_scope_filter_captures_more_than_500_projects` (501-project bulk, Faker Lorem ~1M combos, ~12.5% sporadic→deterministic в parallel; sequential 14/14 ✅; root cause partial — possible worker state sharing); #76 docs/superpowers/plans/ relative paths требуют `../../../` для app/ references; #75 Vuetify-internal default mdi-* names НЕ покрыты простым grep user code; #74 `npm install` требует `--legacy-peer-deps` из-за Histoire 1.0.0-beta.1 peerDep conflict. **Полный список 1-77 в файле**
|
||||
```
|
||||
|
||||
- [ ] **Step 2.2: Verify**
|
||||
|
||||
```bash
|
||||
grep -E "77 квирков|#77 Pest" "C:/Users/Administrator/.claude/projects/c---------------------crm-------------/memory/MEMORY.md"
|
||||
```
|
||||
|
||||
Expected: 2 matches (line 5 mentions).
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Extend `.claude/agents/pest-parallel-debugger.md` subagent
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `.claude/agents/pest-parallel-debugger.md` (на feat/claude-automation OR на feat/claude-automation-quirk77-update — new sub-branch off main after PR merge)
|
||||
|
||||
**Decision (apply-time):** if Task 9 sync нормативки (completion plan) уже merged → create new sub-branch `feat/quirk-77-update` off main; else commit on `feat/claude-automation` directly (этот PR remains scoped к 8 automations + sync нормативки already drafted; adding quirk #77 в same PR mixes scope).
|
||||
|
||||
Recommendation: **separate branch** post Task 9 merge для atomic-commit hygiene.
|
||||
|
||||
### Edit 3.1: Add Quirk 77 в known-quirks section (after Quirk 73)
|
||||
|
||||
**Anchor:** end of Quirk 73 block, before `**NB:** quirks 70 (axe-core...)` line.
|
||||
|
||||
**Insert:**
|
||||
|
||||
```markdown
|
||||
3. **Quirk 77 (memory feedback_environment.md, added 13.05.2026 day +1) — Pest --parallel deterministic unique-key collision на `projects(tenant_id, name)` в bulk-action tests.**
|
||||
- Symptom: `vendor/bin/pest --parallel --recreate-databases` reproducibly fails 738/742 на `ProjectBulkActionsTest::rejects_bulk_when_scope_filter_captures_more_than_500_projects`. Signature `SQLSTATE[23505] projects_tenant_id_name_key — (tenant_id, name)=(<id>, "<faker-3words>")`. Tenant_id varies per run (~50 apart — per-worker auto-increment).
|
||||
- Test creates 501 projects в single tenant; Faker Lorem provider ~100 default English words → ~1M 3-word combos; birthday paradox ~12.5% per-test → НЕ deterministic baseline. Reproducible-in-parallel-but-not-sequential pattern suggests worker state sharing (shared Faker seed via PHP global state? Eloquent factory caching?). Full RCA pending.
|
||||
- Sequential `vendor/bin/pest tests/Feature/Api/ProjectBulkActionsTest.php` passes 14/14 ✅.
|
||||
- Mitigation: treat as known parallel-only flake; sequential isolation always passes; baseline regression check on main post-merge — accept 738/742 OR rerun sequential.
|
||||
```
|
||||
|
||||
(Adjust numbering — make this #3 in known-quirks list, push existing «**NB:**» line после.)
|
||||
|
||||
### Edit 3.2: Add Hypothesis 4 в diagnostic pipeline (between current H3 «cumulative state» и H4 «other»)
|
||||
|
||||
**Current diagnostic pipeline ends with «Hypothesis 4 — other». Insert new H4 (quirk 77) before, renumber «other» to H5.**
|
||||
|
||||
**Replace:**
|
||||
|
||||
```markdown
|
||||
4. **Hypothesis 4 — other.** If none of above → escalate с raw output + tested hypotheses + outcome per hypothesis.
|
||||
```
|
||||
|
||||
**With:**
|
||||
|
||||
```markdown
|
||||
4. **Hypothesis 4 — quirk 77 (unique-key collision в bulk-action tests).** Failing test creates ≥500 records of one model в single tenant с Faker-generated unique field? Pattern: `SQLSTATE[23505]` + `_tenant_id_<col>_key` constraint name + Faker-style value в DETAIL. Rerun sequential `./vendor/bin/pest <test-file>` — if passes 14/14 → quirk 77 confirmed; document as known parallel-only flake, не fix без user OK (root cause не fully RCA'd).
|
||||
5. **Hypothesis 5 — other.** If none of above → escalate с raw output + tested hypotheses + outcome per hypothesis.
|
||||
```
|
||||
|
||||
### Edit 3.3: Update output template — add Hypothesis 4 row
|
||||
|
||||
**Replace в Output format block:**
|
||||
|
||||
```text
|
||||
Hypothesis 3 (quirk 73 cumulative state): <falsified|confirmed|untested>
|
||||
Evidence: <command + output>
|
||||
|
||||
Conclusion: <real fix needed | quirk 72 — known race document | quirk 73 — recreate-databases fixed | other — escalate>
|
||||
```
|
||||
|
||||
**With:**
|
||||
|
||||
```text
|
||||
Hypothesis 3 (quirk 73 cumulative state): <falsified|confirmed|untested>
|
||||
Evidence: <command + output>
|
||||
Hypothesis 4 (quirk 77 unique-key collision): <falsified|confirmed|untested>
|
||||
Evidence: <command + output>
|
||||
|
||||
Conclusion: <real fix needed | quirk 72 — known race document | quirk 73 — recreate-databases fixed | quirk 77 — known parallel-only flake document | other — escalate>
|
||||
```
|
||||
|
||||
### Edit 3.4: Update agent description (frontmatter)
|
||||
|
||||
**OLD description:**
|
||||
|
||||
```yaml
|
||||
description: |
|
||||
Diagnose Pest 4 --parallel test failures in the Лидерра CRM project.
|
||||
Classifies failures as (a) real failure, (b) quirk 72 (Redis supplier:session
|
||||
race в subdir-only), (c) quirk 73 (cumulative state on long sessions),
|
||||
or (d) other — escalate. Falsifies hypotheses with actual command runs.
|
||||
```
|
||||
|
||||
**NEW description:**
|
||||
|
||||
```yaml
|
||||
description: |
|
||||
Diagnose Pest 4 --parallel test failures in the Лидерра CRM project.
|
||||
Classifies failures as (a) real failure, (b) quirk 72 (Redis supplier:session
|
||||
race в subdir-only), (c) quirk 73 (cumulative state on long sessions),
|
||||
(d) quirk 77 (unique-key collision в bulk-action tests with Faker-generated names),
|
||||
or (e) other — escalate. Falsifies hypotheses with actual command runs.
|
||||
```
|
||||
|
||||
### Step 3.5: Verify markdownlint + commit
|
||||
|
||||
```bash
|
||||
npx markdownlint-cli2 ".claude/agents/pest-parallel-debugger.md" 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
```bash
|
||||
git add .claude/agents/pest-parallel-debugger.md
|
||||
"C:/моя/проекты/портал crm/Документация/bin/gitleaks.exe" protect --staged --config "C:/моя/проекты/портал crm/Документация/.gitleaks.toml" --no-banner
|
||||
git commit -m "$(cat <<'EOF'
|
||||
feat(agent): extend pest-parallel-debugger с quirk 77 (unique-key collision)
|
||||
|
||||
Added Quirk 77 documentation per memory feedback_environment.md addition:
|
||||
Pest --parallel deterministic unique-key collision на projects(tenant_id, name)
|
||||
в ProjectBulkActionsTest::rejects_bulk_when_scope_filter_captures_more_than_500_projects.
|
||||
|
||||
- New Hypothesis 4 в diagnostic pipeline (between quirk 73 и «other»)
|
||||
- Updated output template (Hypothesis 4 row + extended Conclusion options)
|
||||
- Updated description frontmatter (added quirk 77 classification)
|
||||
|
||||
Evidence (Task 8 baseline check):
|
||||
- Reproducible 738/742 в --parallel --recreate-databases
|
||||
- Sequential isolation 14/14 passes
|
||||
- Pre-existing flake, NOT regression from any specific commit
|
||||
- Root cause partial (Faker seed sharing hypothesis); full RCA pending
|
||||
|
||||
NB: project-local subagent auto-discovery может требовать session restart.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review (after writing plan)
|
||||
|
||||
### 1. Spec coverage
|
||||
|
||||
- ✅ Memory quirk entry (Task 1) — follows established pattern (numbered, dated, symptom/why/apply).
|
||||
- ✅ MEMORY.md summary update (Task 2) — bumps count + adds #77 to inline summary.
|
||||
- ✅ Subagent extension (Task 3) — 4 distinct edits to integrate quirk 77 throughout subagent prompt.
|
||||
|
||||
### 2. Placeholder scan
|
||||
|
||||
- `<id>`, `<faker-name>`, `<faker-3words>`, `<col>`, `<test-file>` — these are documentation placeholders for varying actual values, not implementation gaps. Acceptable.
|
||||
- No "TBD" / "implement later" / "fill in details".
|
||||
|
||||
### 3. Type consistency
|
||||
|
||||
- Quirk number `77` consistent across Task 1 entry, Task 2 summary, Task 3 hypothesis label.
|
||||
- File paths `app/tests/Feature/Api/ProjectBulkActionsTest.php`, `app/database/factories/ProjectFactory.php`, `db/schema.sql:836` consistent.
|
||||
- Existing quirk numbers `70-76` referenced consistently with their actual content (verified via Grep this session).
|
||||
|
||||
### 4. NB ограничения (явно зафиксированы)
|
||||
|
||||
- **Не верифицировал** root cause of «deterministic-in-parallel-but-not-sequential» pattern beyond «possible worker state sharing» hypothesis. Full RCA requires deeper investigation (PHP shared globals via fork, Faker internal state, Pest worker config). Quirk entry honestly documents this как «root cause partial; pending».
|
||||
- **Не запускал** Pest на feat/claude-automation worktree to re-confirm collision — relied on Task 8 subagent's two consecutive --parallel runs.
|
||||
- **Не верифицировал** что Faker Lorem default has ~100 words exact count — citation of general Faker docs knowledge, may differ in actual installed Faker version.
|
||||
- **Не применил** ни одного Edit от Tasks 1-3 — это plan only.
|
||||
|
||||
---
|
||||
|
||||
## Execution handoff
|
||||
|
||||
Plan complete и saved to `docs/superpowers/plans/2026-05-13-quirk-77-pest-parallel-unique-key-collision-plan.md`. Two execution options:
|
||||
|
||||
1. **Inline Execution** — execute Tasks 1-3 в new session through `superpowers:executing-plans`. NB: Task 1+2 modify files OUTSIDE repo (memory), no commit needed. Task 3 commits to feat/claude-automation OR new sub-branch.
|
||||
|
||||
2. **Plan-only** — saved для будущей реализации; defer until Task 9 sync нормативки (completion plan) merged, then apply quirk 77 update on fresh sub-branch.
|
||||
|
||||
**Recommendation:** Plan-only — wait until completion plan Task 9 merged, then apply quirk 77 на separate branch `feat/quirk-77-update` для atomic-commit hygiene.
|
||||
|
||||
**Which approach?** (или — plan saved здесь, оставить как future-work item для memory cleanliness)
|
||||
@@ -1,552 +0,0 @@
|
||||
# A11y Rescan — Live Portal (Authenticated + Guest) 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:** Provide accurate, current a11y coverage for the entire live Vue portal (guest + authenticated routes), close any real production findings, and document the new baseline. Continuation of Audit #3 Phase 7 + deferred-fixes sprint Task 1 — closes the `out-of-scope` clause for authenticated routes.
|
||||
|
||||
**Architecture:** Three-pillar a11y verification — (1) Pa11y on guest pages (existing baseline, verify holds); (2) Pa11y on authenticated pages via per-URL `actions` login flow (new); (3) axe-core via Playwright MCP sweep on the same authenticated routes (cross-validation against Pa11y). Real findings are fixed inline; known temp (DevIndexBadge) and Vuetify-structural patterns are documented as ignored.
|
||||
|
||||
**Tech Stack:** pa11y-ci 4.1 (chromium + axe rules) + Playwright MCP (browser_navigate + browser_evaluate for axe-core CDN) + Laravel 13 dev-server (`php artisan serve :8000`) + Vue Router (createWebHistory) + Sanctum SPA cookie auth + DemoSeeder credentials `admin@demo.local:password`.
|
||||
|
||||
**Scope:**
|
||||
|
||||
- **In scope:** All 26 SPA routes registered in `app/routes/web.php` (7 guest already in baseline + 14 authenticated + 3 error views + 2 already counted).
|
||||
- **Out of scope:** Static HTML handoff prototypes (already relocated to opt-in `pa11y-handoff.config.json` in deferred-fixes sprint). Mobile viewport (1440×900 desktop baseline is sufficient for first authenticated baseline).
|
||||
- **Branch:** work on `main` (current 🟢 GREEN, HEAD `ae20033`). Atomic commit per logical change.
|
||||
|
||||
**Pre-flight assumptions to verify in Task 1:**
|
||||
|
||||
- Dev-server up on `http://localhost:8000` (status 200 on `/login`).
|
||||
- Demo tenant + `admin@demo.local` user present (DemoSeeder runs auto in local env per commit `e746b3c` Task 2.4).
|
||||
- Vite production build current (Task 1 will rebuild to be safe — quirk #81 from `feedback_environment.md`: `php artisan serve` serves built assets, not Vite HMR).
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Pre-flight + verify guest baseline holds
|
||||
|
||||
**Files:**
|
||||
|
||||
- No modifications — verification only.
|
||||
|
||||
### Step 1.1 — Verify dev-server up + DemoSeeder applied
|
||||
|
||||
- [ ] Run from `c:/моя/проекты/портал crm/Документация`:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/login
|
||||
```
|
||||
|
||||
Expected: `200`. If not 200 → start `cd app && php artisan serve --port=8000 &`.
|
||||
|
||||
- [ ] Verify user count:
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app" && php artisan tinker --execute='echo \App\Models\User::count();'
|
||||
```
|
||||
|
||||
Expected: `>= 1`. If `0` → `php artisan migrate:fresh --seed`.
|
||||
|
||||
### Step 1.2 — Rebuild Vite (quirk #81)
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app" && npx vite build
|
||||
```
|
||||
|
||||
Expected: build OK, ~2s, `app/public/build/assets/*.js` files updated.
|
||||
|
||||
### Step 1.3 — Run Pa11y guest baseline
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация" && npm run a11y
|
||||
```
|
||||
|
||||
Expected: `✔ 7/7 URLs passed`. If anything fails, treat as regression — investigate before continuing.
|
||||
|
||||
### Step 1.4 — Capture & commit nothing (verification only)
|
||||
|
||||
- [ ] Note timing in scratch — no file changes yet.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Discover login form selectors
|
||||
|
||||
**Files:**
|
||||
|
||||
- Read: `app/resources/js/views/auth/LoginView.vue` (selectors for email/password inputs + submit button)
|
||||
- Read: `app/resources/js/views/auth/TwoFactorView.vue` (for 2FA flow if applicable — DemoSeeder user has `totp_enabled: false`, so 2FA can be skipped)
|
||||
|
||||
### Step 2.1 — Read LoginView for `data-testid` / `id` / `name` attributes
|
||||
|
||||
- [ ] Read full file:
|
||||
|
||||
```text
|
||||
app/resources/js/views/auth/LoginView.vue
|
||||
```
|
||||
|
||||
Identify:
|
||||
|
||||
- Email input selector (e.g. `input[type="email"]` or `#email` or `[data-testid="login-email"]`)
|
||||
- Password input selector
|
||||
- Submit button selector
|
||||
- Success-redirect target (likely `/dashboard`)
|
||||
|
||||
### Step 2.2 — Verify DemoSeeder credentials in code
|
||||
|
||||
- [ ] Grep `app/database/seeders/DemoSeeder.php`:
|
||||
|
||||
```bash
|
||||
grep -n "admin@demo.local\|password" app/database/seeders/DemoSeeder.php
|
||||
```
|
||||
|
||||
Expected: confirmation that email is `admin@demo.local` and `Hash::make('password')` is used.
|
||||
|
||||
### Step 2.3 — Document selectors
|
||||
|
||||
- [ ] Write findings in scratch (no commit yet):
|
||||
|
||||
```text
|
||||
Login flow:
|
||||
- POST /login
|
||||
- email input: <selector>
|
||||
- password input: <selector>
|
||||
- submit button: <selector>
|
||||
- on success: redirect to /dashboard (Vue Router auth-guard pulls user via /api/auth/me)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Extend Pa11y config with authenticated URLs (actions-based login)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `pa11y.config.json` (add 14+ authenticated URLs with per-URL `actions` block for login)
|
||||
|
||||
### Step 3.1 — Verify Pa11y `actions` API works locally on a single auth URL
|
||||
|
||||
- [ ] Create temporary `pa11y-test.config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"standard": "WCAG2AA",
|
||||
"timeout": 30000,
|
||||
"wait": 1500,
|
||||
"hideElements": ".js-skip-a11y, [data-a11y-skip], .dev-index-badge, .dev-index-num, .v-overlay-container",
|
||||
"ignore": ["warning", "notice"],
|
||||
"chromeLaunchConfig": {
|
||||
"args": ["--no-sandbox", "--disable-dev-shm-usage"]
|
||||
},
|
||||
"viewport": { "width": 1440, "height": 900 }
|
||||
},
|
||||
"urls": [
|
||||
{
|
||||
"url": "http://localhost:8000/dashboard",
|
||||
"screenCapture": "./bin/a11y-screenshots/live-auth-dashboard.png",
|
||||
"actions": [
|
||||
"navigate to http://localhost:8000/login",
|
||||
"wait for element <SELECTOR_FROM_TASK_2> to be visible",
|
||||
"set field <EMAIL_SELECTOR> to admin@demo.local",
|
||||
"set field <PASSWORD_SELECTOR> to password",
|
||||
"click element <SUBMIT_SELECTOR>",
|
||||
"wait for path to be /dashboard",
|
||||
"navigate to http://localhost:8000/dashboard",
|
||||
"wait for element <DASHBOARD_LANDMARK> to be visible"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Replace `<SELECTOR_FROM_TASK_2>` with selectors discovered in Task 2.
|
||||
|
||||
### Step 3.2 — Run test config
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация" && npx pa11y-ci --config pa11y-test.config.json
|
||||
```
|
||||
|
||||
Expected: `1/1 URL passed` (or violations listed). If Pa11y errors with «navigation timeout» or «selector not found» — debug login-flow selectors before proceeding.
|
||||
|
||||
### Step 3.3 — If Step 3.2 succeeds, merge auth URLs into main config
|
||||
|
||||
- [ ] Add 14 authenticated URL blocks (one per route) to `pa11y.config.json`. Each block shares the same `actions` login template (only target URL + screenshot filename change).
|
||||
|
||||
Routes from `app/routes/web.php` (Route::view + auth-guarded — verify in Vue Router meta):
|
||||
|
||||
1. `/dashboard` (always auth)
|
||||
2. `/deals` (auth)
|
||||
3. `/kanban` (auth)
|
||||
4. `/projects` (auth)
|
||||
5. `/billing` (auth)
|
||||
6. `/settings` (auth)
|
||||
7. `/reports` (auth)
|
||||
8. `/reminders` (auth)
|
||||
9. `/admin/tenants` (admin-auth)
|
||||
10. `/admin/billing` (admin)
|
||||
11. `/admin/incidents` (admin)
|
||||
12. `/admin/system` (admin)
|
||||
13. `/admin/pricing-tiers` (admin)
|
||||
14. `/admin/supplier-prices` (admin)
|
||||
|
||||
`/admin/impersonation` — covered by separate flow (`saas_admin` SSO not present on MVP per `routes/web.php:74`); for MVP, treat as authenticated like other admin.
|
||||
|
||||
### Step 3.4 — Delete the temporary test config
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация" && rm pa11y-test.config.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Run extended Pa11y baseline + capture findings
|
||||
|
||||
**Files:**
|
||||
|
||||
- Output: terminal log + `bin/a11y-screenshots/live-auth-*.png` (auto-generated by Pa11y)
|
||||
|
||||
### Step 4.1 — Full Pa11y run
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация" && npm run a11y 2>&1 | tee /tmp/pa11y-extended-baseline.log
|
||||
```
|
||||
|
||||
Expected: at least one of two outcomes:
|
||||
|
||||
- All ≥21 URLs pass → straight to documentation (Task 8)
|
||||
- Some URLs fail with violations → categorize in Task 5
|
||||
|
||||
### Step 4.2 — Tally pass/fail per URL
|
||||
|
||||
- [ ] From log, build a table:
|
||||
|
||||
| URL | Status | Violations count | Notes |
|
||||
|---|---|---|---|
|
||||
| /login | pass/fail | N | |
|
||||
| /dashboard | | | |
|
||||
| ... | | | |
|
||||
|
||||
### Step 4.3 — For each failing URL, capture violation details
|
||||
|
||||
- [ ] Per-violation: rule name (color-contrast, label, region, etc.), DOM selector, expected ratio/text, recommendation.
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Categorize findings (real prod vs known temp vs structural)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Output: classification table in scratch + decision matrix for Task 7.
|
||||
|
||||
### Step 5.1 — Triage each finding
|
||||
|
||||
For each violation, decide:
|
||||
|
||||
- **REAL_PROD** — fix in code (Task 7)
|
||||
- **KNOWN_TEMP** — DevIndexBadge / DevIndex* — already in `hideElements`; if it leaks through, investigate why
|
||||
- **STRUCTURAL_VUETIFY** — `.v-overlay-container` / portal — already ignored; investigate if new
|
||||
- **CONTENT_DATA** — depends on DemoSeeder content (e.g. empty table state) — fix in test data OR component fallback
|
||||
- **MIGRATED_KNOWN** — already known from Audit #3 Phase 7 (empty-table-header on /admin/tenants — should be CLOSED now since title='Действия' was set in deferred-fixes sprint commit `c524227`)
|
||||
|
||||
### Step 5.2 — Decision matrix output
|
||||
|
||||
```text
|
||||
REAL_PROD → fix in Task 7 (atomic commit per finding)
|
||||
KNOWN_TEMP → no fix; verify hideElements covers selector
|
||||
STRUCTURAL → no fix; document in audit-baseline-pa11y.md
|
||||
CONTENT_DATA → fix in seed/component fallback if production-relevant
|
||||
MIGRATED_KNOWN→ verify fix from c524227 holds; if regressed, re-fix
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — axe-core cross-validation via Playwright MCP
|
||||
|
||||
**Files:**
|
||||
|
||||
- No file changes — Playwright MCP session output.
|
||||
|
||||
### Step 6.1 — Launch Playwright + login
|
||||
|
||||
- [ ] Open Playwright MCP browser to `http://localhost:8000/login`
|
||||
- [ ] Fill email `admin@demo.local`, password `password`, submit
|
||||
- [ ] Wait for `/dashboard`
|
||||
|
||||
### Step 6.2 — Inject axe-core CDN + run on each route
|
||||
|
||||
For each of the 18 routes in scope:
|
||||
|
||||
- [ ] `browser_navigate http://localhost:8000/<route>`
|
||||
- [ ] `browser_wait_for` until main content rendered
|
||||
- [ ] `browser_evaluate`:
|
||||
|
||||
```javascript
|
||||
() => {
|
||||
return new Promise((resolve) => {
|
||||
if (!window.axe) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.0/axe.min.js';
|
||||
script.onload = () => window.axe.run(document).then(resolve);
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
window.axe.run(document).then(resolve);
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Capture `violations[]` array per route.
|
||||
|
||||
### Step 6.3 — Cross-compare Pa11y vs axe-core findings
|
||||
|
||||
Build a table:
|
||||
|
||||
| Route | Pa11y violations | axe-core violations | Agreement? |
|
||||
|---|---|---|---|
|
||||
|
||||
Pa11y uses axe-core rules under the hood, so they should largely agree. Discrepancies signal:
|
||||
|
||||
- Pa11y standard (WCAG2AA) vs axe default (axe-core baseline incl. best-practice) — axe surfaces more.
|
||||
- Timing differences (Pa11y waits, axe runs once snapshot).
|
||||
- Visual diff (Pa11y full screencap renders Vuetify portals empty vs axe sees populated DOM).
|
||||
|
||||
---
|
||||
|
||||
## Task 7 — Fix real production findings (one atomic commit per finding)
|
||||
|
||||
**Files:**
|
||||
|
||||
- TBD based on findings — likely Vue components in `app/resources/js/components/**`, `app/resources/js/views/**`, or scoped styles.
|
||||
|
||||
### Step 7.1 — For each REAL_PROD finding, write TDD-style fix
|
||||
|
||||
Per finding:
|
||||
|
||||
- [ ] Identify the source file containing the offending element
|
||||
- [ ] Make minimal change (scoped CSS color override / aria-label / label association / etc.)
|
||||
- [ ] Rebuild Vite: `cd "/c/моя/проекты/портал crm/Документация/app" && npx vite build`
|
||||
- [ ] Re-run pa11y on the affected URL only:
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация" && npx pa11y-ci --config pa11y.config.json --include "<full-url>"
|
||||
```
|
||||
|
||||
- [ ] Verify violation gone, no new ones introduced.
|
||||
|
||||
### Step 7.2 — Commit per fix
|
||||
|
||||
```bash
|
||||
git add <files>
|
||||
git commit -m "fix(a11y): <one-line summary>"
|
||||
```
|
||||
|
||||
Atomic commits make rollback safe and history readable. Per Pravila — one logical change → one commit.
|
||||
|
||||
---
|
||||
|
||||
## Task 8 — Update Pa11y baseline doc
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/audit-baseline-pa11y.md` (extended baseline post-rescan)
|
||||
|
||||
### Step 8.1 — Append «Authenticated rescan 2026-05-14» section
|
||||
|
||||
```markdown
|
||||
## Authenticated rescan — 2026-05-14
|
||||
|
||||
> Extension to first baseline (guest pages only). Added 14 authenticated routes
|
||||
> via Pa11y `actions` per-URL login flow. DemoSeeder credentials.
|
||||
|
||||
### URLs scanned (live Vue, authenticated)
|
||||
|
||||
| # | URL | Pa11y exit | Violations | Status |
|
||||
|---|---|---|---|---|
|
||||
| 1 | /dashboard | 0/N errors | M | ✅/⚠️ |
|
||||
| ... |
|
||||
|
||||
### New fixes during rescan
|
||||
|
||||
| URL | Violation | File | Fix commit |
|
||||
|---|---|---|---|
|
||||
| ... |
|
||||
```
|
||||
|
||||
### Step 8.2 — Update «Authenticated pages — out of scope» section
|
||||
|
||||
```markdown
|
||||
~~## Authenticated pages — out of scope для первой baseline~~
|
||||
|
||||
(SUPERSEDED 2026-05-14) — see «Authenticated rescan» section above.
|
||||
Implemented via Pa11y `actions` API per-URL login flow + DemoSeeder
|
||||
credentials.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9 — Create audit findings document
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `docs/superpowers/audits/2026-05-14-a11y-rescan-findings.md`
|
||||
|
||||
### Step 9.1 — Write findings doc following Audit #3 format
|
||||
|
||||
```markdown
|
||||
# A11y Rescan — Live Portal Findings (2026-05-14)
|
||||
|
||||
> Continuation of Audit #3 Phase 7 — closes the out-of-scope clause for
|
||||
> authenticated routes after deferred-fixes sprint (commits 8ba9c55..ae20033).
|
||||
|
||||
## Scope expansion
|
||||
|
||||
- Pre-rescan baseline (after sprint `ae20033`): 7 guest pages, 0 violations
|
||||
- Rescan adds: 14 authenticated routes (Pa11y `actions` API) + axe-core
|
||||
cross-validation via Playwright MCP
|
||||
|
||||
## Findings
|
||||
|
||||
| ID | Severity | Rule | Route | DOM | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| ... |
|
||||
|
||||
## Per-route table
|
||||
|
||||
[Reuse table from Task 8 Step 8.1]
|
||||
|
||||
## Fix tally
|
||||
|
||||
- REAL_PROD fixed inline: N
|
||||
- KNOWN_TEMP (DevIndexBadge) — verified ignored via hideElements: N
|
||||
- STRUCTURAL_VUETIFY (.v-overlay-container) — documented: N
|
||||
- MIGRATED_KNOWN (empty-table-header from `c524227`) — verified closed: N
|
||||
|
||||
## Verdict: 🟢 GREEN / 🟡 YELLOW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10 — Final regression sweep (Phase 14-style)
|
||||
|
||||
**Files:**
|
||||
|
||||
- No file changes — verification only.
|
||||
|
||||
### Step 10.1 — Run all gates
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app" && ./vendor/bin/pest --parallel
|
||||
```
|
||||
|
||||
Expected: 742/739/3sk/0.
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app" && npx vitest run --no-coverage
|
||||
```
|
||||
|
||||
Expected: 91 files / 736 / 3sk / 0.
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация/app" && npx vite build
|
||||
```
|
||||
|
||||
Expected: ≤3s.
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация" && npm run links
|
||||
```
|
||||
|
||||
Expected: 0 errors.
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация" && ./bin/gitleaks.exe detect --source . --redact --log-level info
|
||||
```
|
||||
|
||||
Expected: 0 leaks.
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация" && npm run a11y
|
||||
```
|
||||
|
||||
Expected: full pass on all 21+ URLs after Task 7 fixes (or documented exemptions).
|
||||
|
||||
---
|
||||
|
||||
## Task 11 — Final commit + push
|
||||
|
||||
### Step 11.1 — Stage doc-only changes from Tasks 8+9
|
||||
|
||||
```bash
|
||||
cd "/c/моя/проекты/портал crm/Документация"
|
||||
git add pa11y.config.json docs/audit-baseline-pa11y.md docs/superpowers/audits/2026-05-14-a11y-rescan-findings.md docs/superpowers/plans/2026-05-14-a11y-rescan-live-portal.md
|
||||
```
|
||||
|
||||
NB: code-fix commits from Task 7 are already individually committed.
|
||||
|
||||
### Step 11.2 — Commit doc bundle
|
||||
|
||||
```bash
|
||||
git commit -m "$(cat <<'EOF'
|
||||
docs(a11y): extend Pa11y scope to 14 authenticated routes + audit findings
|
||||
|
||||
Closes Audit #3 Phase 7 «authenticated pages out of scope» clause from
|
||||
deferred-fixes sprint Task 1.
|
||||
|
||||
- pa11y.config.json: +14 authenticated URLs via per-URL actions login flow
|
||||
- docs/audit-baseline-pa11y.md: «Authenticated rescan 2026-05-14» section
|
||||
- docs/superpowers/audits/2026-05-14-a11y-rescan-findings.md: full findings
|
||||
|
||||
Final tallies: <N> URLs / <V> violations / <F> fixes inline.
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
### Step 11.3 — Push
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Pre-push hooks: gitleaks-full-history + lychee-links must pass.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
- ✅ Re-verify guest baseline (Task 1)
|
||||
- ✅ Login flow discovery (Task 2)
|
||||
- ✅ Pa11y actions config (Task 3)
|
||||
- ✅ Run + tally (Task 4)
|
||||
- ✅ Categorize findings (Task 5)
|
||||
- ✅ axe-core cross-validation (Task 6)
|
||||
- ✅ Fix real prod findings (Task 7)
|
||||
- ✅ Update baseline doc (Task 8)
|
||||
- ✅ Audit findings document (Task 9)
|
||||
- ✅ Final regression (Task 10)
|
||||
- ✅ Commit + push (Task 11)
|
||||
|
||||
**Placeholder scan:**
|
||||
|
||||
- Task 3 references `<SELECTOR_FROM_TASK_2>` etc. — these ARE filled in once Task 2 produces the selectors. Acceptable per workflow.
|
||||
- Task 7 file paths are TBD — fundamentally cannot be filled in upfront because they depend on Task 5 findings. The procedure (identify source → minimal change → verify → commit) is fully specified.
|
||||
- Task 8/9 violation tables are template skeletons — populated as Task 4 produces data.
|
||||
|
||||
**Type consistency:**
|
||||
|
||||
- All Pa11y config blocks share same `defaults` shape — consistent with current `pa11y.config.json`.
|
||||
- All git commands use absolute path pattern — quirk #79 compliant.
|
||||
- All `cd "/c/моя/проекты/портал crm/Документация"` invocations use full absolute path — no double-cd risk.
|
||||
|
||||
---
|
||||
|
||||
## Execution choice
|
||||
|
||||
Plan complete and saved to `docs/superpowers/plans/2026-05-14-a11y-rescan-live-portal.md`. Two execution options:
|
||||
|
||||
1. **Inline Execution (recommended for this task)** — sequential in this session with checkpoints. Task 6 (Playwright MCP) and Task 7 (fix iteration) need orchestrator decisions per-finding — subagent dispatch would fragment context.
|
||||
2. **Subagent-Driven** — alternative for parallelism, but most tasks have inter-task dependencies (Task 2 selectors → Task 3 config → Task 4 run → Task 5 categorize → Task 7 fix per finding). Linear flow fits better.
|
||||
|
||||
**Recommendation:** Inline Execution.
|
||||
@@ -1,282 +0,0 @@
|
||||
# PDD Regions Field 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:** Добавить редактирование регионов проекта в `ProjectDetailsDrawer` через `v-autocomplete` с bidirectional bitmask binding (codes[] ↔ region_mask + region_mode).
|
||||
|
||||
**Architecture:** In-place port рецепта из NewProjectDialog.vue (Подход A). `selectedRegions: Ref<number[]>` reactive ↔ `form.region_mask` + `form.region_mode` через watch (forward) + `maskToCodes` helper (reverse при reseedFromProject). Backend без изменений (Task 5 onSave уже шлёт оба поля). Sentinel code:0 «Вся РФ» исключён из selectableRegions (fixes latent NewProjectDialog bug).
|
||||
|
||||
**Tech Stack:** Vue 3.5 + Vuetify 3.12 v-autocomplete + Pinia + Vitest jsdom + axios.
|
||||
|
||||
**Spec:** [../specs/2026-05-14-pdd-regions-field-design.md](../specs/2026-05-14-pdd-regions-field-design.md) commit `4f60add`.
|
||||
|
||||
**Environment quirk:** Repo path `c:\моя\проекты\портал crm\Документация\` contains cyrillic — `Write`/`Edit` tools may silently fail intermittently (memory quirk #85). Workaround: Write→`C:\Users\Administrator\AppData\Local\Temp\<f>`→PowerShell `Copy-Item`. Try Edit first; if file size unchanged or `git status` empty after operation — fall back to Temp+Copy.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
| Path | Responsibility | Status |
|
||||
|---|---|---|
|
||||
| `app/resources/js/components/projects/ProjectDetailsDrawer.vue` | Add: REGIONS import / `selectedRegions` ref / `selectableRegions` filter / `maskToCodes` helper / watch / reseed line / template autocomplete block | **Modify** |
|
||||
| `app/tests/Frontend/ProjectDetailsDrawer.spec.ts` | Add: 3 new `it()` blocks at end of describe | **Modify** |
|
||||
|
||||
Backend, store, NewProjectDialog — **no changes**.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Regions field — failing tests + implementation + commit
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue`
|
||||
- Modify: `app/tests/Frontend/ProjectDetailsDrawer.spec.ts`
|
||||
|
||||
- [ ] **Step 1: Read current component & test files**
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация" && cat app/resources/js/components/projects/ProjectDetailsDrawer.vue | head -80
|
||||
```
|
||||
|
||||
Note current state: 14 tests passing, imports `{ reactive, computed, onMounted, onBeforeUnmount, watch, ref }` from 'vue' + `axios` + `useProjectsStore` + type `Project`. Has `form`, `reseedFromProject`, watch on `props.project?.id`, `onSave`, `onPause`, `onDelete`.
|
||||
|
||||
- [ ] **Step 2: Write 3 failing tests**
|
||||
|
||||
Append to `app/tests/Frontend/ProjectDetailsDrawer.spec.ts` after the existing last `it()` block (after Delete tests, inside the same `describe('ProjectDetailsDrawer', ...)`).
|
||||
|
||||
NB: `axios.patch as ReturnType<typeof vi.fn>` cast pattern is established in this file from Task 5.
|
||||
|
||||
```ts
|
||||
it('renders region chips when project has non-zero region_mask', async () => {
|
||||
const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' };
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
|
||||
await wrapper.vm.$nextTick();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Адыгея');
|
||||
expect(text).toContain('Башкортостан');
|
||||
});
|
||||
|
||||
it('selecting regions encodes mask + sets mode=exclude on save', async () => {
|
||||
(axios.patch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: sampleProject } });
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
|
||||
const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' });
|
||||
await autocomplete.vm.$emit('update:model-value', [3, 5]);
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.get('[data-testid="pdd-save"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(axios.patch).toHaveBeenCalledWith(
|
||||
'/api/projects/42',
|
||||
expect.objectContaining({ region_mask: 40, region_mode: 'exclude' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('clearing all regions resets mask=0 + mode=include on save', async () => {
|
||||
(axios.patch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: sampleProject } });
|
||||
const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' };
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
|
||||
const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' });
|
||||
await autocomplete.vm.$emit('update:model-value', []);
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.get('[data-testid="pdd-save"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(axios.patch).toHaveBeenCalledWith(
|
||||
'/api/projects/42',
|
||||
expect.objectContaining({ region_mask: 0, region_mode: 'include' }),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run, verify 3 fail**
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация/app" && npx vitest run tests/Frontend/ProjectDetailsDrawer.spec.ts -t "region"
|
||||
```
|
||||
|
||||
Expected: 3 failed (no autocomplete in template + no chips rendered + no region_mask in payload).
|
||||
|
||||
- [ ] **Step 4: Implement in component**
|
||||
|
||||
Edit [app/resources/js/components/projects/ProjectDetailsDrawer.vue](../../../app/resources/js/components/projects/ProjectDetailsDrawer.vue):
|
||||
|
||||
**4a.** Add import after the existing `import { useProjectsStore } from '../../stores/projectsStore';`:
|
||||
|
||||
```ts
|
||||
import { REGIONS } from '../../constants/regions';
|
||||
```
|
||||
|
||||
**4b.** Add right after `const dayLabels = ...` line (or near other reactive const declarations, before lifecycle hooks):
|
||||
|
||||
```ts
|
||||
const selectedRegions = ref<number[]>([]);
|
||||
const selectableRegions = REGIONS.filter((r) => r.code !== 0);
|
||||
|
||||
function maskToCodes(mask: number): number[] {
|
||||
const codes: number[] = [];
|
||||
for (let i = 1; i <= 31; i++) if (mask & (1 << i)) codes.push(i);
|
||||
return codes;
|
||||
}
|
||||
|
||||
watch(selectedRegions, (codes) => {
|
||||
if (codes.length === 0) {
|
||||
form.region_mask = 0;
|
||||
form.region_mode = 'include';
|
||||
} else {
|
||||
form.region_mask = codes.reduce((acc, c) => (c >= 1 && c <= 31 ? acc | (1 << c) : acc), 0);
|
||||
form.region_mode = 'exclude';
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**4c.** Inside `reseedFromProject(p)`, AFTER the existing `form.region_mask = p.region_mask ?? 0;` line, add:
|
||||
|
||||
```ts
|
||||
selectedRegions.value = maskToCodes(form.region_mask);
|
||||
```
|
||||
|
||||
**4d.** In template, find the existing «Лимит лидов в день» `<label class="pdd-field">` block. **Insert immediately AFTER its closing `</label>`** (and BEFORE the `<div class="pdd-field">` with «Дни недели приёма»):
|
||||
|
||||
```vue
|
||||
<div class="pdd-field">
|
||||
<span class="pdd-label">Регионы (пусто = вся РФ)</span>
|
||||
<v-autocomplete
|
||||
v-model="selectedRegions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details
|
||||
data-testid="pdd-regions"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
NB: `<v-autocomplete>` auto-imported via vite-plugin-vuetify.
|
||||
|
||||
- [ ] **Step 5: Run all PDD tests, verify 17 pass**
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация/app" && npx vitest run tests/Frontend/ProjectDetailsDrawer.spec.ts
|
||||
```
|
||||
|
||||
Expected: 17 passed (14 pre-existing + 3 new).
|
||||
|
||||
- [ ] **Step 6: Run vue-tsc + ESLint regression check**
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация/app" && npx eslint resources/js/components/projects/ProjectDetailsDrawer.vue tests/Frontend/ProjectDetailsDrawer.spec.ts && npx vue-tsc --noEmit 2>&1 | head -10
|
||||
```
|
||||
|
||||
Expected: ESLint 0 errors / 0 warnings. vue-tsc 0 errors.
|
||||
|
||||
- [ ] **Step 7: Run ProjectsView integration tests (regression)**
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация/app" && npx vitest run tests/Frontend/ProjectsView.spec.ts
|
||||
```
|
||||
|
||||
Expected: 8 passed + 1 skipped (no regression from drawer changes).
|
||||
|
||||
- [ ] **Step 8: Commit atomically**
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация" && git add app/resources/js/components/projects/ProjectDetailsDrawer.vue app/tests/Frontend/ProjectDetailsDrawer.spec.ts
|
||||
```
|
||||
|
||||
```bash
|
||||
git commit -m "feat(pdd): regions multi-select autocomplete + bitmask binding"
|
||||
```
|
||||
|
||||
Pre-commit hook (lefthook) will run gitleaks + markdownlint + cspell + stylelint + eslint-vue automatically. **DO NOT use `--no-verify`.** If a hook fails, fix root cause before retrying.
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Full regression sweep + visual smoke handoff
|
||||
|
||||
**No new code.** Verification only.
|
||||
|
||||
- [ ] **Step 1: Full Vitest suite**
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация/app" && npm run test:vue 2>&1 | tail -8
|
||||
```
|
||||
|
||||
Expected: 92 files passed, 758 tests passed + 3 skipped (delta vs baseline of 755+3sk after Task 1 +3 tests = 758).
|
||||
|
||||
- [ ] **Step 2: Vite production build (sanity)**
|
||||
|
||||
```bash
|
||||
cd "c:/моя/проекты/портал crm/Документация/app" && npm run build 2>&1 | tail -5
|
||||
```
|
||||
|
||||
Expected: `✓ built in <Ns>` zero errors. ProjectsView chunk size may grow by ~1 kB (v-autocomplete + REGIONS-import) — acceptable.
|
||||
|
||||
- [ ] **Step 3: Document visual-smoke handoff to user**
|
||||
|
||||
Per spec §6 visual smoke (8 steps):
|
||||
|
||||
1. Open /projects → select project «Доставка еды (СМС)» (or any) → drawer opens.
|
||||
2. See «Регионы (пусто = вся РФ)» field between Лимит and Дни.
|
||||
3. Click input → 31-region dropdown (NO «Вся РФ»).
|
||||
4. Select Москва + Санкт-Петербург → 2 chips appear.
|
||||
5. Click Сохранить → toast, Network tab shows PATCH with `region_mask` non-zero + `region_mode=exclude`.
|
||||
6. Close drawer → reopen same project → 2 chips re-rendered (reverse-decompose works).
|
||||
7. Click ✕ on a chip → chip removed.
|
||||
8. Click clearable X → all chips removed → save → `region_mask=0`, `region_mode=include`.
|
||||
|
||||
Report results to user.
|
||||
|
||||
---
|
||||
|
||||
## Out of plan
|
||||
|
||||
- Composable `useRegionsBitmask` extraction — Plan 6 when 3+ consumers.
|
||||
- NewProjectDialog reverse-decompose backport (TODO line 172) — separate sweep.
|
||||
- bigint mask для region codes >31 — schema change + Plan 6.
|
||||
- Mobile-adaptive autocomplete (touch keyboard / full-screen modal) — Plan 6.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
|
||||
| Spec section | Task implementing it |
|
||||
|---|---|
|
||||
| §1 behavior table — 4 states | Task 1 Step 4 (watch + reseed) |
|
||||
| §1 edge case `mode=include+mask≠0` legacy | Task 1 Step 4 (reseed decompose ignores mode) |
|
||||
| §2 architecture Approach A | Task 1 Step 4 (all parts) |
|
||||
| §3 script additions (selectedRegions / selectableRegions / maskToCodes / watch / reseed) | Task 1 Step 4a–4c |
|
||||
| §3 template autocomplete block | Task 1 Step 4d |
|
||||
| §4 data flow (reseed → watch → save) | Existing onSave (Task 5 PDD) + Task 1 Step 4 (other branches) |
|
||||
| §5 error handling (?? 0, bit 0 skip, defensive guards) | Task 1 Step 4b (`(c >= 1 && c <= 31 ?` guard + loop `for i=1..31`) |
|
||||
| §6 three Vitest tests | Task 1 Steps 2–5 |
|
||||
| §6 visual smoke 8 steps | Task 2 Step 3 (handoff to user) |
|
||||
| §6 regressions (14 PDD + 5 integration + vue-tsc + ESLint) | Task 1 Steps 5–7 + Task 2 Step 1–2 |
|
||||
|
||||
All spec sections mapped. No gaps.
|
||||
|
||||
**Placeholder scan:** No "TBD" / "TODO" / vague. All code in 4a–4d is concrete. All `expect.objectContaining` assertions use literal values (mask 40 = bits 3+5 = 8+32; mask 6 = bits 1+2 = 2+4).
|
||||
|
||||
**Type consistency:**
|
||||
|
||||
- `selectedRegions: Ref<number[]>` declared in 4b, used unchanged in 4c reseed + watch + template.
|
||||
- `selectableRegions` const, computed once at setup, no later re-binding.
|
||||
- `maskToCodes(mask: number): number[]` signature stable.
|
||||
- `form.region_mask` / `form.region_mode` already typed in existing `FormState` interface (Task 2 PDD).
|
||||
|
||||
No issues found.
|
||||
|
||||
---
|
||||
|
||||
## Files referenced (resolution)
|
||||
|
||||
- Component: `app/resources/js/components/projects/ProjectDetailsDrawer.vue` (currently 14 tests passing, post-Task 7 PDD + Task 8 wire + polish-debt spec).
|
||||
- Test: `app/tests/Frontend/ProjectDetailsDrawer.spec.ts` (14 cases, last `it` block is Delete confirm=false test).
|
||||
- Constants: `app/resources/js/constants/regions.ts` (REGIONS array, 32 entries, code:0 sentinel + codes 1-31).
|
||||
- Parent spec: `docs/superpowers/specs/2026-05-14-project-details-drawer-design.md` (commit `0d7f505` polish-debt).
|
||||
- This plan's spec: `docs/superpowers/specs/2026-05-14-pdd-regions-field-design.md` (commit `4f60add`).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,998 +0,0 @@
|
||||
# Portal Full Audit #3 — 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:** Выполнить полный 14-фазный аудит кодовой базы Лидерра портала на HEAD `08605cf`; закрыть все новые P0/P1 in-flight; задокументировать P2/P3 deferred; выпустить Audit #3 report.
|
||||
|
||||
**Architecture:** Pre-flight → ×4 parallel static-analysis subagents → sequential test/schema/security/UI-smoke/a11y/coverage/bundle/pre-prod/TODO sweeps → categorize → hybrid fix (P0+P1 only, atomic commits) → final regression → report. Каждая фаза пишет findings в `findings.md`. Blocked items → `blocked.md`. Summary → `report.md`.
|
||||
|
||||
**Tech Stack:** Laravel 13 / Pest 4 / Pint / Larastan / Vue 3 / Vuetify 3 / Vitest 4 / vue-tsc / ESLint / Prettier / knip / Histoire / Vite / Playwright MCP / Laravel Boost MCP / Redis MCP / gitleaks / lychee / markdownlint / cspell / squawk / Pa11y
|
||||
|
||||
**Hybrid fix policy:**
|
||||
|
||||
- `[FIX-NOW]` — P0/P1, fix ≤30 мин, один atomic commit per finding
|
||||
- `[FIX-DEFER]` — P2/P3, только запись в findings.md, без кода
|
||||
- `[BLOCKED]` — нужно явное «закрываем» от заказчика → `blocked.md`
|
||||
|
||||
**Baseline:** HEAD `08605cf` (origin/main, 2026-05-14)
|
||||
**vs Audit #2:** `9e175a1` (+11 commits delta)
|
||||
**Economy mode:** 0%
|
||||
|
||||
---
|
||||
|
||||
## Audit artefacts
|
||||
|
||||
- `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
- `docs/superpowers/audits/2026-05-14-portal-full-audit-blocked.md`
|
||||
- `docs/superpowers/audits/2026-05-14-portal-full-audit-report.md`
|
||||
- `docs/superpowers/audits/audit-screens/2026-05-14/` (Playwright screenshots)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Pre-flight — git state, delta, skeletons
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
- Create: `docs/superpowers/audits/2026-05-14-portal-full-audit-blocked.md`
|
||||
- Create: `docs/superpowers/audits/2026-05-14-portal-full-audit-report.md`
|
||||
|
||||
- [ ] **Step 1: Verify branch и HEAD**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
git status
|
||||
```
|
||||
|
||||
Expected: branch=`main`, HEAD=`08605cf`, 3 untracked files (parse-bundle-analyze.mjs / 2 plan docs).
|
||||
|
||||
- [ ] **Step 2: Delta commits с Audit #2 baseline `9e175a1`**
|
||||
|
||||
```powershell
|
||||
git log --oneline 9e175a1..HEAD
|
||||
```
|
||||
|
||||
Expected 11 commits:
|
||||
|
||||
```
|
||||
08605cf fix(tests): Bus::fake partial + session mock — close quirk #72
|
||||
9a45346 fix(tests): RefreshDatabase on LookupsTest + ProjectExtensionsTest — close quirk #62
|
||||
7ee78a9 feat(docs): interactive automation graph — 73 nodes, 6 conflicts
|
||||
9b21bbc docs(spec): automation graph design spec — vis.js Solarized dark
|
||||
7007379 docs(plans): add test-quality-preprod sprint plan + fix lychee/cspell
|
||||
bf84568 fix(a11y): add aria-label to VTooltip on /admin/tenants impersonate btn
|
||||
b241c79 docs: add RUNBOOK.md — production deployment runbook
|
||||
9530d17 fix(schedule): register partitions:create-months as daily cron
|
||||
219f262 fix(test): ProjectFactory unique name + test:parallel composer alias
|
||||
e280edd style(frontend): apply prettier --write — fix formatting drift (4 files)
|
||||
58986a2 test(vitest): add testTimeout: 10000 — fix quirk #80 router.spec.ts coverage timeout
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Composer install check**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация\app"
|
||||
composer install 2>&1 | Select-Object -Last 3
|
||||
```
|
||||
|
||||
Expected: `Nothing to install...` exit 0.
|
||||
|
||||
- [ ] **Step 4: npm install check (квирк 74 — `--legacy-peer-deps` обязателен)**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
npm install --legacy-peer-deps 2>&1 | Select-Object -Last 5
|
||||
```
|
||||
|
||||
Expected: `up to date, 0 vulnerabilities` exit 0.
|
||||
|
||||
- [ ] **Step 5: Создать 3 skeleton файла**
|
||||
|
||||
Создать `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`:
|
||||
|
||||
```markdown
|
||||
# Portal Full Audit #3 — Findings (2026-05-14)
|
||||
|
||||
> **Связано:** план `../plans/2026-05-14-portal-full-audit-3.md`, blocked `2026-05-14-portal-full-audit-blocked.md`, report `2026-05-14-portal-full-audit-report.md`
|
||||
> **HEAD:** `main` `08605cf`
|
||||
> **vs Audit #2:** `9e175a1` (+11 commits delta)
|
||||
> **Режим:** экономия 0%
|
||||
|
||||
## Severity легенда
|
||||
- **P0** — блокирует production / data corruption / security incident
|
||||
- **P1** — нарушение функциональности / failing test / type error / a11y violation
|
||||
- **P2** — warning / style / dead code / stale doc
|
||||
- **P3** — cosmetic / nice-to-have
|
||||
|
||||
## Hybrid fix policy
|
||||
- `[FIX-NOW]` — P0/P1, atomic commit
|
||||
- `[FIX-DEFER]` — P2/P3, только запись здесь
|
||||
- `[BLOCKED]` — нужно явное «закрываем» → `blocked.md`
|
||||
```
|
||||
|
||||
Создать `docs/superpowers/audits/2026-05-14-portal-full-audit-blocked.md`:
|
||||
|
||||
```markdown
|
||||
# Portal Full Audit #3 — Blocked Items (2026-05-14)
|
||||
|
||||
Нет открытых blocked items.
|
||||
```
|
||||
|
||||
Создать `docs/superpowers/audits/2026-05-14-portal-full-audit-report.md`:
|
||||
|
||||
```markdown
|
||||
# Portal Full Audit #3 — Summary Report (2026-05-14)
|
||||
|
||||
> **HEAD start:** `main` `08605cf`
|
||||
> **Режим:** экономия 0%
|
||||
> *(Заполняется в Task 14)*
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Commit skeletons**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-blocked.md
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-report.md
|
||||
git commit -m "docs(audit): Phase 0 pre-flight skeletons (audit #3)"
|
||||
```
|
||||
|
||||
Expected: HEAD moves to new commit on `main`.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Static analysis ×4 parallel subagents
|
||||
|
||||
Dispatch 4 subagents параллельно. Каждый пишет raw output + findings в Phase 1 секцию findings.md.
|
||||
|
||||
#### Subagent A — Backend: Pint + Larastan + composer audit
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: Pint check**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация\app"
|
||||
composer pint:test 2>&1
|
||||
```
|
||||
|
||||
Expected: `{"tool":"pint","result":"passed"}` exit 0. Если exit 1 → список файлов → P2 `[FIX-NOW]` если ≤5 файлов, иначе `[FIX-DEFER]`.
|
||||
|
||||
- [ ] **Step 2: Larastan**
|
||||
|
||||
```powershell
|
||||
composer stan 2>&1
|
||||
```
|
||||
|
||||
Expected: `{"tool":"phpstan","result":"passed","errors":0}` exit 0. Любая ошибка = P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 3: composer audit**
|
||||
|
||||
```powershell
|
||||
composer audit --locked 2>&1
|
||||
```
|
||||
|
||||
Expected: `No security vulnerability advisories found.` Любой CVE = P0 `[FIX-NOW]`.
|
||||
|
||||
#### Subagent B — Frontend: ESLint + vue-tsc + Prettier + knip
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: ESLint**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
npm run lint:vue 2>&1
|
||||
```
|
||||
|
||||
Expected: empty stdout, exit 0. Любая ошибка = P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 2: vue-tsc**
|
||||
|
||||
```powershell
|
||||
npx vue-tsc --noEmit 2>&1
|
||||
```
|
||||
|
||||
Expected: нет вывода, exit 0. Любая type error = P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 3: Prettier check на реальных (не генерированных) файлах**
|
||||
|
||||
```powershell
|
||||
npx prettier --check "app/resources/**/*.{ts,vue}" "app/tests/**/*.{ts,php}" "app/routes/**/*.php" "app/app/**/*.php" 2>&1
|
||||
```
|
||||
|
||||
Expected: ≤4 mismatches (Audit #2 в `e280edd` исправил 4 файла; проверяем что дрейфа нет). Если >0 → P2 `[FIX-DEFER]`, задокументировать количество vs Audit #2 baseline.
|
||||
|
||||
- [ ] **Step 4: knip** (проверяем что lucide false-positive не исправлен — это ожидаемо)
|
||||
|
||||
```powershell
|
||||
npx knip 2>&1 | Select-Object -First 50
|
||||
```
|
||||
|
||||
Expected: только известный false-positive `lucide-vue-next` (dynamic IconSet в vuetify.ts:19). Любой новый unused export = P2 `[FIX-DEFER]`.
|
||||
|
||||
#### Subagent C — Docs: markdownlint + cspell + lychee
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: markdownlint**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
npm run lint:md 2>&1
|
||||
```
|
||||
|
||||
Expected: 0 ошибок (Audit #2 baseline = 0). Любые новые = P2 `[FIX-NOW если ≤5 мин]`.
|
||||
|
||||
- [ ] **Step 2: cspell**
|
||||
|
||||
```powershell
|
||||
npm run spell 2>&1 | Select-Object -Last 10
|
||||
```
|
||||
|
||||
Expected: 0 issues (Audit #2 baseline = 0). Новые опечатки из 11-commit delta = P2 `[FIX-NOW]`, добавить в `cspell-words.txt`.
|
||||
|
||||
- [ ] **Step 3: lychee**
|
||||
|
||||
```powershell
|
||||
npm run links 2>&1 | Select-Object -Last 20
|
||||
```
|
||||
|
||||
Expected: 0 broken links. Audit #2 baseline: 318 OK / 0 errors. Любые новые broken links = P2 `[FIX-NOW если ≤15 мин]`.
|
||||
|
||||
#### Subagent D — SQL: squawk + pgFormatter
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: squawk**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
npx squawk 2>&1 | Select-Object -Last 20
|
||||
```
|
||||
|
||||
Expected: 0 issues (Audit #2 baseline = 0).
|
||||
|
||||
- [ ] **Step 2: pgFormatter dry-run diff count**
|
||||
|
||||
```powershell
|
||||
pg_format --dry-run db/schema.sql 2>&1 | Measure-Object -Line
|
||||
```
|
||||
|
||||
Expected: ~6284 diff lines (Q.HARD.002 defer из Audit #2 — large-schema auto-format). Если меньше → прогресс, задокументировать.
|
||||
|
||||
- [ ] **Step 3: Записать Phase 1 findings в findings.md и commit**
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): Phase 1 static analysis findings (audit #3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Test suites
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: Pest --parallel (baseline match)**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация\app"
|
||||
composer test:parallel 2>&1
|
||||
```
|
||||
|
||||
Expected: `742 tests, 739 passed, 3 skipped, 0 failed`.
|
||||
Полный вывод показать в findings. Если failed ≠ 0 → P1 `[FIX-NOW]` — investigate с ≥3 гипотезами (quirks 62/72 должны быть закрыты).
|
||||
|
||||
- [ ] **Step 2: Pest sequential**
|
||||
|
||||
```powershell
|
||||
composer test 2>&1
|
||||
```
|
||||
|
||||
Expected: `742 tests, 739 passed, 3 skipped, 0 failed` (quirk #62 fix в `9a45346` должен держать). Если fails → P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 3: Vitest**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
npm run test:vue 2>&1
|
||||
```
|
||||
|
||||
Expected: `88 files / 683 passed / 3 skipped` (Audit #2 baseline). Любые новые failures = P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 4: Histoire build**
|
||||
|
||||
```powershell
|
||||
npm run story -- --build 2>&1 | Select-Object -Last 10
|
||||
```
|
||||
|
||||
Expected: `35 stories / 63 variants` build OK. Failure = P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 5: Vite build**
|
||||
|
||||
```powershell
|
||||
npm run build 2>&1 | Select-Object -Last 10
|
||||
```
|
||||
|
||||
Expected: success, ~2–3.5s, 0 errors. Любая build error = P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 6: Записать Phase 2 findings + commit**
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): Phase 2 test suite findings (audit #3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Schema integrity (Boost MCP)
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: Root table count**
|
||||
|
||||
Boost MCP `database-query`:
|
||||
|
||||
```sql
|
||||
SELECT count(*) AS root_tables
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind = 'r'
|
||||
AND n.nspname = 'public'
|
||||
AND c.relispartition = false;
|
||||
```
|
||||
|
||||
Expected: 61 (Audit #2 dev-actual). Если ≠ 61 → разобраться; если добавились новые таблицы не из схемы → P1.
|
||||
|
||||
- [ ] **Step 2: RLS policies count (invariant)**
|
||||
|
||||
```sql
|
||||
SELECT count(*) AS rls_policies
|
||||
FROM pg_policies
|
||||
WHERE schemaname = 'public';
|
||||
```
|
||||
|
||||
Expected: **39** (строгий инвариант). Если ≠ 39 → P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 3: User functions — имена (invariant)**
|
||||
|
||||
```sql
|
||||
SELECT proname FROM pg_proc
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_proc.pronamespace
|
||||
WHERE nspname = 'public' AND prokind = 'f'
|
||||
ORDER BY proname;
|
||||
```
|
||||
|
||||
Expected ровно 5: `audit_block_mutation`, `audit_chain_hash`, `calc_lead_score`, `report_jobs_log_export`, `set_pd_subject_request_deadline`. Любое отклонение = P1.
|
||||
|
||||
- [ ] **Step 4: Orphan FK check**
|
||||
|
||||
```sql
|
||||
SELECT tc.table_name, kcu.column_name, ccu.table_name AS foreign_table
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'
|
||||
AND ccu.table_name NOT IN (
|
||||
SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'
|
||||
);
|
||||
```
|
||||
|
||||
Expected: 0 rows. Любой orphan FK = P0 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 5: Проверить schema.sql header drift (Audit #2 P2 open item)**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
Select-String -Path db/schema.sql -Pattern "v8\." | Select-Object -First 3
|
||||
```
|
||||
|
||||
Проверить: был ли обновлён header в 11-commit delta. Если header всё ещё показывает старые метрики (62/117/13) → P2 `[FIX-DEFER]` (тот же что в Audit #2).
|
||||
|
||||
- [ ] **Step 6: Записать Phase 3 findings + commit**
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): Phase 3 schema integrity findings (audit #3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Security
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: Enumerate CI workflows (methodology: `ls .github/workflows/` FIRST)**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
Get-ChildItem .github/workflows/
|
||||
```
|
||||
|
||||
Expected: `sast.yml`, `dependency-check.yml`, `trivy.yml`. Задокументировать список.
|
||||
|
||||
- [ ] **Step 2: Gitleaks delta scan (11 новых коммитов)**
|
||||
|
||||
```powershell
|
||||
./bin/gitleaks.exe detect --source . --log-opts="9e175a1..HEAD" 2>&1
|
||||
```
|
||||
|
||||
Expected: 0 leaks. Любой leak = P0 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 3: Gitleaks full history scan**
|
||||
|
||||
```powershell
|
||||
./bin/gitleaks.exe detect --source . 2>&1 | Select-Object -Last 5
|
||||
```
|
||||
|
||||
Expected: 0 leaks / ~419 commits (Audit #2 baseline: 0/401).
|
||||
|
||||
- [ ] **Step 4: No-git scan app/**
|
||||
|
||||
```powershell
|
||||
./bin/gitleaks.exe detect --source app/ --no-git 2>&1 | Select-Object -Last 5
|
||||
```
|
||||
|
||||
Expected: 0 secrets.
|
||||
|
||||
- [ ] **Step 5: Записать Phase 4 findings + commit**
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): Phase 4 security findings (audit #3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: UI smoke (Playwright MCP — 24-view sweep)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
- Create: `docs/superpowers/audits/audit-screens/2026-05-14/` (screenshots)
|
||||
|
||||
Перед smoke: убедиться что dev-сервер запущен (`php artisan serve` на :8000 + `npm run dev`).
|
||||
|
||||
- [ ] **Step 1: Запустить dev-сервер (если не запущен)**
|
||||
|
||||
```powershell
|
||||
# В отдельном терминале:
|
||||
cd "C:\моя\проекты\портал crm\Документация\app"
|
||||
php artisan serve --port=8000
|
||||
# И в другом:
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Проверить: `http://localhost:8000/login` доступен.
|
||||
|
||||
- [ ] **Step 2: Login + screenshot**
|
||||
|
||||
Playwright MCP `browser_navigate` → `http://localhost:8000/login`
|
||||
Playwright MCP `browser_take_screenshot` → `audit-screens/2026-05-14/01-login.png`
|
||||
Playwright MCP `browser_fill_form` (email + password) + `browser_click` submit
|
||||
Expected: редирект на `/dashboard`. `browser_take_screenshot` → `02-dashboard.png`.
|
||||
|
||||
- [ ] **Step 3: Sweep аутентифицированных views**
|
||||
|
||||
Для каждого из 24 маршрутов (источник: `routes/web.php`):
|
||||
`/dashboard`, `/projects`, `/reminders`, `/notifications`, `/profile`, `/billing`, `/admin/tenants`, `/admin/billing`, `/admin/pricing-tiers`, `/admin/supplier-prices`, `/admin/incidents`, `/admin/system`, `/login`, `/register`, `/2fa`, `/forgot`, `/recovery`
|
||||
|
||||
Для каждого:
|
||||
|
||||
- `browser_navigate` → URL
|
||||
- `browser_take_screenshot` → `NN-<view>.png`
|
||||
- Проверить: страница рендерится (не blank), 0 JS error в console, Lucide иконки видны
|
||||
- DevIndexBadge не должен быть виден (должен tree-shaken в prod build; если dev-сервер — может присутствовать, задокументировать)
|
||||
|
||||
- [ ] **Step 4: Error views**
|
||||
|
||||
Navigate → `/this-page-does-not-exist` → check 404 page.
|
||||
Navigate → любой protected route без auth (logout сначала) → check redirect.
|
||||
`browser_take_screenshot` → `25-404.png`
|
||||
|
||||
- [ ] **Step 5: Logout flow**
|
||||
|
||||
Кликнуть logout button или navigate `/logout`.
|
||||
Expected: redirect на `/login`.
|
||||
|
||||
- [ ] **Step 6: Записать Phase 5 findings + commit**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git add docs/superpowers/audits/audit-screens/2026-05-14/
|
||||
git commit -m "docs(audit): Phase 5 UI smoke 24-view Playwright sweep (audit #3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Cross-doc integrity
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: Проверить версии нормативных документов**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
(Select-String -Path CLAUDE.md -Pattern "Версия:").Line | Select-Object -First 1
|
||||
(Select-String -Path docs/Pravila_raboty_Claude_v1_1.md -Pattern "^# ").Line | Select-Object -First 1
|
||||
(Select-String -Path docs/Plugin_stack_rules_v1.md -Pattern "^# ").Line | Select-Object -First 1
|
||||
(Select-String -Path docs/Tooling_v8_3.md -Pattern "^# ").Line | Select-Object -First 1
|
||||
(Select-String -Path "docs/Открытые_вопросы_v8_3.md" -Pattern "^# ").Line | Select-Object -First 1
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
- CLAUDE.md: v1.92 от 13.05.2026
|
||||
- Pravila: v1.13 от 13.05.2026
|
||||
- PSR_v1: v2.1 от 13.05.2026
|
||||
- Tooling: v1.17 от 13.05.2026
|
||||
- Реестр: v1.83 от 13.05.2026
|
||||
|
||||
Любое несоответствие с memory → P2 `[FIX-DEFER]`.
|
||||
|
||||
- [ ] **Step 2: Verify schema.sql version marker**
|
||||
|
||||
```powershell
|
||||
Select-String -Path db/schema.sql -Pattern "v8\." | Select-Object -First 3
|
||||
```
|
||||
|
||||
Expected: `v8.20` (Audit #2 baseline). Если другое — задокументировать.
|
||||
|
||||
- [ ] **Step 3: routes/web.php completeness**
|
||||
|
||||
```powershell
|
||||
Select-String -Path app/routes/web.php -Pattern "Route::view" | Measure-Object
|
||||
cat app/routes/web.php
|
||||
```
|
||||
|
||||
Expected: 26+ явных `Route::view` + fallback + SPA-catch-all. Проверить что все известные views присутствуют.
|
||||
|
||||
- [ ] **Step 4: Verify .mcp.json servers**
|
||||
|
||||
```powershell
|
||||
cat .mcp.json
|
||||
```
|
||||
|
||||
Expected: 5 servers: `laravel-boost`, `playwright`, `github`, `sentry`, `redis`. Если какого нет → P2.
|
||||
|
||||
- [ ] **Step 5: Записать Phase 6 findings + commit**
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): Phase 6 cross-doc integrity findings (audit #3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: A11y targeted — Pa11y + axe-core P3 follow-up
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: Pa11y 4 guest URLs**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
npm run a11y 2>&1
|
||||
```
|
||||
|
||||
Expected: `No issues found!` на всех 4 guest URLs. Регрессия = P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 2: axe-core verify aria-label fix на /admin/tenants (bf84568)**
|
||||
|
||||
Playwright MCP (залогиненным как admin) `browser_navigate` → `http://localhost:8000/admin/tenants`
|
||||
`browser_evaluate`:
|
||||
|
||||
```javascript
|
||||
const { default: axe } = await import('https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.0/axe.min.js');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const results = await axe.run();
|
||||
return results.violations.filter(v => v.id === 'aria-tooltip-name').map(v => ({ id: v.id, count: v.nodes.length, help: v.help }));
|
||||
```
|
||||
|
||||
Expected: `[]` — 0 `aria-tooltip-name` violations (bf84568 fix должен держать). Если violation вернулся → P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 3: axe-core full scan /admin/tenants — все violations**
|
||||
|
||||
```javascript
|
||||
const { default: axe } = await import('https://cdnjs.cloudflare.com/ajax/libs/axe-core/4.10.0/axe.min.js');
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const results = await axe.run();
|
||||
return results.violations.map(v => ({ id: v.id, impact: v.impact, count: v.nodes.length, help: v.help }));
|
||||
```
|
||||
|
||||
Задокументировать полный список violations. P3 known items из Audit #2: `region` (sitewide) + `empty-table-header` + `page-has-heading-one`. Любое новое critical/serious = P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 4: axe-core scan /dashboard + /projects (проверка P3 drift)**
|
||||
|
||||
Аналогично Step 3, на двух ключевых authenticated views. Задокументировать любое изменение vs Audit #2 P3 baseline.
|
||||
|
||||
- [ ] **Step 5: Записать Phase 7 a11y findings + commit**
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): Phase 7 a11y targeted Pa11y+axe-core (audit #3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Coverage targeted (Vitest --coverage)
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: Запустить coverage (quirk #80 fix `testTimeout:10000` должен держать)**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
npm run test:vue -- --coverage 2>&1
|
||||
```
|
||||
|
||||
Показать полный вывод. Expected: 0 timeouts (quirk #80 закрыт в `58986a2`). Totals ≥ Audit #2 baseline:
|
||||
|
||||
- Stmts: ≥78.30%
|
||||
- Branch: ≥75.78%
|
||||
- Funcs: ≥70.12%
|
||||
- Lines: ≥80.47%
|
||||
|
||||
Любое падение ниже baseline → P2 `[FIX-DEFER — coverage regression]`.
|
||||
|
||||
- [ ] **Step 2: Check known P2 low-coverage files**
|
||||
|
||||
Из coverage output найти:
|
||||
|
||||
- `ReminderDialog.vue` — Audit #2 baseline: 0%. Текущее %? Если всё ещё 0% → P2 `[FIX-DEFER — нужен новый spec]`.
|
||||
- `AdminLayout.vue` — Audit #2 baseline: 9%. Текущее %?
|
||||
- `api/admin.ts` — Audit #2 baseline: 11%. Текущее %?
|
||||
|
||||
Задокументировать дельту по каждому.
|
||||
|
||||
- [ ] **Step 3: Записать Phase 8 findings + commit**
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): Phase 8 coverage targeted (audit #3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Bundle analyzer delta
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: Build с visualizer (если не был только что запущен в Task 3)**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
npm run build -- --config app/vite.config.ts 2>&1 | Select-Object -Last 10
|
||||
```
|
||||
|
||||
Expected: success. Проверить что `app/storage/bundle-analyze.html` обновился.
|
||||
|
||||
- [ ] **Step 2: Запустить parse-bundle-analyze.mjs**
|
||||
|
||||
```powershell
|
||||
node app/scripts/parse-bundle-analyze.mjs 2>&1
|
||||
```
|
||||
|
||||
Expected: Top-15 chunks table + Critical-path eager total. Audit #2 baseline: ~189 kB gzip.
|
||||
Задокументировать: top-5 chunks + eager total gzip/brotli. Если eager total вырос >10 kB vs baseline → P2 `[FIX-DEFER — bundle bloat]`.
|
||||
|
||||
- [ ] **Step 3: Записать Phase 9 findings + commit**
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): Phase 9 bundle analyzer delta (audit #3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Pre-prod gaps + TODO sweep + untracked files
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: Verify partitions:create-months в schedule**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация\app"
|
||||
php artisan schedule:list 2>&1 | Select-String "partition"
|
||||
```
|
||||
|
||||
Expected: `partitions:create-months` appears (закрыто `9530d17`). Если нет → P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 2: Verify RUNBOOK.md**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
Measure-Object -InputObject (Get-Content RUNBOOK.md -Raw) -Line
|
||||
Select-String -Path RUNBOOK.md -Pattern "^## " | Select-Object -First 15
|
||||
```
|
||||
|
||||
Expected: 100+ строк, 13 секций (закрыто `b241c79`). Если файл не найден → P1 `[FIX-NOW]`.
|
||||
|
||||
- [ ] **Step 3: .env.example full diff (Audit #2 Phase 14.5 был unverified)**
|
||||
|
||||
```powershell
|
||||
cat app/.env.example | Measure-Object -Line
|
||||
cat app/.env.example
|
||||
```
|
||||
|
||||
Expected: ~80 key-value pairs. Проверить наличие критических групп: `APP_*`, `DB_*`, `REDIS_*`, `MAIL_*`, `QUEUE_*`, `SENTRY_*`. Если critical группа отсутствует → P2 `[FIX-DEFER]`.
|
||||
|
||||
- [ ] **Step 4: Sentry prod SDK статус**
|
||||
|
||||
```powershell
|
||||
cd app
|
||||
composer show sentry/sentry-laravel 2>&1
|
||||
```
|
||||
|
||||
Expected: `Package sentry/sentry-laravel not found` → P2 `[FIX-DEFER pending Б-1]` (без изменений vs Audit #2).
|
||||
|
||||
- [ ] **Step 5: TODO/FIXME sweep**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
Select-String -Path app/app/, app/resources/ -Pattern "TODO|FIXME|HACK|XXX" -Recurse -Include "*.php", "*.vue", "*.ts" | Measure-Object
|
||||
Select-String -Path app/app/, app/resources/ -Pattern "TODO|FIXME|HACK|XXX" -Recurse -Include "*.php", "*.vue", "*.ts"
|
||||
```
|
||||
|
||||
Audit #2 baseline: 19 matches. Задокументировать delta. Любой новый TODO не связанный с Б-1 = P2 `[FIX-DEFER]`.
|
||||
|
||||
- [ ] **Step 6: Commit 3 untracked files (все полезные — закоммитить)**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
git add app/scripts/parse-bundle-analyze.mjs
|
||||
git add docs/superpowers/plans/2026-05-13-automation-graph.md
|
||||
git add docs/superpowers/plans/2026-05-13-pest-p1-quirks-fix.md
|
||||
git commit -m "docs: commit untracked audit+plan artefacts (parse-bundle-analyze.mjs + 2 plan docs)"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Записать Phase 10-12 findings + commit**
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): Phase 10-12 pre-prod/TODO/untracked findings (audit #3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Categorize + fix loop (P0+P1 only)
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: Подсчитать findings по severity**
|
||||
|
||||
Просмотреть все Phase 1-12 entries в findings.md. Составить rollup:
|
||||
|
||||
```
|
||||
P0: N
|
||||
P1: N
|
||||
P2: N
|
||||
P3: N
|
||||
FIX-NOW eligible (P0+P1): list
|
||||
```
|
||||
|
||||
Сравнить vs Audit #2: P0=0 / P1=5 / P2=15 / P3=18+.
|
||||
|
||||
- [ ] **Step 2: Если P0 найдены — fix немедленно**
|
||||
|
||||
Для каждого P0:
|
||||
|
||||
1. Понять root cause (≥3 гипотезы per `systematic-debugging`)
|
||||
2. Написать failing test
|
||||
3. Реализовать fix
|
||||
4. Прогнать `composer test:parallel` → должен быть 0 failed
|
||||
5. `git commit -m "fix: [description] (audit #3 P0)"`
|
||||
|
||||
- [ ] **Step 3: Если P1 FIX-NOW найдены — fix каждый атомарно**
|
||||
|
||||
Для каждого P1 `[FIX-NOW]`:
|
||||
|
||||
1. Один atomic commit per finding
|
||||
2. После каждого: `composer test:parallel && npm run test:vue`
|
||||
3. Шаблон commit: `fix: [description] — audit #3 P1`
|
||||
|
||||
**Ожидаемые кандидаты P1 (если появятся):**
|
||||
|
||||
*Larastan error (example):*
|
||||
|
||||
```php
|
||||
// В app/app/SomeClass.php
|
||||
// Исправить конкретный type annotation per Larastan output
|
||||
```
|
||||
|
||||
*vue-tsc error (example):*
|
||||
|
||||
```typescript
|
||||
// В app/resources/js/SomeComponent.vue
|
||||
// Исправить конкретный type error per vue-tsc output
|
||||
```
|
||||
|
||||
*Pest failure (unexpected):*
|
||||
|
||||
```
|
||||
// Прогнать в isolation: composer test -- --filter "TestName"
|
||||
// Найти root cause (quirk? regression?) → fix
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Обновить findings.md после fix loop**
|
||||
|
||||
Пометить каждый закрытый P1 как `[FIX-NOW ✅ fixed in <commit-hash>]`.
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Final regression
|
||||
|
||||
**Files:** Modify `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
|
||||
- [ ] **Step 1: Full Pest --parallel**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация\app"
|
||||
composer test:parallel 2>&1
|
||||
```
|
||||
|
||||
Expected: `742/739/3sk/0 failed`. Показать полный вывод. Если failed > 0 → STOP, investigate, fix до репорта.
|
||||
|
||||
- [ ] **Step 2: Full Vitest**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
npm run test:vue 2>&1
|
||||
```
|
||||
|
||||
Expected: `88/683/3sk`. Показать полный вывод.
|
||||
|
||||
- [ ] **Step 3: Vite build**
|
||||
|
||||
```powershell
|
||||
npm run build 2>&1 | Select-Object -Last 5
|
||||
```
|
||||
|
||||
Expected: success, 0 errors.
|
||||
|
||||
- [ ] **Step 4: gitleaks delta (все audit commits)**
|
||||
|
||||
```powershell
|
||||
./bin/gitleaks.exe detect --source . 2>&1 | Select-Object -Last 5
|
||||
```
|
||||
|
||||
Expected: 0 leaks.
|
||||
|
||||
- [ ] **Step 5: lychee**
|
||||
|
||||
```powershell
|
||||
npm run links 2>&1 | Select-Object -Last 5
|
||||
```
|
||||
|
||||
Expected: 0 broken.
|
||||
|
||||
- [ ] **Step 6: Записать Phase 9 final regression + commit**
|
||||
|
||||
```powershell
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): Phase 9 final regression (audit #3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: Report + memory update + push
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/superpowers/audits/2026-05-14-portal-full-audit-report.md`
|
||||
- Modify: `docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md`
|
||||
- Create/Modify: `C:\Users\Administrator\.claude\projects\c---------------------crm-------------\memory\project_full_audit_2026-05-14.md`
|
||||
- Modify: `C:\Users\Administrator\.claude\projects\c---------------------crm-------------\memory\project_state.md`
|
||||
- Modify: `C:\Users\Administrator\.claude\projects\c---------------------crm-------------\memory\MEMORY.md`
|
||||
|
||||
- [ ] **Step 1: Написать итоговый report.md**
|
||||
|
||||
Заполнить `2026-05-14-portal-full-audit-report.md`:
|
||||
|
||||
```markdown
|
||||
# Portal Full Audit #3 — Summary Report (2026-05-14)
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Findings:** N (P0: N, P1: N, P2: N, P3: N)
|
||||
- **Fixed in code:** N atomic fix-commits
|
||||
- **Deferred:** N P1 + N P2 + N P3 documented
|
||||
- **Blocked Q-items:** 0
|
||||
- **vs Audit #2:** [delta]
|
||||
|
||||
## По phase'ам (детали в findings.md)
|
||||
[1-sentence per phase]
|
||||
|
||||
## Метрики до/после
|
||||
|
||||
| Метрика | Phase 2 baseline | Audit #2 baseline | Delta |
|
||||
|---|---|---|---|
|
||||
| Pest --parallel | ... | 742/739/3sk | ... |
|
||||
| Vitest | ... | 88/683/3sk | ... |
|
||||
| Histoire | ... | 35/63 | ... |
|
||||
| Vite build | ...s | ~2-3.5s | ... |
|
||||
| ESLint | 0 | 0 | 0 |
|
||||
| vue-tsc | 0 | 0 | 0 |
|
||||
| Coverage Stmts/Branch/Funcs/Lines | ... | 78.30/75.78/70.12/80.47 | ... |
|
||||
| gitleaks | 0 | 0/401 | ... |
|
||||
| lychee | 0 | 0/318 | ... |
|
||||
| RLS policies | 39 | 39 | 0 |
|
||||
|
||||
## Verdict
|
||||
|
||||
🟢/🟡/🔴 [reasoning]
|
||||
|
||||
## Commits made
|
||||
[list]
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update memory — project_full_audit_2026-05-14.md**
|
||||
|
||||
Создать `C:\Users\Administrator\.claude\projects\c---------------------crm-------------\memory\project_full_audit_2026-05-14.md` с результатами.
|
||||
|
||||
- [ ] **Step 3: Update memory — project_state.md**
|
||||
|
||||
Добавить секцию "Portal Full Audit #3 (2026-05-14)" с HEAD, metrics, open items.
|
||||
|
||||
- [ ] **Step 4: Update MEMORY.md index**
|
||||
|
||||
Добавить строку для нового memory файла audit #3.
|
||||
|
||||
- [ ] **Step 5: Финальный commit**
|
||||
|
||||
```powershell
|
||||
cd "C:\моя\проекты\портал crm\Документация"
|
||||
git rev-parse --abbrev-ref HEAD
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-report.md
|
||||
git add docs/superpowers/audits/2026-05-14-portal-full-audit-findings.md
|
||||
git commit -m "docs(audit): finalize portal full audit #3 — report (2026-05-14)"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Push после user confirmation**
|
||||
|
||||
```powershell
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Pre-push hooks: gitleaks-full-history + lychee. Expected: 0 leaks / 0 broken.
|
||||
|
||||
---
|
||||
|
||||
## Self-review
|
||||
|
||||
### Spec coverage
|
||||
|
||||
- ✅ Pre-flight (Task 1)
|
||||
- ✅ Static analysis ×4 parallel (Task 2)
|
||||
- ✅ Test suites Pest/Vitest/Histoire/Vite (Task 3)
|
||||
- ✅ Schema integrity RLS/FK/functions (Task 4)
|
||||
- ✅ Security gitleaks + CI workflows methodology (Task 5)
|
||||
- ✅ UI smoke 24-view (Task 6)
|
||||
- ✅ Cross-doc integrity normative docs (Task 7)
|
||||
- ✅ A11y Pa11y + axe-core P3 follow-up (Task 8)
|
||||
- ✅ Coverage targeted known P2 files (Task 9)
|
||||
- ✅ Bundle analyzer delta vs Audit #2 (Task 10)
|
||||
- ✅ Pre-prod gaps (schedule/RUNBOOK/.env/Sentry) (Task 11)
|
||||
- ✅ TODO/FIXME sweep update (Task 11)
|
||||
- ✅ Untracked files committed (Task 11)
|
||||
- ✅ Categorize hybrid policy + fix loop P0/P1 (Task 12)
|
||||
- ✅ Final regression all tools (Task 13)
|
||||
- ✅ Report + memory + push (Task 14)
|
||||
|
||||
### Open P2 items carryforward из Audit #2 (tracked в этом плане)
|
||||
|
||||
- knip lucide false-positive → Task 2 Subagent B Step 4
|
||||
- schema.sql header drift → Task 4 Step 5
|
||||
- prettier drift (остаток 312-4=~38 файлов) → Task 2 Subagent B Step 3
|
||||
- coverage debt ReminderDialog/AdminLayout/api/admin.ts → Task 9 Step 2
|
||||
- KanbanView code-split candidate → Task 10 Step 2 (bundle delta)
|
||||
- Sentry prod SDK → Task 11 Step 4
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,801 +0,0 @@
|
||||
# Переделка миграции проектов — План 1: фундамент данных
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Заложить модель данных под per-субъект экспорт и cap-распределение: `supplier_projects.subject_code`, pivot `project_supplier_links` (замена 3 FK-слотов), `deals.subject_code`, сид `system_settings.supplier_export_mode`, бэкофилл существующих привязок.
|
||||
|
||||
**Architecture:** Инкрементальные миграции (raw SQL, idempotent guard через `to_regclass`/`Schema::hasColumn`/`IF NOT EXISTS`) + параллельная правка консолидированного `db/schema.sql` + запись в `db/CHANGELOG_schema.md`. Pivot — SaaS-level (без RLS, как `supplier_projects`): пишется sync-флоу, читается sharing-флоу через BYPASSRLS-роль `crm_supplier_worker`. Колонки `projects.supplier_b{1,2,3}_project_id` остаются (двойная запись) до зелёной регрессии — удаление в follow-up.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / PostgreSQL 16 (NULLS NOT DISTINCT для unique-индекса пула «Вся РФ») / Pest 4 / squawk (линт миграций) / Eloquent belongsToMany.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../specs/2026-05-20-project-migration-redesign-design.md) §4.2, §5.
|
||||
|
||||
**Prereq:** выполняется в изолированном worktree (см. `superpowers:using-git-worktrees`) от свежего `origin/main`, либо на ветке `feat/project-migration-redesign`. После каждой миграции — `php artisan migrate:fresh` smoke не запускать на каждом шаге; полный прогон — Task 7.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `supplier_projects.subject_code` + составной unique-индекс
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php`
|
||||
- Modify: `db/schema.sql:902-930` (блок `CREATE TABLE supplier_projects` + индекс)
|
||||
- Modify: `db/CHANGELOG_schema.md` (новая запись сверху)
|
||||
- Test: `app/tests/Feature/Supplier/SupplierProjectSubjectCodeTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Database\QueryException;
|
||||
|
||||
it('allows same (platform, unique_key) with different subject_code', function (): void {
|
||||
SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'okna.ru',
|
||||
'subject_code' => 83, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
expect(SupplierProject::query()->where('unique_key', 'okna.ru')->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('rejects duplicate (platform, unique_key, subject_code) including NULL pool', function (): void {
|
||||
SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'pool.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
expect(fn () => SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'pool.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
it('rejects subject_code out of 1..89 range', function (): void {
|
||||
expect(fn () => SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bad.ru',
|
||||
'subject_code' => 90, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — убедиться, что падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierProjectSubjectCodeTest`
|
||||
Expected: FAIL — `subject_code` ещё не fillable / колонки нет (массовое присвоение игнорит поле, unique старый, тесты падают на count/отсутствии исключения).
|
||||
|
||||
- [ ] **Step 3: Написать миграцию**
|
||||
|
||||
`app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Per-субъект supplier_projects (эпик переделки миграции проектов, v8.26).
|
||||
*
|
||||
* +subject_code SMALLINT NULL (1..89 субъект РФ; NULL = пул «Вся РФ»).
|
||||
* Старый unique (platform, unique_key) → (platform, unique_key, subject_code)
|
||||
* NULLS NOT DISTINCT — пул «Вся РФ» уникален per (platform, unique_key).
|
||||
*
|
||||
* Guard: migrate:fresh грузит schema.sql v8.26 (delta уже там) до миграций.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('supplier_projects', 'subject_code')) {
|
||||
DB::statement('ALTER TABLE supplier_projects ADD COLUMN subject_code SMALLINT');
|
||||
}
|
||||
|
||||
DB::statement(<<<'SQL'
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'chk_supplier_projects_subject_code'
|
||||
) THEN
|
||||
ALTER TABLE supplier_projects
|
||||
ADD CONSTRAINT chk_supplier_projects_subject_code
|
||||
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89)) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
SQL);
|
||||
DB::statement('ALTER TABLE supplier_projects VALIDATE CONSTRAINT chk_supplier_projects_subject_code');
|
||||
|
||||
DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_unique_key_unique');
|
||||
DB::statement(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_key_subject_unique '
|
||||
.'ON supplier_projects (platform, unique_key, subject_code) NULLS NOT DISTINCT'
|
||||
);
|
||||
|
||||
DB::statement(
|
||||
"COMMENT ON COLUMN supplier_projects.subject_code IS "
|
||||
."'Субъект РФ 1..89 (resources/js/constants/regions.ts). NULL = пул «Вся РФ». Эпик миграции проектов v8.26.'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP INDEX IF EXISTS supplier_projects_platform_key_subject_unique');
|
||||
DB::statement(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS supplier_projects_platform_unique_key_unique '
|
||||
.'ON supplier_projects (platform, unique_key)'
|
||||
);
|
||||
DB::statement('ALTER TABLE supplier_projects DROP CONSTRAINT IF EXISTS chk_supplier_projects_subject_code');
|
||||
|
||||
if (Schema::hasColumn('supplier_projects', 'subject_code')) {
|
||||
Schema::table('supplier_projects', fn ($t) => $t->dropColumn('subject_code'));
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Зеркалить в `db/schema.sql`**
|
||||
|
||||
В блоке `CREATE TABLE supplier_projects` ([schema.sql:902](../../../db/schema.sql#L902)) после строки `current_regions JSONB,` добавить:
|
||||
|
||||
```sql
|
||||
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ» (v8.26)
|
||||
```
|
||||
|
||||
В список CHECK добавить:
|
||||
|
||||
```sql
|
||||
,
|
||||
CONSTRAINT chk_supplier_projects_subject_code
|
||||
CHECK (subject_code IS NULL OR (subject_code BETWEEN 1 AND 89))
|
||||
```
|
||||
|
||||
Заменить индекс `supplier_projects_platform_unique_key_unique` ([schema.sql:929-930](../../../db/schema.sql#L929)) на:
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX supplier_projects_platform_key_subject_unique
|
||||
ON supplier_projects(platform, unique_key, subject_code) NULLS NOT DISTINCT;
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Записать в `db/CHANGELOG_schema.md`** (новая запись сверху, над v8.25)
|
||||
|
||||
```markdown
|
||||
## v8.26 — 2026-05-20 — supplier_projects.subject_code (per-субъект экспорт)
|
||||
|
||||
`supplier_projects` +1 колонка `subject_code SMALLINT NULL` (1..89; NULL = пул «Вся РФ»),
|
||||
+1 CHECK `chk_supplier_projects_subject_code`. Unique-индекс
|
||||
`supplier_projects_platform_unique_key_unique` (platform, unique_key) → заменён на
|
||||
`supplier_projects_platform_key_subject_unique` (platform, unique_key, subject_code)
|
||||
NULLS NOT DISTINCT (пул «Вся РФ» уникален per источник×платформа).
|
||||
Эпик: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.2.
|
||||
Миграция: 2026_05_20_100000_supplier_projects_subject_code.php (Schema::hasColumn +
|
||||
pg_constraint guards). Индексы: −1 +1 (нет дельты count). RLS не затронут (SaaS-level).
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Сделать `subject_code` fillable в модели**
|
||||
|
||||
В `app/app/Models/SupplierProject.php` в `$fillable` ([SupplierProject.php:32](../../../app/app/Models/SupplierProject.php#L32)) добавить `'subject_code',`; в `casts()` ([:45](../../../app/app/Models/SupplierProject.php#L45)) добавить `'subject_code' => 'integer',`.
|
||||
|
||||
- [ ] **Step 7: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=SupplierProjectSubjectCodeTest`
|
||||
Expected: PASS (3 теста). Вывести полный вывод.
|
||||
|
||||
- [ ] **Step 8: squawk + pint + larastan на изменённое**
|
||||
|
||||
Run: `composer pint -- app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php app/app/Models/SupplierProject.php; composer stan`
|
||||
Expected: 0 ошибок (larastan может потребовать `php artisan ide-helper:models -W` для нового атрибута — выполнить при @property-ошибке).
|
||||
|
||||
- [ ] **Step 9: Коммит** (явный путь — shared-dir)
|
||||
|
||||
```bash
|
||||
git add -- app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php app/app/Models/SupplierProject.php app/tests/Feature/Supplier/SupplierProjectSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
git commit -m "feat(supplier): supplier_projects.subject_code + per-subject unique index (v8.26)" -- app/database/migrations/2026_05_20_100000_supplier_projects_subject_code.php app/app/Models/SupplierProject.php app/tests/Feature/Supplier/SupplierProjectSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: pivot-таблица `project_supplier_links` (замена 3 FK-слотов)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_20_101000_create_project_supplier_links.php`
|
||||
- Modify: `db/schema.sql` (после блока ALTER TABLE projects FK ~[schema.sql:947](../../../db/schema.sql#L947))
|
||||
- Modify: `db/CHANGELOG_schema.md`
|
||||
- Test: `app/tests/Feature/Supplier/ProjectSupplierLinkSchemaTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('creates pivot row linking project to supplier_project', function (): void {
|
||||
$project = Project::factory()->create();
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'link.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => 'B1',
|
||||
'subject_code' => 82,
|
||||
]);
|
||||
|
||||
expect(DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)->where('supplier_project_id', $sp->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('cascades pivot deletion when supplier_project is deleted', function (): void {
|
||||
$project = Project::factory()->create();
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'casc.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id, 'supplier_project_id' => $sp->id, 'platform' => 'B2', 'subject_code' => 82,
|
||||
]);
|
||||
|
||||
$sp->delete();
|
||||
|
||||
expect(DB::table('project_supplier_links')->where('supplier_project_id', $sp->id)->exists())->toBeFalse();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=ProjectSupplierLinkSchemaTest`
|
||||
Expected: FAIL — таблицы `project_supplier_links` нет.
|
||||
|
||||
- [ ] **Step 3: Написать миграцию**
|
||||
|
||||
`app/database/migrations/2026_05_20_101000_create_project_supplier_links.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* M:N pivot между projects (tenant) и supplier_projects (SaaS, shared) — v8.26.
|
||||
*
|
||||
* Заменяет 3 FK-слота projects.supplier_b{1,2,3}_project_id (которые не вмещают
|
||||
* per-субъект модель: N субъектов × до 3 платформ = до 3N связей).
|
||||
* SaaS-level (без RLS, как supplier_projects): пишется sync-флоу, читается
|
||||
* sharing-флоу через BYPASSRLS-роль crm_supplier_worker.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$exists = DB::selectOne("SELECT to_regclass('public.project_supplier_links') AS r");
|
||||
if ($exists !== null && $exists->r !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::unprepared(<<<'SQL'
|
||||
CREATE TABLE project_supplier_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
subject_code SMALLINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1', 'B2', 'B3')),
|
||||
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_psl_supplier_project ON project_supplier_links (supplier_project_id);
|
||||
CREATE INDEX idx_psl_project ON project_supplier_links (project_id);
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('DROP TABLE IF EXISTS project_supplier_links');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Зеркалить в `db/schema.sql`** — после блока `ALTER TABLE projects ADD CONSTRAINT projects_supplier_b3_project_id_fk ...` ([schema.sql:947](../../../db/schema.sql#L947)) добавить:
|
||||
|
||||
```sql
|
||||
|
||||
-- v8.26: M:N pivot projects ↔ supplier_projects (замена 3 FK-слотов supplier_b{1,2,3}_project_id).
|
||||
CREATE TABLE project_supplier_links (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
supplier_project_id BIGINT NOT NULL REFERENCES supplier_projects(id) ON DELETE CASCADE,
|
||||
platform VARCHAR(4) NOT NULL,
|
||||
subject_code SMALLINT, -- субъект РФ 1..89; NULL = пул «Вся РФ»
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_psl_platform CHECK (platform IN ('B1','B2','B3')),
|
||||
CONSTRAINT uq_psl_project_supplier UNIQUE (project_id, supplier_project_id)
|
||||
);
|
||||
CREATE INDEX idx_psl_supplier_project ON project_supplier_links(supplier_project_id);
|
||||
CREATE INDEX idx_psl_project ON project_supplier_links(project_id);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Записать в `db/CHANGELOG_schema.md`** (запись сверху)
|
||||
|
||||
```markdown
|
||||
## v8.26 (доп) — 2026-05-20 — project_supplier_links (M:N pivot)
|
||||
|
||||
+1 таблица SaaS-level `project_supplier_links` (project_id, supplier_project_id,
|
||||
platform, subject_code, created_at): M:N замена 3 FK-слотов
|
||||
projects.supplier_b{1,2,3}_project_id (per-субъект модель). +2 FK (оба ON DELETE
|
||||
CASCADE), +1 CHECK chk_psl_platform, +1 UNIQUE uq_psl_project_supplier, +2 индекса.
|
||||
Без RLS (как supplier_projects). Старые FK-колонки остаются (двойная запись) до
|
||||
follow-up. Миграция: 2026_05_20_101000_create_project_supplier_links.php.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=ProjectSupplierLinkSchemaTest`
|
||||
Expected: PASS (2). Полный вывод.
|
||||
|
||||
- [ ] **Step 7: squawk-чек миграции**
|
||||
|
||||
Run: `composer pint -- app/database/migrations/2026_05_20_101000_create_project_supplier_links.php`
|
||||
Expected: OK. (squawk прогонится в pre-commit; CREATE TABLE без backfill — safe.)
|
||||
|
||||
- [ ] **Step 8: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/database/migrations/2026_05_20_101000_create_project_supplier_links.php app/tests/Feature/Supplier/ProjectSupplierLinkSchemaTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
git commit -m "feat(supplier): project_supplier_links M:N pivot (v8.26)" -- app/database/migrations/2026_05_20_101000_create_project_supplier_links.php app/tests/Feature/Supplier/ProjectSupplierLinkSchemaTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `deals.subject_code` (регион-тег поставщика)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_20_102000_deals_subject_code.php`
|
||||
- Modify: `db/schema.sql` (блок `CREATE TABLE deals` рядом с `region_code` [schema.sql:1612](../../../db/schema.sql#L1612))
|
||||
- Modify: `db/CHANGELOG_schema.md`
|
||||
- Test: `app/tests/Feature/Supplier/DealSubjectCodeTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
it('deals has nullable subject_code column', function (): void {
|
||||
expect(Schema::hasColumn('deals', 'subject_code'))->toBeTrue();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=DealSubjectCodeTest`
|
||||
Expected: FAIL — колонки нет.
|
||||
|
||||
- [ ] **Step 3: Написать миграцию**
|
||||
|
||||
`app/database/migrations/2026_05_20_102000_deals_subject_code.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* deals.subject_code — субъект РФ из тега поставщика (raw_payload['tag']) — v8.26.
|
||||
*
|
||||
* Источник истины региона сделки = тег проекта у поставщика (надёжнее phone-prefix
|
||||
* для мобильных). Отдельно от deals.region_code (ISO-3166, phone-derived).
|
||||
* deals партиционирована — ADD COLUMN наследуется партициями.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('deals', 'subject_code')) {
|
||||
DB::statement('ALTER TABLE deals ADD COLUMN subject_code SMALLINT');
|
||||
}
|
||||
|
||||
DB::statement(
|
||||
"COMMENT ON COLUMN deals.subject_code IS "
|
||||
."'Субъект РФ 1..89 из тега поставщика (raw_payload[tag]). NULL = «Вся РФ»/неизвестно. v8.26.'"
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('deals', 'subject_code')) {
|
||||
Schema::table('deals', fn ($t) => $t->dropColumn('subject_code'));
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Зеркалить в `db/schema.sql`** — в блоке `CREATE TABLE deals` после строки `region_code VARCHAR(8),` ([schema.sql:1612](../../../db/schema.sql#L1612)) добавить:
|
||||
|
||||
```sql
|
||||
subject_code SMALLINT, -- v8.26: субъект РФ 1..89 из тега поставщика (raw_payload[tag]); NULL = вся РФ/неизвестно
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Записать в `db/CHANGELOG_schema.md`** (сверху)
|
||||
|
||||
```markdown
|
||||
## v8.26 (доп) — 2026-05-20 — deals.subject_code
|
||||
|
||||
`deals` +1 колонка `subject_code SMALLINT NULL` — субъект РФ из тега поставщика
|
||||
(raw_payload[tag]); отдельно от region_code (ISO, phone-derived). Наследуется
|
||||
12 партициями. Миграция: 2026_05_20_102000_deals_subject_code.php.
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Сделать `subject_code` fillable в `Deal`**
|
||||
|
||||
В `app/app/Models/Deal.php` добавить `'subject_code'` в `$fillable` и `'subject_code' => 'integer'` в `casts()`.
|
||||
|
||||
- [ ] **Step 7: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=DealSubjectCodeTest`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 8: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/database/migrations/2026_05_20_102000_deals_subject_code.php app/app/Models/Deal.php app/tests/Feature/Supplier/DealSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
git commit -m "feat(supplier): deals.subject_code from supplier tag (v8.26)" -- app/database/migrations/2026_05_20_102000_deals_subject_code.php app/app/Models/Deal.php app/tests/Feature/Supplier/DealSubjectCodeTest.php db/schema.sql db/CHANGELOG_schema.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: seed `system_settings.supplier_export_mode`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php`
|
||||
- Modify: `db/CHANGELOG_schema.md`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierExportModeSeedTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('seeds supplier_export_mode = batch by default', function (): void {
|
||||
$row = DB::table('system_settings')->where('key', 'supplier_export_mode')->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and($row->value)->toBe('batch')
|
||||
->and($row->type)->toBe('string');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierExportModeSeedTest`
|
||||
Expected: FAIL — строки нет.
|
||||
|
||||
- [ ] **Step 3: Написать миграцию (idempotent insert)**
|
||||
|
||||
`app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Глобальный тумблер режима экспорта проектов поставщику (v8.26).
|
||||
* 'batch' (default, прод-безопасно) | 'online'. Резолвится SupplierExportMode (План 3).
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$exists = DB::table('system_settings')->where('key', 'supplier_export_mode')->exists();
|
||||
if ($exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('system_settings')->insert([
|
||||
'key' => 'supplier_export_mode',
|
||||
'value' => 'batch',
|
||||
'type' => 'string',
|
||||
'description' => 'Режим экспорта проектов поставщику: batch (ночной 18:00) | online (сразу при правке).',
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->delete();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Записать в `db/CHANGELOG_schema.md`** (сверху)
|
||||
|
||||
```markdown
|
||||
## v8.26 (доп) — 2026-05-20 — seed system_settings.supplier_export_mode
|
||||
|
||||
Сид-строка `supplier_export_mode='batch'` (тумблер режима экспорта; online|batch).
|
||||
Не структурное изменение. Миграция: 2026_05_20_103000_seed_supplier_export_mode.php.
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=SupplierExportModeSeedTest`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php app/tests/Feature/Supplier/SupplierExportModeSeedTest.php db/CHANGELOG_schema.md
|
||||
git commit -m "feat(supplier): seed supplier_export_mode toggle (v8.26)" -- app/database/migrations/2026_05_20_103000_seed_supplier_export_mode.php app/tests/Feature/Supplier/SupplierExportModeSeedTest.php db/CHANGELOG_schema.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Eloquent-связи Project ↔ SupplierProject через pivot
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Models/SupplierProject.php` (relation `projects()`)
|
||||
- Modify: `app/app/Models/Project.php` (relation `supplierProjects()`)
|
||||
- Test: `app/tests/Feature/Supplier/ProjectSupplierRelationTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
|
||||
it('links project to supplier projects via belongsToMany pivot', function (): void {
|
||||
$project = Project::factory()->create();
|
||||
$sp1 = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'rel.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$sp2 = SupplierProject::query()->create([
|
||||
'platform' => 'B2', 'signal_type' => 'site', 'unique_key' => 'rel.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$project->supplierProjects()->attach([
|
||||
$sp1->id => ['platform' => 'B1', 'subject_code' => 82],
|
||||
$sp2->id => ['platform' => 'B2', 'subject_code' => 82],
|
||||
]);
|
||||
|
||||
expect($project->supplierProjects()->count())->toBe(2)
|
||||
->and($sp1->projects()->pluck('projects.id')->all())->toContain($project->id)
|
||||
->and($project->supplierProjects->first()->pivot->platform)->not->toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=ProjectSupplierRelationTest`
|
||||
Expected: FAIL — методов `supplierProjects()` / `projects()` нет.
|
||||
|
||||
- [ ] **Step 3: Добавить связь в `SupplierProject`**
|
||||
|
||||
В `app/app/Models/SupplierProject.php` добавить импорт `use Illuminate\Database\Eloquent\Relations\BelongsToMany;` и метод:
|
||||
|
||||
```php
|
||||
/**
|
||||
* @return BelongsToMany<Project, $this>
|
||||
*/
|
||||
public function projects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Project::class, 'project_supplier_links')
|
||||
->withPivot(['platform', 'subject_code']);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Добавить связь в `Project`**
|
||||
|
||||
В `app/app/Models/Project.php` добавить импорт `use Illuminate\Database\Eloquent\Relations\BelongsToMany;` и метод:
|
||||
|
||||
```php
|
||||
/**
|
||||
* @return BelongsToMany<SupplierProject, $this>
|
||||
*/
|
||||
public function supplierProjects(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(SupplierProject::class, 'project_supplier_links')
|
||||
->withPivot(['platform', 'subject_code']);
|
||||
}
|
||||
```
|
||||
|
||||
(Если `use App\Models\SupplierProject;` ещё не импортирован — добавить.)
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=ProjectSupplierRelationTest`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 6: pint + larastan**
|
||||
|
||||
Run: `composer pint -- app/app/Models/SupplierProject.php app/app/Models/Project.php; composer stan`
|
||||
Expected: 0 ошибок (при @property/relation-ошибке — `php artisan ide-helper:models -W` для обеих моделей, закоммитить обновлённый helper-файл если меняется).
|
||||
|
||||
- [ ] **Step 7: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Models/SupplierProject.php app/app/Models/Project.php app/tests/Feature/Supplier/ProjectSupplierRelationTest.php
|
||||
git commit -m "feat(supplier): Project<->SupplierProject belongsToMany via pivot" -- app/app/Models/SupplierProject.php app/app/Models/Project.php app/tests/Feature/Supplier/ProjectSupplierRelationTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Backfill pivot из существующих `supplier_b{1,2,3}_project_id`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php`
|
||||
- Test: `app/tests/Feature/Supplier/BackfillProjectSupplierLinksTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('backfills pivot rows from legacy supplier_b{1,2,3}_project_id slots', function (): void {
|
||||
$sp1 = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'bf.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$sp3 = SupplierProject::query()->create([
|
||||
'platform' => 'B3', 'signal_type' => 'site', 'unique_key' => 'bf.ru',
|
||||
'subject_code' => null, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'supplier_b1_project_id' => $sp1->id,
|
||||
'supplier_b3_project_id' => $sp3->id,
|
||||
]);
|
||||
|
||||
// Симулируем «до бэкофилла»: pivot пуст.
|
||||
DB::table('project_supplier_links')->where('project_id', $project->id)->delete();
|
||||
|
||||
// Запуск логики бэкофилла повторно (миграция идемпотентна).
|
||||
require_once base_path('database/migrations/2026_05_20_104000_backfill_project_supplier_links.php');
|
||||
(include base_path('database/migrations/2026_05_20_104000_backfill_project_supplier_links.php'))->up();
|
||||
|
||||
$rows = DB::table('project_supplier_links')->where('project_id', $project->id)->get();
|
||||
expect($rows)->toHaveCount(2)
|
||||
->and($rows->pluck('platform')->sort()->values()->all())->toBe(['B1', 'B3']);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=BackfillProjectSupplierLinksTest`
|
||||
Expected: FAIL — файла миграции нет.
|
||||
|
||||
- [ ] **Step 3: Написать миграцию бэкофилла**
|
||||
|
||||
`app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Бэкофилл pivot project_supplier_links из legacy supplier_b{1,2,3}_project_id (v8.26).
|
||||
*
|
||||
* Для каждого ненулевого слота → строка pivot (subject_code=NULL: legacy-записи без
|
||||
* субъекта). Идемпотентно — ON CONFLICT DO NOTHING по uq_psl_project_supplier.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
foreach (['B1' => 'supplier_b1_project_id', 'B2' => 'supplier_b2_project_id', 'B3' => 'supplier_b3_project_id'] as $platform => $col) {
|
||||
DB::statement(
|
||||
'INSERT INTO project_supplier_links (project_id, supplier_project_id, platform, subject_code, created_at) '
|
||||
."SELECT id, {$col}, ?, NULL, NOW() FROM projects WHERE {$col} IS NOT NULL "
|
||||
.'ON CONFLICT (project_id, supplier_project_id) DO NOTHING',
|
||||
[$platform]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Бэкофилл-данные не откатываем точечно (pivot живёт дальше); no-op.
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing && php artisan test --filter=BackfillProjectSupplierLinksTest`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 5: pint**
|
||||
|
||||
Run: `composer pint -- app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php`
|
||||
Expected: OK.
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php app/tests/Feature/Supplier/BackfillProjectSupplierLinksTest.php
|
||||
git commit -m "feat(supplier): backfill project_supplier_links from legacy FK slots" -- app/database/migrations/2026_05_20_104000_backfill_project_supplier_links.php app/tests/Feature/Supplier/BackfillProjectSupplierLinksTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Регрессия фундамента
|
||||
|
||||
**Files:** нет (проверка).
|
||||
|
||||
- [ ] **Step 1: migrate:fresh на чистой БД**
|
||||
|
||||
Run: `php artisan migrate:fresh --env=testing`
|
||||
Expected: все миграции прошли, 0 ошибок (schema.sql v8.26 + 5 новых инкрементальных миграций идемпотентны).
|
||||
|
||||
- [ ] **Step 2: Полный Supplier+Integration suite**
|
||||
|
||||
Run: `php artisan test --testsuite=Feature -- --filter='Supplier|Integration|Project'` (или `composer test -- --group=supplier` если группа есть; иначе `php artisan test app/tests/Feature/Supplier`)
|
||||
Expected: GREEN, 0 регрессий относительно baseline. Выписать число passed/failed; любой failed — с file:line.
|
||||
|
||||
- [ ] **Step 3: pint + larastan на всём изменённом**
|
||||
|
||||
Run: `composer pint; composer stan`
|
||||
Expected: 0 ошибок (larastan baseline без новых записей; при необходимости regen ide-helper).
|
||||
|
||||
- [ ] **Step 4 (verification-before-completion):** перед заявлением «План 1 готов» — invoke `superpowers:verification-before-completion`, привести фактический вывод `migrate:fresh` + теста, не «должно проходить».
|
||||
|
||||
---
|
||||
|
||||
## Self-review (выполнен при написании плана)
|
||||
|
||||
- **Покрытие spec §4.2:** subject_code (T1), pivot (T2), deals.subject_code (T3), system_settings seed (T4), модели/связи (T5); §5 миграция/бэкофилл (T6). Резолвер `SupplierExportMode::current()`, формула заказа, save-маппинг, админка, ЛК — **вне Плана 1** (Планы 2-4, см. roadmap в spec/комментарии).
|
||||
- **Без плейсхолдеров:** весь код приведён; «if squawk flags» снят — CHECK сразу NOT VALID + VALIDATE (squawk-safe).
|
||||
- **Согласованность типов:** `subject_code SMALLINT`/cast integer везде; pivot withPivot(['platform','subject_code']) — те же поля во всех тасках; имена индексов/констрейнтов уникальны.
|
||||
- **Известное ограничение:** Task 6 тест дважды вызывает `up()` (idempotent ON CONFLICT) — проверяет повторный прогон; если в проекте тест-БД не несёт legacy FK-колонок (они ещё есть, §4.2 двойная запись) — ок.
|
||||
@@ -1,641 +0,0 @@
|
||||
# Переделка миграции проектов — План 2: входящее распределение лидов
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Переписать маршрутизацию входящего лида: cap=3 (лид уходит максимум 3 случайным недобравшим клиентам), eligibility — структурно через pivot `project_supplier_links` (без phone-фильтра по `region_mask`), регион сделки — из тега поставщика (`raw_payload['tag']` → `deals.subject_code`).
|
||||
|
||||
**Architecture:** `LeadRouter` фильтрует кандидатов запросом через pivot (заменяет `match($platform)` по FK-колонке), убирает PHP-фильтр `PhonePrefixService::phoneMatchesRegions`. Новый pure-сервис `LeadDistributor` отбирает ≤3 получателей через инъектируемый сидируемый `\Random\Randomizer` (детерминизм тестов). `RegionTagResolver` (зеркало `resources/js/constants/regions.ts`) резолвит имя субъекта из тега в код 1..89. `RouteSupplierLeadJob` оркеструет: matched → distributor (cap) → deal.subject_code из тега.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Eloquent / `\Random\Randomizer` (PHP 8.2 seedable RNG) / Pest 4.
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../specs/2026-05-20-project-migration-redesign-design.md) §4.5 (часть распределения), §4.6, §4.4.
|
||||
|
||||
**Prereq:** План 1 (фундамент) исполнен — есть `project_supplier_links`, `supplier_projects.subject_code`, `deals.subject_code`, связи belongsToMany. **Зависимость от Плана 3 отсутствует:** в проде pivot пока заполнен только бэкофиллом (legacy-связи, subject_code=NULL) — LeadRouter работает и на них; per-субъект связи появятся в Плане 3. Формула заказа (`SupplierQuotaAllocator`) — в Плане 3 (с её вызывающим sync-джобом), НЕ здесь.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `RegionTagResolver` — тег субъекта → код 1..89
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Support/RussianRegions.php` (канонический словарь субъектов — зеркало `resources/js/constants/regions.ts`)
|
||||
- Create: `app/app/Services/RegionTagResolver.php`
|
||||
- Test: `app/tests/Unit/Services/RegionTagResolverTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\RegionTagResolver;
|
||||
use App\Support\RussianRegions;
|
||||
|
||||
it('resolves subject name to code', function (): void {
|
||||
$r = new RegionTagResolver;
|
||||
expect($r->resolve('Москва'))->toBe(82)
|
||||
->and($r->resolve('Санкт-Петербург'))->toBe(83)
|
||||
->and($r->resolve('Республика Адыгея'))->toBe(1);
|
||||
});
|
||||
|
||||
it('returns null for «РФ» pool tag, empty and unknown', function (): void {
|
||||
$r = new RegionTagResolver;
|
||||
expect($r->resolve('РФ'))->toBeNull()
|
||||
->and($r->resolve(''))->toBeNull()
|
||||
->and($r->resolve('Нарния'))->toBeNull();
|
||||
});
|
||||
|
||||
it('canonical region map mirrors regions.ts — exactly 89 subjects', function (): void {
|
||||
expect(count(RussianRegions::CODE_TO_NAME))->toBe(89);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=RegionTagResolverTest`
|
||||
Expected: FAIL — классов нет.
|
||||
|
||||
- [ ] **Step 3: Создать `RussianRegions` (зеркало regions.ts, 89 субъектов)**
|
||||
|
||||
`app/app/Support/RussianRegions.php` — карта `code => name` для кодов **1..89** (sentinel 0 «Вся РФ» НЕ включаем). Источник истины — [resources/js/constants/regions.ts](../../../app/resources/js/constants/regions.ts): скопировать ВСЕ 89 пар `{ code, name }` (code 1..89), сохранив точные названия (включая диакритику «Северная Осетия — Алания», «Республика Саха (Якутия)»).
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Канонический справочник субъектов РФ (1..89) — PHP-зеркало
|
||||
* resources/js/constants/regions.ts (конституционный порядок, ст. 65).
|
||||
* Sentinel 0 «Вся РФ» не входит (= NULL subject_code / пустой regions).
|
||||
*
|
||||
* ВАЖНО: при правке regions.ts синхронно править этот файл (тест RegionTagResolverTest
|
||||
* «mirrors regions.ts — exactly 89» ловит расхождение по count, но не по именам —
|
||||
* сверять имена вручную при изменениях).
|
||||
*/
|
||||
final class RussianRegions
|
||||
{
|
||||
/** @var array<int, string> code(1..89) => официальное имя субъекта */
|
||||
public const CODE_TO_NAME = [
|
||||
1 => 'Республика Адыгея',
|
||||
2 => 'Республика Алтай',
|
||||
// … скопировать коды 3..81 из regions.ts (республики/края/области) …
|
||||
82 => 'Москва',
|
||||
83 => 'Санкт-Петербург',
|
||||
84 => 'Севастополь',
|
||||
85 => 'Еврейская автономная область',
|
||||
86 => 'Ненецкий автономный округ',
|
||||
87 => 'Ханты-Мансийский автономный округ — Югра',
|
||||
88 => 'Чукотский автономный округ',
|
||||
89 => 'Ямало-Ненецкий автономный округ',
|
||||
];
|
||||
|
||||
/** @return array<string, int> name => code (обратный индекс) */
|
||||
public static function nameToCode(): array
|
||||
{
|
||||
return array_flip(self::CODE_TO_NAME);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**NB исполнителю:** заполнить все 89 строк из regions.ts (в примере выше пропущены 3..81 — скопировать дословно). Тест Step 1 «exactly 89» провалится, если не все.
|
||||
|
||||
- [ ] **Step 4: Создать `RegionTagResolver`**
|
||||
|
||||
`app/app/Services/RegionTagResolver.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Support\RussianRegions;
|
||||
|
||||
/**
|
||||
* Резолвит регион-тег поставщика (raw_payload['tag'] = имя субъекта или «РФ»)
|
||||
* в код субъекта 1..89. «РФ»/пусто/неизвестно → null (пул «Вся РФ»/неизвестно).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.4.
|
||||
*/
|
||||
final class RegionTagResolver
|
||||
{
|
||||
public function resolve(string $tag): ?int
|
||||
{
|
||||
$tag = trim($tag);
|
||||
|
||||
if ($tag === '' || $tag === 'РФ') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RussianRegions::nameToCode()[$tag] ?? null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=RegionTagResolverTest`
|
||||
Expected: PASS (3). Полный вывод. (Если «exactly 89» падает — не все субъекты скопированы из regions.ts.)
|
||||
|
||||
- [ ] **Step 6: pint + larastan**
|
||||
|
||||
Run: `composer pint -- app/app/Support/RussianRegions.php app/app/Services/RegionTagResolver.php; composer stan`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 7: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Support/RussianRegions.php app/app/Services/RegionTagResolver.php app/tests/Unit/Services/RegionTagResolverTest.php
|
||||
git commit -m "feat(supplier): RegionTagResolver + RussianRegions (subject name->code)" -- app/app/Support/RussianRegions.php app/app/Services/RegionTagResolver.php app/tests/Unit/Services/RegionTagResolverTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `LeadRouter` — eligibility через pivot, без phone-фильтра
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/LeadRouter.php` (весь класс — убрать `PhonePrefixService`, переписать запрос на pivot, сигнатура `matchEligibleProjects` без `$phone`)
|
||||
- Modify: `app/tests/Feature/Services/LeadRouterTest.php` (region-filter кейсы → pivot-кейсы; вызовы без `$phone`)
|
||||
- Modify: `app/tests/Feature/Supplier/SupplierConnectionTest.php:100` (вызов без `$phone`)
|
||||
|
||||
- [ ] **Step 1: Переписать тест `LeadRouterTest` под pivot**
|
||||
|
||||
Заменить region-filter кейсы (include/exclude по `region_mask`) на eligibility-через-pivot. Базовый каркас (адаптировать существующие helper'ы фабрик из текущего файла; ключевая смена — кандидат входит, только если есть строка pivot на этот supplier_project):
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\LeadRouter;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function linkProjectToSupplier(Project $project, SupplierProject $sp): void
|
||||
{
|
||||
DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $project->id,
|
||||
'supplier_project_id' => $sp->id,
|
||||
'platform' => $sp->platform,
|
||||
'subject_code' => $sp->subject_code,
|
||||
]);
|
||||
}
|
||||
|
||||
it('returns project linked via pivot to the supplier_project', function (): void {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
linkProjectToSupplier($project, $sp);
|
||||
|
||||
$matched = app(LeadRouter::class)->matchEligibleProjects($sp);
|
||||
|
||||
expect($matched)->toHaveCount(1)
|
||||
->and($matched->first()->id)->toBe($project->id);
|
||||
});
|
||||
|
||||
it('excludes project NOT linked to this supplier_project', function (): void {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r2.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]); // не линкуем
|
||||
|
||||
expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
|
||||
});
|
||||
|
||||
it('excludes inactive project, project at limit, and zero-balance tenant', function (): void {
|
||||
$sp = SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'r3.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
$t1 = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$inactive = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => false, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($inactive, $sp);
|
||||
|
||||
$atLimit = Project::factory()->create(['tenant_id' => $t1->id, 'is_active' => true, 'daily_limit_target' => 5, 'delivered_today' => 5, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($atLimit, $sp);
|
||||
|
||||
$t0 = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => 0]);
|
||||
$broke = Project::factory()->create(['tenant_id' => $t0->id, 'is_active' => true, 'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127]);
|
||||
linkProjectToSupplier($broke, $sp);
|
||||
|
||||
expect(app(LeadRouter::class)->matchEligibleProjects($sp))->toHaveCount(0);
|
||||
});
|
||||
```
|
||||
|
||||
(Сохранить из старого файла прочие нерегиональные кейсы, заменив вызовы `matchEligibleProjects($supplier, '799...')` → `matchEligibleProjects($supplier)` и добавив `linkProjectToSupplier()` где проект должен быть eligible. Региональные кейсы include/exclude по `region_mask` — **удалить** (фильтр снят).)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=LeadRouterTest`
|
||||
Expected: FAIL — сигнатура ещё с `$phone` + фильтр по region даёт другой результат / pivot не учитывается.
|
||||
|
||||
- [ ] **Step 3: Переписать `LeadRouter`**
|
||||
|
||||
`app/app/Services/LeadRouter.php` — полностью:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Подбор eligible Лидерра-проектов для входящего лида (sharing-model §6).
|
||||
*
|
||||
* Eligibility — структурно через pivot project_supplier_links: проект eligible,
|
||||
* если связан с пришедшим supplier_project (= источник × субъект) + активен +
|
||||
* сегодня рабочий день + есть остаток лимита + у тенанта есть баланс.
|
||||
*
|
||||
* Регион сопоставляется самим supplier_project (тег = субъект) — phone-prefix
|
||||
* фильтр убран (эпик миграции проектов, Q5): для мобильных он no-op, а регион
|
||||
* гарантирован тем, через какой supplier_project пришёл лид.
|
||||
*
|
||||
* Запрос через connection pgsql_supplier (BYPASSRLS crm_supplier_worker) — в
|
||||
* sharing-flow tenant ещё не определён, SELECT видит проекты всех tenant'ов.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.5.
|
||||
*/
|
||||
class LeadRouter
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
public function matchEligibleProjects(SupplierProject $supplierProject): Collection
|
||||
{
|
||||
// МСК-aligned ISO day-of-week (reset-cron тоже 00:00 МСК).
|
||||
$todayBit = 1 << (Carbon::now('Europe/Moscow')->isoWeekday() - 1);
|
||||
|
||||
/** @var Collection<int, Project> $candidates */
|
||||
$candidates = Project::on('pgsql_supplier')
|
||||
->whereExists(function ($q) use ($supplierProject): void {
|
||||
$q->selectRaw('1')
|
||||
->from('project_supplier_links')
|
||||
->whereColumn('project_supplier_links.project_id', 'projects.id')
|
||||
->where('project_supplier_links.supplier_project_id', $supplierProject->id);
|
||||
})
|
||||
->where('is_active', true)
|
||||
->whereRaw('(delivery_days_mask & ?) <> 0', [$todayBit])
|
||||
->whereRaw('delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)')
|
||||
->whereExists(function ($q): void {
|
||||
$q->selectRaw('1')
|
||||
->from('tenants')
|
||||
->whereColumn('tenants.id', 'projects.tenant_id')
|
||||
->where(function ($qq): void {
|
||||
$qq->where('tenants.balance_leads', '>', 0)
|
||||
->orWhere('tenants.balance_rub', '>', 0);
|
||||
});
|
||||
})
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
return $candidates->values();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Обновить прочих вызывающих (drop `$phone`)**
|
||||
|
||||
- `app/tests/Feature/Supplier/SupplierConnectionTest.php:100`: `matchEligibleProjects($supplier, '79991234567')` → `matchEligibleProjects($supplier)` (+ убедиться, что проект там линкуется через pivot, иначе ожидание скорректировать).
|
||||
- `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php:489`: mock `shouldReceive('matchEligibleProjects')->with(...)` — снять аргумент phone из `with()` (если задан), вернуть Collection как раньше.
|
||||
|
||||
- [ ] **Step 5: Прогнать LeadRouter + связанные**
|
||||
|
||||
Run: `php artisan test --filter='LeadRouterTest|SupplierConnectionTest'`
|
||||
Expected: PASS. Полный вывод; любой failed — с file:line.
|
||||
|
||||
- [ ] **Step 6: pint + larastan**
|
||||
|
||||
Run: `composer pint -- app/app/Services/LeadRouter.php; composer stan`
|
||||
Expected: 0 ошибок. (`PhonePrefixService` остаётся в проекте — его юзает `ProjectService` dual-write; `PhonePrefixServiceTest` не трогаем.)
|
||||
|
||||
- [ ] **Step 7: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Services/LeadRouter.php app/tests/Feature/Services/LeadRouterTest.php app/tests/Feature/Supplier/SupplierConnectionTest.php
|
||||
git commit -m "feat(supplier): LeadRouter eligibility via pivot, drop phone region filter" -- app/app/Services/LeadRouter.php app/tests/Feature/Services/LeadRouterTest.php app/tests/Feature/Supplier/SupplierConnectionTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `LeadDistributor` — cap=3 случайных получателя (детерминируемо)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/LeadDistributor.php`
|
||||
- Test: `app/tests/Unit/Services/LeadDistributorTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\LeadDistributor;
|
||||
use Illuminate\Support\Collection;
|
||||
use Random\Engine\Mt19937;
|
||||
use Random\Randomizer;
|
||||
|
||||
function projects(int $n): Collection
|
||||
{
|
||||
return collect(range(1, $n))->map(fn (int $i) => (object) ['id' => $i]);
|
||||
}
|
||||
|
||||
it('returns all when eligible count <= cap (3)', function (): void {
|
||||
$d = new LeadDistributor;
|
||||
expect($d->selectRecipients(projects(2)))->toHaveCount(2)
|
||||
->and($d->selectRecipients(projects(3)))->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('caps at 3 when more eligible', function (): void {
|
||||
$d = new LeadDistributor;
|
||||
expect($d->selectRecipients(projects(7)))->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('selection is a subset of eligible and deterministic under seeded RNG', function (): void {
|
||||
$eligible = projects(7);
|
||||
$d = new LeadDistributor(new Randomizer(new Mt19937(42)));
|
||||
$picked = $d->selectRecipients($eligible)->pluck('id')->all();
|
||||
|
||||
expect($picked)->toHaveCount(3)
|
||||
->and(collect($picked)->every(fn ($id) => $id >= 1 && $id <= 7))->toBeTrue();
|
||||
|
||||
// тот же seed → тот же выбор
|
||||
$d2 = new LeadDistributor(new Randomizer(new Mt19937(42)));
|
||||
expect($d2->selectRecipients($eligible)->pluck('id')->all())->toBe($picked);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=LeadDistributorTest`
|
||||
Expected: FAIL — класса нет.
|
||||
|
||||
- [ ] **Step 3: Создать `LeadDistributor`**
|
||||
|
||||
`app/app/Services/LeadDistributor.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Random\Randomizer;
|
||||
|
||||
/**
|
||||
* Отбор получателей входящего лида: ≤ CAP случайных из eligible (sharing cap).
|
||||
*
|
||||
* cap=3 — защита владельца номера-донора (лид продаётся максимум 3 раза).
|
||||
* Eligible уже отфильтрован LeadRouter (есть остаток лимита) → отбор лимит не
|
||||
* превышает. Рандом через инъектируемый \Random\Randomizer (тесты сидируют
|
||||
* Mt19937 для детерминизма; прод — CSPRNG по умолчанию).
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.6.
|
||||
*/
|
||||
final class LeadDistributor
|
||||
{
|
||||
public const CAP = 3;
|
||||
|
||||
public function __construct(private readonly Randomizer $randomizer = new Randomizer) {}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @param Collection<int, T> $eligible
|
||||
* @return Collection<int, T>
|
||||
*/
|
||||
public function selectRecipients(Collection $eligible): Collection
|
||||
{
|
||||
$items = $eligible->values()->all();
|
||||
|
||||
if (count($items) <= self::CAP) {
|
||||
return collect($items);
|
||||
}
|
||||
|
||||
$keys = $this->randomizer->pickArrayKeys($items, self::CAP);
|
||||
|
||||
return collect($keys)->map(fn (int $k) => $items[$k])->values();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=LeadDistributorTest`
|
||||
Expected: PASS (3). Полный вывод.
|
||||
|
||||
- [ ] **Step 5: pint + larastan**
|
||||
|
||||
Run: `composer pint -- app/app/Services/LeadDistributor.php; composer stan`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Services/LeadDistributor.php app/tests/Unit/Services/LeadDistributorTest.php
|
||||
git commit -m "feat(supplier): LeadDistributor cap=3 seedable random selection" -- app/app/Services/LeadDistributor.php app/tests/Unit/Services/LeadDistributorTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `RouteSupplierLeadJob` — cap=3 + регион в deal
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/RouteSupplierLeadJob.php` (handle: distributor; createDealCopyForProject: deal.subject_code из тега)
|
||||
- Modify: `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` (cap=3 кейс, mock matchEligibleProjects без phone, seeded LeadDistributor bind)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест (cap=3 + регион)**
|
||||
|
||||
Добавить в `RouteSupplierLeadJobTest` (адаптировать существующие фабрики/helpers файла):
|
||||
|
||||
```php
|
||||
it('caps deal creation at 3 recipients and tags deal with subject from payload', function (): void {
|
||||
// seeded distributor — детерминизм
|
||||
$this->app->bind(\App\Services\LeadDistributor::class, fn () => new \App\Services\LeadDistributor(
|
||||
new \Random\Randomizer(new \Random\Engine\Mt19937(7))
|
||||
));
|
||||
|
||||
$sp = \App\Models\SupplierProject::query()->create([
|
||||
'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'cap.ru',
|
||||
'subject_code' => 82, 'current_limit' => 0, 'sync_status' => 'ok',
|
||||
]);
|
||||
|
||||
// 5 eligible клиентов, привязанных к sp через pivot, с балансом и лимитом
|
||||
foreach (range(1, 5) as $i) {
|
||||
$t = \App\Models\Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$p = \App\Models\Project::factory()->create([
|
||||
'tenant_id' => $t->id, 'is_active' => true,
|
||||
'daily_limit_target' => 10, 'delivered_today' => 0, 'delivery_days_mask' => 127,
|
||||
]);
|
||||
\Illuminate\Support\Facades\DB::table('project_supplier_links')->insert([
|
||||
'project_id' => $p->id, 'supplier_project_id' => $sp->id, 'platform' => 'B1', 'subject_code' => 82,
|
||||
]);
|
||||
}
|
||||
|
||||
$lead = \App\Models\SupplierLead::factory()->create([
|
||||
'phone' => '79991234567',
|
||||
'raw_payload' => ['project' => 'B1_cap.ru', 'tag' => 'Москва', 'vid' => 555111],
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
(new \App\Jobs\RouteSupplierLeadJob($lead->id))->handle(
|
||||
app(\App\Services\LeadRouter::class),
|
||||
app(\App\Services\SupplierProjects\SupplierProjectResolver::class),
|
||||
app(\App\Services\DuplicateDetector::class),
|
||||
app(\App\Services\NotificationService::class),
|
||||
app(\App\Services\Billing\LedgerService::class),
|
||||
app(\App\Services\LeadDistributor::class),
|
||||
app(\App\Services\RegionTagResolver::class),
|
||||
);
|
||||
|
||||
$deals = \App\Models\Deal::query()->where('source_crm_id', 555111)->get();
|
||||
expect($deals)->toHaveCount(3)
|
||||
->and($deals->pluck('subject_code')->unique()->all())->toBe([82]);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter='RouteSupplierLeadJobTest'`
|
||||
Expected: FAIL — handle() ещё без `LeadDistributor`/`RegionTagResolver` параметров; создаётся 5 deal'ов вместо 3; subject_code не пишется.
|
||||
|
||||
- [ ] **Step 3: Внести изменения в `RouteSupplierLeadJob`**
|
||||
|
||||
a) Импорты: добавить `use App\Services\LeadDistributor;` и `use App\Services\RegionTagResolver;`.
|
||||
|
||||
b) Сигнатура `handle()` — добавить два параметра в конец:
|
||||
|
||||
```php
|
||||
public function handle(
|
||||
LeadRouter $router,
|
||||
SupplierProjectResolver $resolver,
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
LeadDistributor $distributor,
|
||||
RegionTagResolver $tagResolver,
|
||||
): void {
|
||||
```
|
||||
|
||||
c) Заменить блок matched→loop ([RouteSupplierLeadJob.php:111-133](../../../app/app/Jobs/RouteSupplierLeadJob.php#L111)):
|
||||
|
||||
```php
|
||||
$matched = $router->matchEligibleProjects($supplier);
|
||||
$selected = $distributor->selectRecipients($matched); // cap=3 случайных
|
||||
|
||||
$subjectCode = $tagResolver->resolve((string) ($lead->raw_payload['tag'] ?? ''));
|
||||
|
||||
$createdCount = 0;
|
||||
$failures = [];
|
||||
foreach ($selected as $project) {
|
||||
try {
|
||||
if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger, $subjectCode)) {
|
||||
$createdCount++;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$failures[] = ['project_id' => $project->id, 'tenant_id' => $project->tenant_id, 'error' => $e->getMessage()];
|
||||
Log::warning('supplier_lead.per_project_routing_failed', [
|
||||
'supplier_lead_id' => $lead->id,
|
||||
'project_id' => $project->id,
|
||||
'tenant_id' => $project->tenant_id,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($selected->isNotEmpty() && $createdCount === 0 && count($failures) === $selected->count()) {
|
||||
throw new RuntimeException(
|
||||
'All eligible projects failed routing for supplier_lead='.$lead->id.
|
||||
'; last error: '.($failures[array_key_last($failures)]['error'] ?? 'unknown')
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
d) `createDealCopyForProject` — добавить параметр `?int $subjectCode` и писать его в Deal::create:
|
||||
|
||||
```php
|
||||
private function createDealCopyForProject(
|
||||
SupplierLead $lead,
|
||||
Project $project,
|
||||
DuplicateDetector $duplicateDetector,
|
||||
NotificationService $notifier,
|
||||
LedgerService $ledger,
|
||||
?int $subjectCode,
|
||||
): bool {
|
||||
```
|
||||
|
||||
В `Deal::create([...])` ([RouteSupplierLeadJob.php:247](../../../app/app/Jobs/RouteSupplierLeadJob.php#L247)) добавить строку `'subject_code' => $subjectCode,` (рядом с `'status' => 'new',`).
|
||||
|
||||
- [ ] **Step 4: Прогнать тест cap+регион — зелёный**
|
||||
|
||||
Run: `php artisan test --filter='RouteSupplierLeadJobTest'`
|
||||
Expected: PASS (новый кейс + существующие). Полный вывод; failed — с file:line. (Существующие кейсы с ≤3 eligible не затронуты — distributor вернёт всех.)
|
||||
|
||||
- [ ] **Step 5: pint + larastan**
|
||||
|
||||
Run: `composer pint -- app/app/Jobs/RouteSupplierLeadJob.php; composer stan`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 6: Коммит**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
|
||||
git commit -m "feat(supplier): RouteSupplierLeadJob cap=3 distribution + deal.subject_code from tag" -- app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Регрессия распределения
|
||||
|
||||
**Files:** нет (проверка).
|
||||
|
||||
- [ ] **Step 1: Полный Supplier + Jobs + Integration suite**
|
||||
|
||||
Run: `php artisan test app/tests/Feature/Supplier app/tests/Feature/Jobs app/tests/Feature/Services app/tests/Unit/Services`
|
||||
Expected: GREEN. Выписать passed/failed; любой failed — file:line. Особое внимание `AutoPauseFlowTest` (зависит от matchEligibleProjects is_active фильтра — он сохранён).
|
||||
|
||||
- [ ] **Step 2: pint + larastan по всему дереву**
|
||||
|
||||
Run: `composer pint; composer stan`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 3 (verification-before-completion):** перед заявлением «План 2 готов» — invoke `superpowers:verification-before-completion`; привести фактический вывод тестов, не «должно проходить».
|
||||
|
||||
---
|
||||
|
||||
## Self-review (выполнен при написании плана)
|
||||
|
||||
- **Покрытие spec §4.5/§4.6/§4.4:** pivot-eligibility + снятие phone-фильтра (T2); cap=3 рандом (T3) + интеграция в job (T4); регион→deals.subject_code из тега (T1+T4). Формула заказа §4.5 (заказ) — в Плане 3 (с вызывающим sync-джобом), отмечено в prereq.
|
||||
- **Без плейсхолдеров:** код приведён; единственное «дозаполнить» — 89 субъектов в `RussianRegions` из существующего regions.ts (концертный источник в репо + тест «exactly 89» как гейт; не TODO).
|
||||
- **Согласованность типов:** `matchEligibleProjects(SupplierProject): Collection` — единая новая сигнатура во всех вызовах (T2 Step 4 перечисляет всех вызывающих); `selectRecipients(Collection): Collection`; `RegionTagResolver::resolve(string): ?int`; `deals.subject_code` int — совпадает с Планом 1 Task 3.
|
||||
- **Риск:** `Randomizer::pickArrayKeys` доступен с PHP 8.2 (у нас 8.3 ✓). Детерминизм в job-тесте — через container-bind seeded `LeadDistributor`.
|
||||
@@ -1,548 +0,0 @@
|
||||
# Переделка миграции проектов — План 3: канал экспорта + формула заказа
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Переписать экспорт проектов поставщику: режимы online/batch (глобальный тумблер), per-субъект save (R7, tag=субъект), один multi-flag save B1+B2+B3 с захватом 3 external_id (R5), портал делит лимит сам (R6, наш split убран), формула заказа `max(наиб, ceil(Σ/3))`, запись связей в pivot.
|
||||
|
||||
**Architecture:** `SupplierExportMode` (резолвер тумблера `system_settings`). `SupplierQuotaAllocator` → pure `computeOrder()` (max(наиб, ceil(Σ/3))) + union workdays, без platform-split. `SupplierPortalClient::saveProjectMultiFlag()` — один save с 3 флагами → дочитывает `listProjects()` → возвращает `[platform => external_id]` (R-SAVE вариант а). `SyncSupplierProjectsJob` переписан с FK-итерации на **группировку (источник × субъект)** из активных Лидерра-проектов; создаёт/обновляет 3 supplier_projects (по платформе) на группу + пишет pivot. `SyncSupplierProjectJob` (онлайн) при mode=online шлёт полные параметры, иначе — каркас (как сейчас). `ProjectService` диспатчит онлайн-sync mode-aware.
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 / Playwright bridge (R-SAVE live smoke).
|
||||
|
||||
**Spec:** [docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md](../specs/2026-05-20-project-migration-redesign-design.md) §4.1, §4.3, §4.5 (часть заказа), §8 R-SAVE.
|
||||
|
||||
**Prereq:** Планы 1+2 исполнены (pivot, subject_code, RegionTagResolver, RussianRegions, LeadRouter на pivot). Жив supplier-портал + сессия (`refresh-session.js`) для Task 1 smoke.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: R-SAVE live smoke — захват 3 external_id из multi-flag save (ГЕЙТ)
|
||||
|
||||
> Гейт всего плана. До зелёного smoke прод-путь R5 не переписывать. Решение заказчика — **вариант а** (дочитать listProjects); fallback **б** (3 раздельных save) только если а провалится.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/storage/_smoke_multiflag_save.php` (временный tinker-скрипт; удалить после)
|
||||
- Create: `docs/discovery/2026-05-20-rsave-multiflag-finding.md` (фиксация результата)
|
||||
|
||||
- [ ] **Step 1: Написать smoke-скрипт**
|
||||
|
||||
`app/storage/_smoke_multiflag_save.php` — через живой `SupplierPortalClient` (Tier-1 AJAX):
|
||||
|
||||
1. POST `/admin/visit/rt-project-save` с payload, где `srcrt=srcbl=srcmt=true`, уникальный `name` (валидный домен, lowercase — recon §находка 1), `tag="Москва"`, `type=hosts`, `regions=[]`, `limit=9`.
|
||||
2. Запомнить `id` из ответа (последний из 3 — recon §находка 2).
|
||||
3. `listProjects()` → найти ВСЕ строки с `name == <наш>` И `tag == "Москва"` → собрать их `id`.
|
||||
4. Вывести: сколько проектов создалось (ожидаем 3), их id + платформы (поле `src`).
|
||||
5. Cleanup: `deleteProject()` по каждому из найденных id.
|
||||
|
||||
```php
|
||||
<?php
|
||||
// app/storage/_smoke_multiflag_save.php — запуск: php artisan tinker --execute="require 'storage/_smoke_multiflag_save.php';"
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
|
||||
$client = app(SupplierPortalClient::class);
|
||||
$name = 'rsave-smoke-'.date('His').'.example';
|
||||
$tag = 'Москва';
|
||||
|
||||
// 1. multi-flag save (срабатываем напрямую через request — saveProject() пока single-flag).
|
||||
$resp = app('supplier.smoke.multiflag')($name, $tag); // helper ниже ИЛИ inline request
|
||||
echo "save response id (last): ".$resp."\n";
|
||||
|
||||
// 3. дочитать список
|
||||
$all = $client->listProjects();
|
||||
$mine = array_values(array_filter($all, fn ($p) => ($p['name'] ?? '') === $name && ($p['tag'] ?? '') === $tag));
|
||||
echo "found projects by name+tag: ".count($mine)."\n";
|
||||
foreach ($mine as $p) {
|
||||
echo " id={$p['id']} src=".json_encode($p['src'] ?? null)."\n";
|
||||
}
|
||||
|
||||
// 5. cleanup
|
||||
foreach ($mine as $p) {
|
||||
$client->deleteProject((int) $p['id']);
|
||||
echo "deleted {$p['id']}\n";
|
||||
}
|
||||
```
|
||||
|
||||
(`src`/поле платформы в строке listProjects — уточнить по факту вывода; recon §listProjects описывает конверт `{projects:[{id,tag,src,name,type,...}]}`.)
|
||||
|
||||
- [ ] **Step 2: Прогнать smoke против живого портала**
|
||||
|
||||
Run: `cd app; php artisan tinker --execute="require base_path('storage/_smoke_multiflag_save.php');"`
|
||||
Expected: вывод `found projects by name+tag: 3` + 3 разные платформы + 3 успешных delete. Привести ПОЛНЫЙ вывод.
|
||||
|
||||
- [ ] **Step 3: Зафиксировать находку**
|
||||
|
||||
Создать `docs/discovery/2026-05-20-rsave-multiflag-finding.md`: результат (3 ли проекта; как платформа кодируется в `src`; по каким полям матчить). **Решение:** вариант а подтверждён ИЛИ откат на б (3 раздельных save). Этот файл — вход для Task 4.
|
||||
|
||||
- [ ] **Step 4: Удалить временный скрипт**
|
||||
|
||||
```bash
|
||||
rm app/storage/_smoke_multiflag_save.php
|
||||
git add -- docs/discovery/2026-05-20-rsave-multiflag-finding.md
|
||||
git commit -m "docs(supplier): R-SAVE multi-flag save live smoke finding" -- docs/discovery/2026-05-20-rsave-multiflag-finding.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `SupplierExportMode` — резолвер глобального тумблера
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Supplier/SupplierExportMode.php`
|
||||
- Test: `app/tests/Feature/Supplier/SupplierExportModeTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\SupplierExportMode;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('reads mode from system_settings, defaults batch', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
expect(SupplierExportMode::current())->toBe('online')
|
||||
->and(SupplierExportMode::isOnline())->toBeTrue();
|
||||
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
|
||||
expect(SupplierExportMode::current())->toBe('batch')
|
||||
->and(SupplierExportMode::isOnline())->toBeFalse();
|
||||
});
|
||||
|
||||
it('falls back to batch when setting missing', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->delete();
|
||||
expect(SupplierExportMode::current())->toBe('batch');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierExportModeTest`
|
||||
Expected: FAIL — класса нет.
|
||||
|
||||
- [ ] **Step 3: Создать резолвер**
|
||||
|
||||
`app/app/Services/Supplier/SupplierExportMode.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Supplier;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Глобальный режим экспорта проектов поставщику (system_settings).
|
||||
* 'online' — sync сразу при create/edit с полными параметрами;
|
||||
* 'batch' — каркас сразу + полные параметры ночным SyncSupplierProjectsJob (18:00).
|
||||
* Spec: docs/superpowers/specs/2026-05-20-project-migration-redesign-design.md §4.1.
|
||||
*/
|
||||
final class SupplierExportMode
|
||||
{
|
||||
public const ONLINE = 'online';
|
||||
|
||||
public const BATCH = 'batch';
|
||||
|
||||
public static function current(): string
|
||||
{
|
||||
$value = DB::table('system_settings')->where('key', 'supplier_export_mode')->value('value');
|
||||
|
||||
return $value === self::ONLINE ? self::ONLINE : self::BATCH;
|
||||
}
|
||||
|
||||
public static function isOnline(): bool
|
||||
{
|
||||
return self::current() === self::ONLINE;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SupplierExportModeTest`
|
||||
Expected: PASS (2). Полный вывод.
|
||||
|
||||
- [ ] **Step 5: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint -- app/app/Services/Supplier/SupplierExportMode.php; composer stan
|
||||
git add -- app/app/Services/Supplier/SupplierExportMode.php app/tests/Feature/Supplier/SupplierExportModeTest.php
|
||||
git commit -m "feat(supplier): SupplierExportMode toggle resolver (online|batch)" -- app/app/Services/Supplier/SupplierExportMode.php app/tests/Feature/Supplier/SupplierExportModeTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `SupplierQuotaAllocator` — формула заказа max(наиб, ceil(Σ/3))
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Supplier/SupplierQuotaAllocator.php` (новый `computeOrder()`; удалить `distributeForPlatform`)
|
||||
- Test: `app/tests/Unit/Services/SupplierQuotaAllocatorTest.php` (переписать под формулу заказа)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест (verified-примеры из brief)**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\SupplierQuotaAllocator;
|
||||
|
||||
it('computeOrder = max(наибольший лимит, ceil(Σ/3))', function (array $limits, int $expected): void {
|
||||
expect(SupplierQuotaAllocator::computeOrder($limits))->toBe($expected);
|
||||
})->with([
|
||||
'brief 1' => [[5, 5, 10, 20], 20],
|
||||
'brief 2' => [array_merge(array_fill(0, 15, 5), [10]), 29], // 15×5 + 10 → Σ85, наиб10, ceil(85/3)=29
|
||||
'brief 3' => [[15, 15, 15], 15],
|
||||
'brief 4' => [[15, 15, 15, 30], 30],
|
||||
'brief 5' => [[10, 10, 10, 10], 14],
|
||||
'single' => [[7], 7],
|
||||
'empty' => [[], 0],
|
||||
]);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierQuotaAllocatorTest`
|
||||
Expected: FAIL — метода `computeOrder` нет (старый `allocate`/`distributeForPlatform` ещё на месте).
|
||||
|
||||
- [ ] **Step 3: Добавить `computeOrder`, удалить `distributeForPlatform`**
|
||||
|
||||
В `app/app/Services/Supplier/SupplierQuotaAllocator.php`:
|
||||
|
||||
- Добавить статический метод:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Заказ у поставщика на (источник × субъект): max(наибольший лимит, ceil(Σ/3)).
|
||||
* ceil(Σ/3) — ёмкость шаринга (лид продаётся ≤3 раз).
|
||||
* наиб — крупнейший клиент должен иметь шанс добрать.
|
||||
* Один лимит на группу; портал делит на B1/B2/B3 сам (R6 — наш split убран).
|
||||
*
|
||||
* @param array<int, int> $dailyLimits лимиты eligible-сегодня клиентов группы
|
||||
*/
|
||||
public static function computeOrder(array $dailyLimits): int
|
||||
{
|
||||
if ($dailyLimits === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$sum = array_sum($dailyLimits);
|
||||
$max = max($dailyLimits);
|
||||
|
||||
return max($max, (int) ceil($sum / 3));
|
||||
}
|
||||
```
|
||||
|
||||
- Удалить метод `distributeForPlatform()` ([SupplierQuotaAllocator.php:73-95](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L73)). `allocate()` и `unionInts()` — переписываются/используются в Task 5 (sync rewrite); на этом шаге **оставить `allocate()` как есть**, но заменить внутри вызов `distributeForPlatform(...)` на `computeOrder($eligibleProjects->pluck('daily_limit')->all())` для лимита (временная совместимость до Task 5; платформенный split исчезает — лимит одинаков для всех платформ). Конкретно строку `$platformLimit = self::distributeForPlatform(...)` ([:59](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L59)) заменить на:
|
||||
|
||||
```php
|
||||
$platformLimit = self::computeOrder(
|
||||
$eligibleProjects->pluck('daily_limit')->map(fn ($v) => (int) $v)->all()
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SupplierQuotaAllocatorTest`
|
||||
Expected: PASS (7 датасетов). Полный вывод.
|
||||
|
||||
- [ ] **Step 5: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint -- app/app/Services/Supplier/SupplierQuotaAllocator.php; composer stan
|
||||
git add -- app/app/Services/Supplier/SupplierQuotaAllocator.php app/tests/Unit/Services/SupplierQuotaAllocatorTest.php
|
||||
git commit -m "feat(supplier): order formula max(max, ceil(sum/3)), drop platform split" -- app/app/Services/Supplier/SupplierQuotaAllocator.php app/tests/Unit/Services/SupplierQuotaAllocatorTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: `SupplierPortalClient::saveProjectMultiFlag` — R5/R6/R7 save (вариант а)
|
||||
|
||||
> Точная реализация захвата id зависит от Task 1 finding. Ниже — вариант а (дочитать listProjects). Если smoke показал иное (б) — реализовать раздельные save по платформам.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Supplier/SupplierPortalClient.php` (новый `saveProjectMultiFlag()`; `toPayload` — tag из dto, multi-flag по набору платформ)
|
||||
- Modify: `app/app/Services/Supplier/Dto/SupplierProjectDto.php` (+поля `tag`, `platforms`)
|
||||
- Test: `app/tests/Feature/Supplier/SupplierPortalClientMultiFlagTest.php` (HTTP::fake)
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест (HTTP::fake save + listProjects)**
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Supplier\Dto\SupplierProjectDto;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('multi-flag save returns external_id per platform via listProjects', function (): void {
|
||||
Http::fake([
|
||||
'*/admin/visit/rt-project-save' => Http::response(['status' => 'OK', 'id' => '300'], 200),
|
||||
'*/admin/visit/rt-projects-load*' => Http::response(['projects' => [
|
||||
['id' => '100', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'rt'],
|
||||
['id' => '200', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'bl'],
|
||||
['id' => '300', 'name' => 'okna.ru', 'tag' => 'Москва', 'src' => 'mt'],
|
||||
['id' => '999', 'name' => 'other.ru', 'tag' => 'Москва', 'src' => 'rt'],
|
||||
]], 200),
|
||||
]);
|
||||
|
||||
$dto = new SupplierProjectDto(
|
||||
platform: 'B1', signalType: 'site', uniqueKey: 'okna.ru', limit: 9,
|
||||
workdays: [1,2,3,4,5,6,7], regions: [82], regionsReverse: false, status: 'active',
|
||||
tag: 'Москва', platforms: ['B1','B2','B3'],
|
||||
);
|
||||
|
||||
$ids = app(SupplierPortalClient::class)->saveProjectMultiFlag($dto);
|
||||
|
||||
expect($ids)->toBe(['B1' => 100, 'B2' => 200, 'B3' => 300]);
|
||||
});
|
||||
```
|
||||
|
||||
(Маппинг `src`-кода портала → платформа — по Task 1 finding: `rt→B1, bl→B2, mt→B3` ориентировочно из `srcrt/srcbl/srcmt`. Уточнить по выводу smoke.)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SupplierPortalClientMultiFlagTest`
|
||||
Expected: FAIL — метода нет, DTO без `tag`/`platforms`.
|
||||
|
||||
- [ ] **Step 3: Расширить DTO**
|
||||
|
||||
В `app/app/Services/Supplier/Dto/SupplierProjectDto.php` добавить в конструктор два параметра (с дефолтами для обратной совместимости single-flag путей):
|
||||
|
||||
```php
|
||||
public string $tag = '_lidpotok',
|
||||
/** @var array<int, string> */
|
||||
public array $platforms = [],
|
||||
```
|
||||
|
||||
(`platforms` пустой → single `[platform]`. `tag` дефолт `_lidpotok` — старые пути не ломаются.)
|
||||
|
||||
- [ ] **Step 4: `toPayload` — tag из dto + multi-flag**
|
||||
|
||||
В `toPayload` ([SupplierPortalClient.php:413](../../../app/app/Services/Supplier/SupplierPortalClient.php#L413)):
|
||||
|
||||
- `'tag' => $dto->tag,` (вместо хардкода `'_lidpotok'`).
|
||||
- Набор активных платформ: `$platforms = $dto->platforms !== [] ? $dto->platforms : [$dto->platform];` → `'srcrt' => in_array('B1', $platforms, true), 'srcbl' => in_array('B2', $platforms, true), 'srcmt' => in_array('B3', $platforms, true),`.
|
||||
- `'regions' => $dto->regions` уже есть — теперь несёт `[subject_code]` или `[]`.
|
||||
|
||||
- [ ] **Step 5: Добавить `saveProjectMultiFlag`**
|
||||
|
||||
```php
|
||||
/**
|
||||
* R5: один save с флагами всех dto->platforms → портал создаёт N rt-проектов,
|
||||
* портал делит лимит сам (R6). Ответ rt-project-save отдаёт id последнего →
|
||||
* дочитываем listProjects и матчим по name+tag (R-SAVE вариант а, Task 1 finding).
|
||||
*
|
||||
* @return array<string, int> [platform => external_id]
|
||||
*/
|
||||
public function saveProjectMultiFlag(SupplierProjectDto $dto): array
|
||||
{
|
||||
$response = $this->request(
|
||||
'POST', '/admin/visit/rt-project-save',
|
||||
$this->toPayload($dto, externalId: 0), asJson: true,
|
||||
);
|
||||
$this->assertStatusOk($response, '/admin/visit/rt-project-save');
|
||||
|
||||
$srcToPlatform = ['rt' => 'B1', 'bl' => 'B2', 'mt' => 'B3'];
|
||||
$out = [];
|
||||
foreach ($this->listProjects() as $p) {
|
||||
if (($p['name'] ?? null) !== $dto->uniqueKey || ($p['tag'] ?? null) !== $dto->tag) {
|
||||
continue;
|
||||
}
|
||||
$platform = $srcToPlatform[$p['src'] ?? ''] ?? null;
|
||||
if ($platform !== null && in_array($platform, $dto->platforms !== [] ? $dto->platforms : [$dto->platform], true)) {
|
||||
$out[$platform] = (int) $p['id'];
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SupplierPortalClientMultiFlagTest`
|
||||
Expected: PASS. Полный вывод. (Старые `SupplierPortalClient` тесты — single-flag/`_lidpotok` дефолт — не затронуты; прогнать `--filter=SupplierPortalClient` целиком.)
|
||||
|
||||
- [ ] **Step 7: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint -- app/app/Services/Supplier/SupplierPortalClient.php app/app/Services/Supplier/Dto/SupplierProjectDto.php; composer stan
|
||||
git add -- app/app/Services/Supplier/SupplierPortalClient.php app/app/Services/Supplier/Dto/SupplierProjectDto.php app/tests/Feature/Supplier/SupplierPortalClientMultiFlagTest.php
|
||||
git commit -m "feat(supplier): saveProjectMultiFlag R5 + tag/platforms DTO (R6/R7)" -- app/app/Services/Supplier/SupplierPortalClient.php app/app/Services/Supplier/Dto/SupplierProjectDto.php app/tests/Feature/Supplier/SupplierPortalClientMultiFlagTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `SyncSupplierProjectsJob` — группировка (источник × субъект) + pivot
|
||||
|
||||
> Ядро рерайта. Переход от FK-итерации к группировке активных Лидерра-проектов по (источник × субъект); на группу — один multi-flag save + 3 supplier_projects (по платформе, subject_code) + pivot-связи.
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php`
|
||||
- Modify: `app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php`
|
||||
|
||||
**Алгоритм `handle()` (новый):**
|
||||
|
||||
1. Загрузить активные Лидерра-projects (`is_active=true`, `archived_at IS NULL`) через `pgsql_supplier`.
|
||||
2. Развернуть каждый в группы `(signal_type, identifier, subject_code)`:
|
||||
- subjects = `project.regions` (коды 1..89); если пусто → одна группа с `subject_code = null` (пул «Вся РФ»).
|
||||
- identifier = `signal_identifier` (site/call) / sender(+keyword) (sms) — как `buildUniqueKey` в `SyncSupplierProjectJob`.
|
||||
- platforms = `resolvePlatforms()` (site/call → B1+B2+B3; sms+keyword → B2+B3; sms → B3).
|
||||
3. Для каждой группы:
|
||||
- eligible-сегодня (workday-маска на завтра, как сейчас targetDate=tomorrow) проекты группы.
|
||||
- `order = SupplierQuotaAllocator::computeOrder($eligibleLimits)`; `workdays = union`; `tag = subject ? RussianRegions::CODE_TO_NAME[subject] : 'РФ'`; `regions = subject ? [subject] : []`.
|
||||
- найти существующие supplier_projects группы (по `unique_key=identifier`, `subject_code`); если ни одного — создать через `saveProjectMultiFlag` (→ 3 id), upsert 3 supplier_projects (platform, signal_type, unique_key, subject_code, supplier_external_id, current_limit=order, current_regions); если есть — `updateProject` каждого external_id (R6: один лимит).
|
||||
- синхронизировать pivot: для каждого contributing Лидерра-проекта × каждого supplier_project группы — upsert строки `project_supplier_links` (idempotent ON CONFLICT).
|
||||
4. Failure-handling (Auth/Transient/Client/Window/TierEscalated), time-budget cutoff — **сохранить как есть**.
|
||||
|
||||
- [ ] **Step 1: Переписать тест `SyncSupplierProjectsJobTest`** под группировку
|
||||
|
||||
Ключевые кейсы (HTTP::fake портала; адаптировать фабрики существующего файла):
|
||||
|
||||
- **per-subject:** проект `regions=[82,83]`, site → 2 группы (Москва, СПб) → 2 multi-flag save → 6 supplier_projects (2 субъекта × 3 платформы) с верными `subject_code`/`tag`; pivot — 6 связей на проект.
|
||||
- **вся РФ пул:** проект `regions=[]` → 1 группа `subject_code=null`, `tag='РФ'`, `regions=[]` → 3 supplier_projects.
|
||||
- **order:** 2 проекта на один (источник×субъект) лимиты [10,20] → save с `limit=20` (computeOrder).
|
||||
- **sms:** sms+keyword → платформы B2+B3 (2 supplier_projects на субъект).
|
||||
- **idempotent:** повторный прогон без изменений → updateProject (не дубль supplier_projects/pivot).
|
||||
|
||||
(Привести полный код хотя бы первого и order-кейса по образцу существующих тестов файла + `Http::fake` save/list как в Task 4.)
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SyncSupplierProjectsJobTest`
|
||||
Expected: FAIL — старая FK-итерация не группирует по субъекту, не пишет pivot.
|
||||
|
||||
- [ ] **Step 3: Переписать `handle()` + `syncOne` → `syncGroup`**
|
||||
|
||||
Реализовать алгоритм выше. Сохранить: connection `pgsql_supplier`, time-budget cutoff, все catch-блоки (Auth/Transient/Client/Window/Tier), `SupplierSyncLog`. Заменить: FK-итерацию supplier_projects → итерацию по группам активных проектов; `SupplierQuotaAllocator::allocate` → `computeOrder` + ручная сборка DTO (tag/regions/platforms) + `saveProjectMultiFlag` на create; pivot upsert через `DB::table('project_supplier_links')->...->upsert(...)` или `INSERT ... ON CONFLICT DO NOTHING`. Хелперы `buildUniqueKey`/`resolvePlatforms` — переиспользовать (вынести из `SyncSupplierProjectJob` в общий трейт/сервис `SupplierProjectGrouping` чтобы DRY; см. Task 6).
|
||||
|
||||
**NB:** точная сигнатура save-капчи — по Task 1 finding (вариант а реализован в Task 4 `saveProjectMultiFlag`). Update-путь существующих проектов остаётся `updateProject(externalId, dto)` на каждый из 3 (R6: одинаковый лимит).
|
||||
|
||||
- [ ] **Step 4: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SyncSupplierProjectsJobTest`
|
||||
Expected: PASS все кейсы. Полный вывод; failed — file:line.
|
||||
|
||||
- [ ] **Step 5: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint -- app/app/Jobs/Supplier/SyncSupplierProjectsJob.php; composer stan
|
||||
git add -- app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php
|
||||
git commit -m "feat(supplier): SyncSupplierProjectsJob per-subject grouping + pivot + order" -- app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: `SyncSupplierProjectJob` (онлайн) — mode-aware полные параметры
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Services/Supplier/SupplierProjectGrouping.php` (DRY-хелперы buildUniqueKey/resolvePlatforms/subjectsOf)
|
||||
- Modify: `app/app/Jobs/SyncSupplierProjectJob.php` (online: full params; batch: каркас как сейчас)
|
||||
- Test: `app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php`
|
||||
|
||||
- [ ] **Step 1: Написать падающий тест**
|
||||
|
||||
```php
|
||||
it('online mode creates per-subject supplier_projects with full params + pivot', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'online']);
|
||||
Http::fake([/* save OK + listProjects 3 ids per subject как в Task 4 */]);
|
||||
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = Project::factory()->create([
|
||||
'tenant_id' => $tenant->id, 'signal_type' => 'site', 'signal_identifier' => 'okna.ru',
|
||||
'is_active' => true, 'daily_limit_target' => 12, 'regions' => [82], 'delivery_days_mask' => 127,
|
||||
]);
|
||||
|
||||
(new SyncSupplierProjectJob($project->id))->handle(app(SupplierProjectChannel::class));
|
||||
|
||||
expect(DB::table('project_supplier_links')->where('project_id', $project->id)->count())->toBe(3)
|
||||
->and(SupplierProject::where('unique_key', 'okna.ru')->where('subject_code', 82)->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('batch mode keeps каркас (limit 0, no subject save)', function (): void {
|
||||
DB::table('system_settings')->where('key', 'supplier_export_mode')->update(['value' => 'batch']);
|
||||
// ... ожидание: каркас как сейчас (current_limit=0), ночной job дольёт.
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Прогнать — падает**
|
||||
|
||||
Run: `php artisan test --filter=SyncSupplierProjectJobTest`
|
||||
Expected: FAIL — джоб не mode-aware, не per-subject.
|
||||
|
||||
- [ ] **Step 3: Вынести общие хелперы в `SupplierProjectGrouping`**
|
||||
|
||||
`app/app/Services/Supplier/SupplierProjectGrouping.php` — статические `buildUniqueKey(Project,$platform)`, `resolvePlatforms(Project): array`, `subjectsOf(Project): array` (`regions ?: [null]`). Перенести логику из `SyncSupplierProjectJob::buildUniqueKey/resolvePlatforms` ([SyncSupplierProjectJob.php:138-172](../../../app/app/Jobs/SyncSupplierProjectJob.php#L138)); Task 5 sync-job тоже их использует (DRY).
|
||||
|
||||
- [ ] **Step 4: Сделать `SyncSupplierProjectJob::handle()` mode-aware**
|
||||
|
||||
`online` → для каждой (subject × platform-set) группы проекта: `saveProjectMultiFlag` (full params: limit=daily_limit_target, regions=[subject]/[], tag) → upsert supplier_projects + pivot. `batch` → текущий каркас-путь (limit 0, regions []), ночной `SyncSupplierProjectsJob` дольёт. Эскалация в ярус-3 при сбое — сохранить (FailoverProjectChannel).
|
||||
|
||||
- [ ] **Step 5: Прогнать тест — зелёный**
|
||||
|
||||
Run: `php artisan test --filter=SyncSupplierProjectJobTest`
|
||||
Expected: PASS. Полный вывод.
|
||||
|
||||
- [ ] **Step 6: pint + larastan + Коммит**
|
||||
|
||||
```bash
|
||||
composer pint -- app/app/Services/Supplier/SupplierProjectGrouping.php app/app/Jobs/SyncSupplierProjectJob.php; composer stan
|
||||
git add -- app/app/Services/Supplier/SupplierProjectGrouping.php app/app/Jobs/SyncSupplierProjectJob.php app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php
|
||||
git commit -m "feat(supplier): online-mode full-param per-subject sync + grouping helpers" -- app/app/Services/Supplier/SupplierProjectGrouping.php app/app/Jobs/SyncSupplierProjectJob.php app/tests/Feature/Supplier/SyncSupplierProjectJobTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: закрепить крон 18:00 тестом (R2 — уже выполнен)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify/verify: `app/tests/Feature/Schedule/SupplierScheduleTest.php`
|
||||
|
||||
- [ ] **Step 1: Убедиться, что тест требует 18:00**
|
||||
|
||||
`SupplierScheduleTest.php:15` уже: «SyncSupplierProjectsJob is scheduled at 18:00 MSK». Прогнать:
|
||||
Run: `php artisan test --filter=SupplierScheduleTest`
|
||||
Expected: PASS (крон уже `dailyAt('18:00')`, [console.php:52](../../../app/routes/console.php#L52)). Если теста на 18:00 нет — добавить assert по образцу существующего. R2 = no-op кроме закрепления.
|
||||
|
||||
- [ ] **Step 2: Коммит (если тест добавлялся)**
|
||||
|
||||
```bash
|
||||
git add -- app/tests/Feature/Schedule/SupplierScheduleTest.php
|
||||
git commit -m "test(supplier): lock SyncSupplierProjectsJob cron at 18:00 MSK (R2)" -- app/tests/Feature/Schedule/SupplierScheduleTest.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Регрессия экспорта
|
||||
|
||||
**Files:** нет (проверка).
|
||||
|
||||
- [ ] **Step 1: Полный Supplier + Jobs + Schedule suite**
|
||||
|
||||
Run: `php artisan test app/tests/Feature/Supplier app/tests/Feature/Jobs app/tests/Feature/Schedule app/tests/Unit/Services`
|
||||
Expected: GREEN. passed/failed; failed — file:line.
|
||||
|
||||
- [ ] **Step 2: pint + larastan**
|
||||
|
||||
Run: `composer pint; composer stan`
|
||||
Expected: 0 ошибок.
|
||||
|
||||
- [ ] **Step 3 (verification-before-completion):** перед «План 3 готов» — invoke `superpowers:verification-before-completion`; фактический вывод тестов.
|
||||
|
||||
---
|
||||
|
||||
## Self-review (выполнен при написании плана)
|
||||
|
||||
- **Покрытие spec §4.1/§4.3/§4.5/§8:** режимы online/batch (T2 + T6 mode-aware + T7 крон); формула заказа (T3); R5 multi-flag + захват id (T1 smoke + T4); R6 (split убран — T3); R7 tag=субъект/regions=[subject]/[] (T4 toPayload + T5/T6 группировка); per-субъект + pivot (T5/T6).
|
||||
- **Без плейсхолдеров:** концертный код для T2/T3/T4. T1 (smoke) и T5 (sync rewrite) несут явную зависимость от живого контракта портала — это не TODO, а гейт: T1 фиксирует контракт, T5 реализует поверх него. `src→platform` маппинг помечен «уточнить по выводу smoke» (реальная неизвестность портала, закрывается T1).
|
||||
- **Согласованность типов:** `computeOrder(array<int>): int`; `saveProjectMultiFlag(DTO): array<string,int>`; DTO +`tag:string`/`platforms:array` с дефолтами (single-flag пути целы); `SupplierExportMode::current(): string`. `subject_code` int|null — совпадает с Планом 1.
|
||||
- **Риск (R-SAVE):** если T1 smoke покажет, что 3 id из listProjects не вытащить надёжно — переключиться на fallback б (3 раздельных save, каждый отдаёт id) в T4; алгоритм T5 при этом не меняется (он зовёт `saveProjectMultiFlag`, чья внутренность инкапсулирует а/б).
|
||||
@@ -1,746 +0,0 @@
|
||||
# Project delete + source dedup + human errors — 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:** Заменить архивацию проектов настоящим удалением (с защитой по сделкам и корректной обработкой шеринга у поставщика), добавить дедуп источника внутри клиента и заменить сырые SQL-ошибки человеческими сообщениями.
|
||||
|
||||
**Architecture:** Бэкенд — вся логика в `ProjectService` (create/update/delete) + новый `DeleteSupplierProjectJob` для удаления/пере-синка донора у поставщика с учётом шеринга + глобальный handler `QueryException`. Архивация (`archived_at`) убирается полностью (миграция-снос колонки). Фронтенд — «Архивировать»→«Удалить», убрать фильтр «Архивные».
|
||||
|
||||
**Tech Stack:** PHP 8.3 / Laravel 13, Pest 4; Vue 3 + Vuetify 3 + Pinia, Vitest; PostgreSQL 16.
|
||||
|
||||
**Спека:** `docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md`
|
||||
|
||||
**Команды (из `app/`):** `C:/tools/php83/php.exe artisan test --filter=<name>`, `composer pint`, `composer stan`, `npm run test:vue`.
|
||||
|
||||
**Ключевые факты (разведка):**
|
||||
|
||||
- `Project` — БЕЗ SoftDeletes → `$project->delete()` = hard delete. У `projects` нет `deleted_at` (только `archived_at` custom + `is_active`).
|
||||
- `Deal` — С SoftDeletes (`deals.deleted_at`). Guard считает ВСЕ сделки через `DB::table('deals')` (минует scope).
|
||||
- `deals.project_id` без FK; cascade на `projects(id)` только у служебных таблиц.
|
||||
- `supplier_projects.supplier_external_id` VARCHAR(64) — id донора у поставщика (числовой; cast к int для `deleteProject(int)`).
|
||||
- `project_supplier_links` — pivot (project_id, supplier_project_id), ON DELETE CASCADE на оба.
|
||||
- Источник: `signal_identifier` (call/site) либо `sms_senders[]`+`sms_keyword` (sms).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Дедуп источника + имени в ProjectService::create()
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Project/ProjectService.php` (метод `create`, +helper `assertSourceUnique`)
|
||||
- Test: `app/tests/Feature/Project/ProjectCreateDedupTest.php` (create)
|
||||
|
||||
- [ ] **Step 1: Написать падающие тесты**
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
});
|
||||
|
||||
function makeCall(array $over = []): array {
|
||||
return array_merge([
|
||||
'name' => 'Проект A', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
|
||||
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
|
||||
], $over);
|
||||
}
|
||||
|
||||
it('blocks duplicate source within tenant with human message', function () {
|
||||
app(\App\Services\Project\ProjectService::class)->create($this->tenant, makeCall());
|
||||
expect(fn () => app(\App\Services\Project\ProjectService::class)
|
||||
->create($this->tenant, makeCall(['name' => 'Проект B'])))
|
||||
->toThrow(\Illuminate\Http\Exceptions\HttpResponseException::class);
|
||||
});
|
||||
|
||||
it('allows same source for a different tenant (sharing)', function () {
|
||||
$other = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
app(\App\Services\Project\ProjectService::class)->create($this->tenant, makeCall());
|
||||
$p = app(\App\Services\Project\ProjectService::class)->create($other, makeCall(['name' => 'Проект B']));
|
||||
expect($p)->toBeInstanceOf(Project::class);
|
||||
});
|
||||
|
||||
it('blocks duplicate name within tenant with human message (not SQL)', function () {
|
||||
app(\App\Services\Project\ProjectService::class)->create($this->tenant, makeCall());
|
||||
try {
|
||||
app(\App\Services\Project\ProjectService::class)
|
||||
->create($this->tenant, makeCall(['name' => 'Проект A', 'signal_identifier' => '79992220000']));
|
||||
$this->fail('expected HttpResponseException');
|
||||
} catch (\Illuminate\Http\Exceptions\HttpResponseException $e) {
|
||||
$body = $e->getResponse()->getData(true);
|
||||
expect($body['errors']['name'][0] ?? '')->not->toContain('SQLSTATE');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — убедиться что падают**
|
||||
|
||||
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectCreateDedupTest`
|
||||
Expected: FAIL (дедупа нет — второй create проходит / бьёт DB-констрейнт).
|
||||
|
||||
- [ ] **Step 3: Реализация в `ProjectService::create()`**
|
||||
|
||||
В начало `create()` (после получения `$tenant`, до `Project::create`) добавить вызовы и helper'ы:
|
||||
|
||||
```php
|
||||
// перед Project::create($data):
|
||||
$this->assertNameUnique($tenant->id, (string) $data['name']);
|
||||
$this->assertSourceUnique($tenant->id, $data);
|
||||
```
|
||||
|
||||
Добавить методы в класс:
|
||||
|
||||
```php
|
||||
private function assertNameUnique(int $tenantId, string $name, ?int $exceptId = null): void
|
||||
{
|
||||
$q = Project::where('tenant_id', $tenantId)->where('name', $name);
|
||||
if ($exceptId !== null) {
|
||||
$q->where('id', '!=', $exceptId);
|
||||
}
|
||||
if ($q->exists()) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'errors' => ['name' => ['Проект с таким названием у вас уже есть. Выберите другое название.']],
|
||||
], 422));
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $data */
|
||||
private function assertSourceUnique(int $tenantId, array $data, ?int $exceptId = null): void
|
||||
{
|
||||
$signalType = $data['signal_type'] ?? null;
|
||||
$q = Project::where('tenant_id', $tenantId)->where('signal_type', $signalType);
|
||||
if ($exceptId !== null) {
|
||||
$q->where('id', '!=', $exceptId);
|
||||
}
|
||||
|
||||
if (in_array($signalType, ['call', 'site'], true)) {
|
||||
$identifier = (string) ($data['signal_identifier'] ?? '');
|
||||
if ($identifier === '') {
|
||||
return;
|
||||
}
|
||||
$q->where('signal_identifier', $identifier);
|
||||
} elseif ($signalType === 'sms') {
|
||||
$senders = (array) ($data['sms_senders'] ?? []);
|
||||
$norm = collect($senders)->map(fn ($s) => mb_strtolower(trim((string) $s)))->sort()->values()->all();
|
||||
if ($norm === []) {
|
||||
return;
|
||||
}
|
||||
// sms-источник идентичен, если совпадают набор отправителей и ключевое слово.
|
||||
$keyword = $data['sms_keyword'] ?? null;
|
||||
$q->where('sms_keyword', $keyword)
|
||||
->whereJsonContains('sms_senders', $norm)
|
||||
->whereRaw('jsonb_array_length(sms_senders::jsonb) = ?', [count($norm)]);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
$existing = $q->first();
|
||||
if ($existing !== null) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'errors' => ['signal_identifier' => ["У вас уже есть проект с этим источником: «{$existing->name}»."]],
|
||||
], 422));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Убедиться, что в шапке есть `use Illuminate\Http\Exceptions\HttpResponseException;` (уже есть).
|
||||
|
||||
- [ ] **Step 4: Запустить — PASS**
|
||||
|
||||
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectCreateDedupTest`
|
||||
Expected: PASS (3 теста).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Services/Project/ProjectService.php app/tests/Feature/Project/ProjectCreateDedupTest.php
|
||||
git commit -m "feat(projects): source+name dedup with human messages on create"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Дедуп источника при смене источника (update)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Project/ProjectService.php` (метод `update`)
|
||||
- Test: `app/tests/Feature/Project/ProjectUpdateDedupTest.php`
|
||||
|
||||
- [ ] **Step 1: Падающий тест**
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
|
||||
it('blocks update that collides source with another project of same tenant', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$svc = app(\App\Services\Project\ProjectService::class);
|
||||
$a = $svc->create($tenant, ['name' => 'A', 'signal_type' => 'call', 'signal_identifier' => '79991110000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
|
||||
$b = $svc->create($tenant, ['name' => 'B', 'signal_type' => 'call', 'signal_identifier' => '79992220000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
|
||||
|
||||
expect(fn () => $svc->update($b, ['signal_identifier' => '79991110000']))
|
||||
->toThrow(\Illuminate\Http\Exceptions\HttpResponseException::class);
|
||||
});
|
||||
|
||||
it('allows update keeping same source on the same project', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$svc = app(\App\Services\Project\ProjectService::class);
|
||||
$a = $svc->create($tenant, ['name' => 'A', 'signal_type' => 'call', 'signal_identifier' => '79991110000', 'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31]);
|
||||
$updated = $svc->update($a, ['signal_identifier' => '79991110000', 'daily_limit_target' => 7]);
|
||||
expect($updated->daily_limit_target)->toBe(7);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — FAIL**
|
||||
|
||||
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectUpdateDedupTest`
|
||||
Expected: FAIL (первый кейс не бросает).
|
||||
|
||||
- [ ] **Step 3: Реализация в `update()`**
|
||||
|
||||
После блока immutable-unset и до `$project->update($data)` добавить:
|
||||
|
||||
```php
|
||||
if (array_key_exists('signal_identifier', $data) || array_key_exists('sms_senders', $data) || array_key_exists('sms_keyword', $data)) {
|
||||
$this->assertSourceUnique($project->tenant_id, array_merge([
|
||||
'signal_type' => $project->signal_type,
|
||||
'signal_identifier' => $project->signal_identifier,
|
||||
'sms_senders' => $project->sms_senders,
|
||||
'sms_keyword' => $project->sms_keyword,
|
||||
], $data), exceptId: $project->id);
|
||||
}
|
||||
if (array_key_exists('name', $data)) {
|
||||
$this->assertNameUnique($project->tenant_id, (string) $data['name'], exceptId: $project->id);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить — PASS**
|
||||
|
||||
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectUpdateDedupTest`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Services/Project/ProjectService.php app/tests/Feature/Project/ProjectUpdateDedupTest.php
|
||||
git commit -m "feat(projects): source+name dedup on update"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Глобальный handler QueryException (никакого SQL в UI)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/bootstrap/app.php` (`withExceptions`)
|
||||
- Test: `app/tests/Feature/Project/QueryExceptionRenderTest.php`
|
||||
|
||||
- [ ] **Step 1: Падающий тест** (бьём прямой DB-констрейнт мимо app-проверок — два проекта с одинаковым именем через прямой insert невозможно из API после Task 1, поэтому тестируем рендер handler'а на искусственном маршруте)
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
it('renders QueryException as human JSON message, not SQLSTATE', function () {
|
||||
Route::get('/_test/boom-query', function () {
|
||||
throw new \Illuminate\Database\QueryException('pgsql', 'SELECT 1', [], new \Exception('SQLSTATE[23505] duplicate key'));
|
||||
});
|
||||
|
||||
$res = $this->getJson('/_test/boom-query');
|
||||
$res->assertStatus(422);
|
||||
expect($res->json('message'))->not->toContain('SQLSTATE');
|
||||
expect($res->json('message'))->toContain('Не удалось');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — FAIL**
|
||||
|
||||
Run: `C:/tools/php83/php.exe artisan test --filter=QueryExceptionRenderTest`
|
||||
Expected: FAIL (по умолчанию 500 + SQL-текст в debug).
|
||||
|
||||
- [ ] **Step 3: Реализация в `bootstrap/app.php`**
|
||||
|
||||
Заменить тело `->withExceptions(function (Exceptions $exceptions): void { // });` на:
|
||||
|
||||
```php
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->render(function (\Illuminate\Database\QueryException $e, \Illuminate\Http\Request $request) {
|
||||
\Illuminate\Support\Facades\Log::error('db.query_exception', [
|
||||
'message' => $e->getMessage(),
|
||||
'sql' => $e->getSql(),
|
||||
'path' => $request->path(),
|
||||
]);
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'Не удалось сохранить. Проверьте данные или попробуйте ещё раз.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
return null; // дефолтный рендер для не-JSON
|
||||
});
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить — PASS**
|
||||
|
||||
Run: `C:/tools/php83/php.exe artisan test --filter=QueryExceptionRenderTest`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -- app/bootstrap/app.php app/tests/Feature/Project/QueryExceptionRenderTest.php
|
||||
git commit -m "feat(errors): global QueryException handler returns human message"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: ProjectService::delete() с guard по сделкам (+ снос archive())
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Services/Project/ProjectService.php` (+`delete()`, −`archive()`, bulk `archive`→`delete`)
|
||||
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (`destroy`→`delete`)
|
||||
- Modify: `app/app/Http/Requests/BulkProjectActionRequest.php` (`archive`→`delete`)
|
||||
- Test: `app/tests/Feature/Project/ProjectDeleteTest.php`
|
||||
|
||||
- [ ] **Step 1: Падающие тесты**
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Project;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('hard-deletes an empty project', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = app(\App\Services\Project\ProjectService::class)->create($tenant, [
|
||||
'name' => 'Empty', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
|
||||
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
|
||||
]);
|
||||
|
||||
app(\App\Services\Project\ProjectService::class)->delete($project);
|
||||
|
||||
expect(Project::find($project->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('blocks delete when project has deals', function () {
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$project = app(\App\Services\Project\ProjectService::class)->create($tenant, [
|
||||
'name' => 'WithDeals', 'signal_type' => 'call', 'signal_identifier' => '79991110000',
|
||||
'daily_limit_target' => 5, 'regions' => [], 'delivery_days_mask' => 31,
|
||||
]);
|
||||
DB::table('deals')->insert([
|
||||
'tenant_id' => $tenant->id, 'project_id' => $project->id, 'phone' => '79990001122',
|
||||
'status' => 'new', 'received_at' => now(), 'created_at' => now(),
|
||||
]);
|
||||
|
||||
expect(fn () => app(\App\Services\Project\ProjectService::class)->delete($project))
|
||||
->toThrow(\Illuminate\Http\Exceptions\HttpResponseException::class);
|
||||
expect(Project::find($project->id))->not->toBeNull();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — FAIL**
|
||||
|
||||
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectDeleteTest`
|
||||
Expected: FAIL (метода `delete()` нет).
|
||||
|
||||
- [ ] **Step 3: Реализация**
|
||||
|
||||
В `ProjectService` добавить `delete()` и удалить `archive()`:
|
||||
|
||||
```php
|
||||
public function delete(Project $project): void
|
||||
{
|
||||
$hasDeals = DB::table('deals')->where('project_id', $project->id)->exists();
|
||||
if ($hasDeals) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'errors' => ['project' => ['Нельзя удалить проект: по нему есть сделки. Поставьте приём на паузу, чтобы скрыть проект из работы.']],
|
||||
], 422));
|
||||
}
|
||||
|
||||
// Доноров фиксируем ДО удаления — pivot уйдёт каскадом.
|
||||
$supplierProjectIds = DB::table('project_supplier_links')
|
||||
->where('project_id', $project->id)
|
||||
->pluck('supplier_project_id')
|
||||
->all();
|
||||
|
||||
$project->delete(); // hard delete (Project без SoftDeletes); cascade чистит pivot + служебные.
|
||||
|
||||
if ($supplierProjectIds !== []) {
|
||||
\App\Jobs\Supplier\DeleteSupplierProjectJob::dispatch(array_map('intval', $supplierProjectIds));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Добавить `use Illuminate\Support\Facades\DB;` в шапку. Удалить метод `archive()`. В `bulkAction()` строку `'archive' => ...` заменить на:
|
||||
|
||||
```php
|
||||
'delete' => $this->bulkDelete($query),
|
||||
```
|
||||
|
||||
Добавить `bulkDelete` (guard per-project, не роняет батч):
|
||||
|
||||
```php
|
||||
private function bulkDelete($query): array
|
||||
{
|
||||
$projects = (clone $query)->get(['id']);
|
||||
$deleted = 0; $skipped = [];
|
||||
foreach ($projects as $p) {
|
||||
$model = Project::find($p->id);
|
||||
if ($model === null) { continue; }
|
||||
try {
|
||||
$this->delete($model);
|
||||
$deleted++;
|
||||
} catch (HttpResponseException) {
|
||||
$skipped[] = ['id' => $p->id, 'reason' => 'has_deals'];
|
||||
}
|
||||
}
|
||||
|
||||
return ['updated' => $deleted, 'skipped' => $skipped, 'warnings' => []];
|
||||
}
|
||||
```
|
||||
|
||||
В `update()` убрать из unset строку `$data['archived_at'],` (колонка уходит в Task 6). В `resolveBulkScope()` ветку match `'archived' => ...` удалить; `'active'`/`'paused'` оставить без `whereNull('archived_at')` (см. Task 6).
|
||||
|
||||
В `ProjectController::destroy()` заменить `$this->projects->archive($project);` на `$this->projects->delete($project);` и docblock «soft-archive» → «hard delete (guard по сделкам)».
|
||||
|
||||
В `BulkProjectActionRequest`: в `Rule::in([...])` для action `'archive'` → `'delete'`; убрать `'archived'` из `Rule::in(['active','paused','archived'])`.
|
||||
|
||||
- [ ] **Step 4: Запустить — PASS** + регрессия bulk
|
||||
|
||||
Run: `C:/tools/php83/php.exe artisan test --filter=ProjectDeleteTest`
|
||||
Then: `C:/tools/php83/php.exe artisan test --filter=Project`
|
||||
Expected: PASS; падений по `archive` нет (если есть старые тесты на archive — обновить на delete в этом же шаге).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Services/Project/ProjectService.php app/app/Http/Controllers/Api/ProjectController.php app/app/Http/Requests/BulkProjectActionRequest.php app/tests/Feature/Project/ProjectDeleteTest.php
|
||||
git commit -m "feat(projects): hard delete with deals-guard, replace archive"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: DeleteSupplierProjectJob — удаление/пере-синк донора с учётом шеринга
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `app/app/Jobs/Supplier/DeleteSupplierProjectJob.php`
|
||||
- Test: `app/tests/Feature/Supplier/DeleteSupplierProjectJobTest.php`
|
||||
|
||||
- [ ] **Step 1: Падающие тесты** (mock `SupplierPortalClient`)
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\Supplier\DeleteSupplierProjectJob;
|
||||
use App\Jobs\Supplier\SyncSupplierProjectsJob;
|
||||
use App\Models\Project;
|
||||
use App\Models\SupplierProject;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('deletes donor at supplier when no consumers remain', function () {
|
||||
$sp = SupplierProject::create(['platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79991110000', 'supplier_external_id' => '555', 'current_limit' => 1]);
|
||||
|
||||
$mock = Mockery::mock(SupplierPortalClient::class);
|
||||
$mock->shouldReceive('deleteProject')->once()->with(555);
|
||||
app()->instance(SupplierPortalClient::class, $mock);
|
||||
|
||||
(new DeleteSupplierProjectJob([$sp->id]))->handle(app(SupplierPortalClient::class));
|
||||
|
||||
expect(SupplierProject::find($sp->id))->toBeNull();
|
||||
});
|
||||
|
||||
it('does NOT delete donor at supplier when other consumers remain; re-syncs', function () {
|
||||
Bus::fake([SyncSupplierProjectsJob::class]);
|
||||
$tenant = Tenant::factory()->create(['balance_leads' => 100]);
|
||||
$sp = SupplierProject::create(['platform' => 'B1', 'signal_type' => 'call', 'unique_key' => '79991110000', 'supplier_external_id' => '555', 'current_limit' => 1]);
|
||||
$other = Project::factory()->create(['tenant_id' => $tenant->id]);
|
||||
DB::table('project_supplier_links')->insert(['project_id' => $other->id, 'supplier_project_id' => $sp->id, 'subject_code' => null]);
|
||||
|
||||
$mock = Mockery::mock(SupplierPortalClient::class);
|
||||
$mock->shouldNotReceive('deleteProject');
|
||||
app()->instance(SupplierPortalClient::class, $mock);
|
||||
|
||||
(new DeleteSupplierProjectJob([$sp->id]))->handle(app(SupplierPortalClient::class));
|
||||
|
||||
expect(SupplierProject::find($sp->id))->not->toBeNull();
|
||||
Bus::assertDispatched(SyncSupplierProjectsJob::class);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — FAIL**
|
||||
|
||||
Run: `C:/tools/php83/php.exe artisan test --filter=DeleteSupplierProjectJobTest`
|
||||
Expected: FAIL (класса нет).
|
||||
|
||||
- [ ] **Step 3: Реализация джоба**
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Supplier;
|
||||
|
||||
use App\Models\SupplierProject;
|
||||
use App\Services\Supplier\SupplierPortalClient;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Удаление/пере-синк доноров у поставщика после удаления Лидерра-проекта.
|
||||
*
|
||||
* Для каждого supplier_project S (донора), к которому был привязан удалённый проект:
|
||||
* - остались другие потребители (project_supplier_links) → донор нужен другим клиентам:
|
||||
* НЕ удаляем у поставщика, пере-синкаем агрегат (SyncSupplierProjectsJob).
|
||||
* - потребителей не осталось → удаляем у поставщика (deleteProject) + локальную запись S.
|
||||
*
|
||||
* Spec: docs/superpowers/specs/2026-05-21-project-delete-dedup-errors-design.md §Решение 2.
|
||||
*/
|
||||
class DeleteSupplierProjectJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $backoff = 60;
|
||||
|
||||
public const DB_CONNECTION = 'pgsql_supplier';
|
||||
|
||||
/** @param array<int,int> $supplierProjectIds */
|
||||
public function __construct(public array $supplierProjectIds) {}
|
||||
|
||||
public function handle(SupplierPortalClient $client): void
|
||||
{
|
||||
$needsResync = false;
|
||||
|
||||
foreach ($this->supplierProjectIds as $id) {
|
||||
$sp = SupplierProject::on(self::DB_CONNECTION)->find($id);
|
||||
if ($sp === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$remaining = DB::connection(self::DB_CONNECTION)
|
||||
->table('project_supplier_links')
|
||||
->where('supplier_project_id', $id)
|
||||
->count();
|
||||
|
||||
if ($remaining > 0) {
|
||||
$needsResync = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($sp->supplier_external_id !== null && $sp->supplier_external_id !== '') {
|
||||
try {
|
||||
$client->deleteProject((int) $sp->supplier_external_id);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('supplier.delete_donor_failed', ['supplier_project_id' => $id, 'error' => $e->getMessage()]);
|
||||
throw $e; // ретрай джоба
|
||||
}
|
||||
}
|
||||
$sp->delete();
|
||||
}
|
||||
|
||||
if ($needsResync) {
|
||||
SyncSupplierProjectsJob::dispatch();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Запустить — PASS**
|
||||
|
||||
Run: `C:/tools/php83/php.exe artisan test --filter=DeleteSupplierProjectJobTest`
|
||||
Expected: PASS (2 теста).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Jobs/Supplier/DeleteSupplierProjectJob.php app/tests/Feature/Supplier/DeleteSupplierProjectJobTest.php
|
||||
git commit -m "feat(supplier): delete/re-sync donor on project delete respecting sharing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Снос archived_at — модель/ресурс/синк/дашборд/контроллер + миграция
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/app/Models/Project.php` (−scopeArchived, scopeActive, fillable+cast `archived_at`)
|
||||
- Modify: `app/app/Http/Resources/ProjectResource.php` (−`archived_at`)
|
||||
- Modify: `app/app/Http/Controllers/Api/ProjectController.php` (index `status=archived`/`active()`)
|
||||
- Modify: `app/app/Http/Controllers/Api/DashboardController.php` (−`whereNull('archived_at')`)
|
||||
- Modify: `app/app/Jobs/Supplier/SyncSupplierProjectsJob.php` (−`whereNull('archived_at')`)
|
||||
- Create: `app/database/migrations/2026_05_21_000000_drop_projects_archived_at.php`
|
||||
- Modify: `db/schema.sql` (убрать строку `archived_at` из projects + header v8.27) + `db/CHANGELOG_schema.md`
|
||||
- Test: запуск всей backend-регрессии
|
||||
|
||||
- [ ] **Step 1: Миграция**
|
||||
|
||||
```php
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE projects DROP COLUMN IF EXISTS archived_at');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
DB::statement('ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Чистка кода**
|
||||
|
||||
- `Project.php`: удалить `'archived_at'` из `$fillable` и из casts; удалить методы `scopeArchived` и `scopeActive` (scope `scopeActiveOnDay` — НЕ трогать, это про день недели). После удаления заменить всех вызывающих `->active()` на чистый query:
|
||||
- `ProjectService::create()` лимит-проверка: `Project::where('tenant_id', $tenant->id)->active()->count()` → `Project::where('tenant_id', $tenant->id)->count()` (после сноса архива «активные» = все проекты тенанта).
|
||||
- `ProjectController::index()` — см. ниже.
|
||||
Проверить `grep -rn "->active(" app/app` после правок (должны остаться только `scopeActiveOnDay`/`PricingTier`/`SupplierProject`).
|
||||
- `ProjectResource.php`: удалить строку `'archived_at' => ...`.
|
||||
- `ProjectController::index()`: удалить ветку `if ($status === 'archived')`; для `active`/`paused`/default убрать вызовы `->active()`/`->archived()` (фильтрация только по `is_active`).
|
||||
- `DashboardController.php:77`: убрать `->whereNull('archived_at')`.
|
||||
- `SyncSupplierProjectsJob.php:89`: убрать `->whereNull('archived_at')`.
|
||||
|
||||
- [ ] **Step 3: schema.sql + CHANGELOG**
|
||||
|
||||
В `db/schema.sql` удалить строку `archived_at TIMESTAMPTZ NULL,` из `CREATE TABLE projects`; обновить header-комментарий → v8.27 (drop projects.archived_at). В `db/CHANGELOG_schema.md` добавить запись v8.27.
|
||||
|
||||
- [ ] **Step 4: Прогнать миграцию на dev + регрессия**
|
||||
|
||||
Run:
|
||||
|
||||
```
|
||||
C:/tools/php83/php.exe artisan migrate
|
||||
C:/tools/php83/php.exe artisan test --filter=Project
|
||||
composer stan
|
||||
```
|
||||
|
||||
Expected: миграция OK; тесты зелёные; Larastan 0 (или обновить baseline, если всплыло legacy).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -- app/app/Models/Project.php app/app/Http/Resources/ProjectResource.php app/app/Http/Controllers/Api/ProjectController.php app/app/Http/Controllers/Api/DashboardController.php app/app/Jobs/Supplier/SyncSupplierProjectsJob.php app/database/migrations/2026_05_21_000000_drop_projects_archived_at.php db/schema.sql db/CHANGELOG_schema.md
|
||||
git commit -m "refactor(projects): remove archive feature, drop archived_at column (schema v8.27)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Фронтенд — «Архивировать» → «Удалить»
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `app/resources/js/stores/projectsStore.ts` (`archive`→`del`, bulk type, `archived_at` из интерфейса)
|
||||
- Modify: `app/resources/js/components/projects/BulkActionsBar.vue`
|
||||
- Modify: `app/resources/js/components/projects/ProjectCard.vue`
|
||||
- Modify: `app/resources/js/components/projects/ProjectDetailsDrawer.vue`
|
||||
- Modify: `app/resources/js/views/ProjectsView.vue` (фильтр «Архивные», `@archive`→`@delete`)
|
||||
- Test: `app/resources/js/stores/projectsStore.spec.ts` (или существующий) + затронутые spec'и
|
||||
|
||||
- [ ] **Step 1: Падающий тест стора**
|
||||
|
||||
В spec проверить, что метод удаления дёргает `DELETE /api/projects/{id}`:
|
||||
|
||||
```ts
|
||||
it('delete() calls DELETE /api/projects/{id}', async () => {
|
||||
const store = useProjectsStore();
|
||||
vi.spyOn(axios, 'delete').mockResolvedValue({ data: null });
|
||||
vi.spyOn(axios, 'get').mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
|
||||
await store.del(7);
|
||||
expect(axios.delete).toHaveBeenCalledWith('/api/projects/7');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить — FAIL**
|
||||
|
||||
Run: `npm --prefix app run test:vue -- projectsStore`
|
||||
Expected: FAIL (`del` не существует).
|
||||
|
||||
- [ ] **Step 3: Реализация фронта**
|
||||
|
||||
- `projectsStore.ts`: переименовать `archive`→`del` (метод + return); в `bulkAction`/`BulkPayload` тип `'archive'`→`'delete'`; убрать `archived_at` из `interface Project`.
|
||||
- `BulkActionsBar.vue`: кнопка «Архивировать»→«Удалить» (`data-testid="bulk-delete"`, иконка `mdi-delete`→Lucide `Trash2` через IconSet), confirm-текст про удаление; `confirmAndRun('delete')`; тип union `'pause'|'resume'|'delete'`.
|
||||
- `ProjectCard.vue`: пункт меню «Архивировать»→«Удалить» (иконка `mdi-delete`), `$emit('delete', project)`; emit-тип `delete: [project: Project]`.
|
||||
- `ProjectDetailsDrawer.vue`: «Архивировать проект?»→«Удалить проект? Действие необратимо.»; `store.del(props.project.id)`.
|
||||
- `ProjectsView.vue`: `@archive`→`@delete="(p) => store.del(p.id)"`; убрать `{ title: 'Архивные', value: 'archived' }` из фильтра статусов.
|
||||
|
||||
NB: иконки — через существующий IconSet mapping в `plugins/vuetify.ts` (`mdi-delete`→`Trash2`); если маппинга нет — добавить.
|
||||
|
||||
- [ ] **Step 4: Запустить — PASS + тип-чек + сборка**
|
||||
|
||||
Run:
|
||||
|
||||
```
|
||||
npm --prefix app run test:vue
|
||||
npm --prefix app run type-check
|
||||
npm --prefix app run build
|
||||
```
|
||||
|
||||
Expected: тесты зелёные (обновить spec'и, где был `archive`/`archived`); type-check 0; build OK.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add -- app/resources/js/stores/projectsStore.ts app/resources/js/components/projects/BulkActionsBar.vue app/resources/js/components/projects/ProjectCard.vue app/resources/js/components/projects/ProjectDetailsDrawer.vue app/resources/js/views/ProjectsView.vue
|
||||
git commit -m "feat(projects-ui): replace archive with delete, drop archived filter"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Живая проверка всех 4 задач + чистка тестовых данных
|
||||
|
||||
Среда: portal `serve` :8000 + `queue:work` (запустить, если не идёт). Демо tenant 1 (`admin@demo.local`/`password`).
|
||||
|
||||
- [ ] **Шаг 1 (задача 4 — человеческие ошибки):** создать проект с именем существующего → ожидать понятное 422-сообщение (не SQL). Создать проект с именем-дублем через UI/`tinker` POST → проверить тело ответа.
|
||||
- [ ] **Шаг 2 (задача 3 — дедуп источника):** в рамках tenant 1 создать 2-й проект на тот же `signal_identifier` → ожидать «У вас уже есть проект с этим источником…».
|
||||
- [ ] **Шаг 3 (задача 2 — шеринг):** под другим тенантом создать проект с тем же источником → проходит (демонстрация «ок»).
|
||||
- [ ] **Шаг 4 (задача 1 — удаление):** удалить пустой проект → исчез; удалить проект со сделкой → блок с сообщением, проект жив. Если есть привязка к донору и других потребителей нет → проверить, что `DeleteSupplierProjectJob` удалил донора у поставщика (или пере-синкнул при наличии других).
|
||||
- [ ] **Шаг 5 (чистка):** удалить все тестовые проекты/сделки/доноров, созданные в шагах 1–4; вернуть БД к чистому демо; зафиксировать в отчёте, что изменилось.
|
||||
|
||||
(Verify-skill: каждый шаг — реальный рантайм, capture результата; затем cleanup.)
|
||||
|
||||
---
|
||||
|
||||
## Финальная регрессия (после всех задач)
|
||||
|
||||
Run (из корня/`app/`):
|
||||
|
||||
```
|
||||
C:/tools/php83/php.exe artisan test
|
||||
npm --prefix app run test:vue
|
||||
npm --prefix app run type-check
|
||||
npm --prefix app run build
|
||||
composer pint && composer stan
|
||||
```
|
||||
|
||||
Все зелёные → готово к push (`git push origin <ветка>:main`) по решению заказчика.
|
||||
@@ -1,214 +0,0 @@
|
||||
# Чек-лист: замечания по проектам (боевой liderra.ru) — 22.05.2026
|
||||
|
||||
> **Процесс заказчика для КАЖДОГО пункта (обязателен):**
|
||||
>
|
||||
> 1. **Тест на практике** — воспроизвести на боевом сайте + у поставщика.
|
||||
> 2. **Чистка №1** — удалить тестовые данные в Лидерре И у поставщика.
|
||||
> 3. **Фикс** — исправить код (TDD: сначала падающий Pest/Vitest-тест, потом код).
|
||||
> 4. **Ре-тест** — снова проверить на практике, что починилось.
|
||||
> 5. **Чистка №2** — снова удалить тестовые данные в Лидерре И у поставщика.
|
||||
> 6. **Закрыто** — только когда всё выше сделано и проверено.
|
||||
|
||||
**Среда:** боевой `liderra.ru` (SSH `ubuntu@111.88.246.137`), боевой поставщик `crm.bp-gr.ru`.
|
||||
**Важно:** код на боевом ВПЕРЕДИ origin/main (ветки `feat/test-deploy`, `feat/root-domain-auto-link`). Каждый баг проверять на РЕАЛЬНОМ боевом, не только в репо.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Соглашения о безопасности тестирования на боевом
|
||||
|
||||
Поставщик — общий и реальный. Тест создаёт реальные проекты у поставщика → риск реальных заказов лидов и списаний. Поэтому:
|
||||
|
||||
- **Фиктивные источники только.** Несуществующие, но валидные по формату: домены `qa-liderra-test-NN.ru`, телефоны `7999000NNNN`, SMS-отправитель `QATESTNN`. Никогда не использовать источник реального клиента.
|
||||
- **Минимальный лимит** (1 лид/день) — даже если заказ уйдёт, объём копеечный.
|
||||
- **Создал → проверил → сразу почистил.** Не оставлять тестовые проекты «на ночь» (чтобы ночной батч не заказал).
|
||||
- **Снимок «до».** Перед каждым тестом снять список проектов у поставщика (`listProjects()`), чтобы точно знать, что удалять после.
|
||||
- **Тестовые клиенты** изолированы (отдельные tenant'ы `qa-test-1..5`), в конце удаляются полностью.
|
||||
- **Деньги.** Тестовым клиентам не пополнять баланс реально; если списание произойдёт — зафиксировать и откатить.
|
||||
|
||||
**Инструменты на боевом (по SSH):**
|
||||
|
||||
- Наблюдать поставщика: `php artisan tinker` → `app(\App\Services\Supplier\SupplierPortalClient::class)->listProjects()`.
|
||||
- Чистить у поставщика: `\App\Jobs\Supplier\DeleteSupplierProjectJob::dispatch([<supplier_project_id>...])` (sync-выполнить через `queue:work --once` или `Bus::dispatchSync`).
|
||||
- Чистить в Лидерре: удалить `projects`/`supplier_projects`/`project_supplier_links` тестовых клиентов; удалить тестовых tenant+users.
|
||||
- БД боевого: PostgreSQL `liderra`, supplier-операции под ролью `crm_supplier_worker` (BYPASSRLS), connection `pgsql_supplier`.
|
||||
|
||||
---
|
||||
|
||||
## ФАЗА 0 — Подготовка (один раз, до пунктов)
|
||||
|
||||
- [ ] **0.1 Снять реальное состояние боевого кода.** По SSH сверить `git hash-object` боевых файлов с origin/main и ветками: `SyncSupplierProjectJob.php`, `SyncSupplierProjectsJob.php`, `SupplierQuotaAllocator.php`, `ProjectController.php`, `ProjectService.php`, `ProjectsView.vue`, `ProjectDetailsDrawer.vue`. Зафиксировать в файле «боевая база» (что реально развёрнуто). Узнать `SupplierExportMode` боевого (online/batch).
|
||||
- [ ] **0.2 Снять снимок поставщика «до всего».** `listProjects()` → сохранить весь список (id, name, limit, status) в `docs/audit/supplier-snapshot-2026-05-22-before.json`. Это эталон чистоты.
|
||||
- [ ] **0.3 Завести 5 тестовых клиентов** на боевом (`qa-test-1..5`): tenant + 1 user каждому, лимит проектов ≥ 5. Зафиксировать их tenant_id.
|
||||
- [ ] **0.4 Проверить процедуру чистки на «пустышке».** Создать 1 тестовый проект с фиктивным источником → убедиться, что он появился у поставщика → удалить его и в Лидерре, и у поставщика → `listProjects()` совпал со снимком 0.2. Только после этого процедура чистки считается рабочей.
|
||||
|
||||
---
|
||||
|
||||
## ГРУППА 1 — Баги синхронизации с поставщиком (один клиент)
|
||||
|
||||
### П1 — Приостановка/возобновление не доходит до поставщика (замечание #10)
|
||||
|
||||
**Подозрение (по коду):** `toggleActive` ([ProjectController.php:129](app/app/Http/Controllers/Api/ProjectController.php#L129)) и bulk pause/resume ([ProjectService.php:130-131](app/app/Services/Project/ProjectService.php#L130-L131)) не диспатчат sync; `needsResync` ([ProjectService.php:38-43](app/app/Services/Project/ProjectService.php#L38-L43)) не включает `is_active`; DTO всегда `status: 'active'`; ночной джоб берёт только `is_active=true`.
|
||||
|
||||
- [ ] **Тест:** создать тест-проект, дождаться синка, увидеть его активным у поставщика → нажать «Приостановить» → проверить `listProjects()`: остался ли «зелёным/активным».
|
||||
- [ ] **Чистка №1.**
|
||||
- [ ] **Фикс:** пауза должна сообщать поставщику (status/обнуление лимита); `is_active` → в needsResync + toggleActive/bulk диспатчат sync; для общего источника — пересчёт без paused-доли (см. П7).
|
||||
- [ ] **Ре-тест:** пауза → у поставщика остановлен/обнулён; возобновление → снова активен.
|
||||
- [ ] **Чистка №2.**
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П2 — Смена источника создаёт новый проект вместо изменения + дубли (замечания #9, #8)
|
||||
|
||||
**Подозрение (по коду):** `unique_key` строится из источника ([SupplierProjectGrouping.php:30-45](app/app/Services/Supplier/SupplierProjectGrouping.php#L30-L45)); смена источника → новый ключ → `existingSps` пусто ([SyncSupplierProjectJob.php:126-132](app/app/Jobs/SyncSupplierProjectJob.php#L126-L132)) → create нового; старый supplier-проект и pivot не чистятся.
|
||||
|
||||
- [ ] **Тест A (изменение):** создать проект, источник `qa-liderra-test-01.ru`, синк → запомнить supplier-id. Сменить источник на `qa-liderra-test-02.ru` → `listProjects()`: сколько проектов (ожидаем баг = 2).
|
||||
- [ ] **Тест B (история #8):** по SSH поднять историю реального инцидента с источником `79135191264` (`activity_logs`/`saas_admin_audit_log`/`supplier_sync_logs`) — как создавался и менялся, откуда 2 проекта.
|
||||
- [ ] **Чистка №1.**
|
||||
- [ ] **Фикс:** при смене источника — найти supplier-проекты по pivot (а не по новому ключу), обновить/переименовать у поставщика ИЛИ удалить старые + создать новые; почистить осиротевшие. Учесть шаринг (старый источник мог быть у другого клиента — тогда только отвязать).
|
||||
- [ ] **Ре-тест:** смена источника → у поставщика РОВНО один проект с новым источником, старый убран/переотвязан.
|
||||
- [ ] **Чистка №2.**
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П3 — Смена дня поставки не доходит до поставщика (замечание #2)
|
||||
|
||||
**Подозрение:** `delivery_days_mask` в needsResync есть; проверить, реально ли `updateProject`/`saveProjectMultiFlag` шлёт workdays и в каком режиме (online/batch); batch только создаёт каркас без update.
|
||||
|
||||
- [ ] **Тест (убрать день):** создать проект со всеми днями, синк → у поставщика дни. Убрать один день → `listProjects()`/деталь проекта у поставщика: изменились ли дни.
|
||||
- [ ] **Тест (добавить день):** добавить ранее убранный день → проверить у поставщика.
|
||||
- [ ] **Чистка №1.**
|
||||
- [ ] **Фикс:** обеспечить передачу workdays поставщику при изменении (в актуальном режиме боевого).
|
||||
- [ ] **Ре-тест:** убрать день → у поставщика убрался; добавить → добавился.
|
||||
- [ ] **Чистка №2.**
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П4 — Деление лимита B1/B2/B3 (замечание #1)
|
||||
|
||||
**Подозрение:** `distributeForPlatform` уже на боевом (`e6beff6`), re-split форсом выполнен, НО «уже-`ok` проекты со старыми ×N батч сам не перечинивает» (ПИЛОТ §2). Возможно остались проекты с дублированным лимитом.
|
||||
|
||||
- [ ] **Тест:** найти на боевом проекты, у которых сумма лимитов B1+B2+B3 ≠ заказу (старый ×N). Создать новый тест-проект с лимитом, напр. 30 → проверить у поставщика: 10/10/10 (правильно) или 30/30/30 (баг).
|
||||
- [ ] **Чистка №1** (для тест-проекта).
|
||||
- [ ] **Фикс:** если новые делятся правильно — добить force-resync «уже-ok» реальных проектов с ×N (без удаления, in-place); если новые делятся неправильно — починить деление.
|
||||
- [ ] **Ре-тест:** новый проект делится корректно; реальные ×N перечинены (Σ = заказу).
|
||||
- [ ] **Чистка №2** (тест-данные; реальные проекты остаются исправленными).
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П5 — Убрать один регион из двух → слетают все (замечание #3)
|
||||
|
||||
**Подозрение:** drawer шлёт `regions` целиком ([ProjectDetailsDrawer.vue:78-83](app/resources/js/components/projects/ProjectDetailsDrawer.vue#L78-L83)); если пусто → трактуется как «вся РФ». Проверить, что реально уходит при удалении одного из двух регионов и как поставщик это видит.
|
||||
|
||||
- [ ] **Тест:** проект с 2 регионами (напр. Москва+Питер), синк → у поставщика 2 региона/tag. Убрать Питер → проверить: остался ли только Питер убран (Москва есть) или слетели оба → «вся РФ».
|
||||
- [ ] **Чистка №1.**
|
||||
- [ ] **Фикс:** убирание одного региона оставляет остальные и у нас, и у поставщика.
|
||||
- [ ] **Ре-тест:** убрать один из двух → второй остаётся.
|
||||
- [ ] **Чистка №2.**
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
---
|
||||
|
||||
## ГРУППА 2 — Сценарии 5+ клиентов одновременно (общий источник у поставщика)
|
||||
|
||||
> Базовый сетап для всех: ≥3 тест-клиента с ОДНИМ источником `qa-liderra-test-shared.ru`, разные регионы/лимиты/дни. Синхронизировать → у поставщика ОДИН проект (заказ = сумма по формуле). Снять снимок-эталон. После каждого пункта — чистка обоих сторон.
|
||||
|
||||
### П6 — Один из группы меняет регион/лимит/дни днём → затирает остальных (сценарии C2, C3)
|
||||
|
||||
**Подозрение:** online-синк одного проекта пишет регионы/лимит/дни ТОЛЬКО этого проекта ([SyncSupplierProjectJob.php:112,118,123](app/app/Jobs/SyncSupplierProjectJob.php#L112)), а ночной — union группы. Дневная правка одного клиента стирает данные остальных.
|
||||
|
||||
- [ ] **Тест:** 5 клиентов на общем источнике (регионы A,B,C,D,E; лимиты разные). Клиент 1 меняет свой регион среди дня → проверить у поставщика: остался union(A..E) или стал только регион клиента 1.
|
||||
- [ ] **Чистка №1.**
|
||||
- [ ] **Фикс:** online-синк одного проекта должен пересчитывать union/заказ по ВСЕЙ группе (как ночной), а не по одному проекту.
|
||||
- [ ] **Ре-тест:** правка одного клиента → у поставщика union всех сохранён, заказ = сумме.
|
||||
- [ ] **Чистка №2.**
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П7 — Один из группы ставит паузу → заказ не уменьшается на его долю (сценарий C4)
|
||||
|
||||
- [ ] **Тест:** 5 клиентов на общем источнике, заказ = X. Клиент 3 ставит паузу → проверить у поставщика: заказ уменьшился на долю клиента 3 или остался X.
|
||||
- [ ] **Чистка №1.**
|
||||
- [ ] **Фикс:** пауза участника группы → пересчёт заказа без него (зависит от П1).
|
||||
- [ ] **Ре-тест:** пауза → заказ пересчитан; возобновление → вернулся.
|
||||
- [ ] **Чистка №2.**
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П8 — Один из группы удаляет проект → не трогать общий supplier-проект (сценарий C5)
|
||||
|
||||
- [ ] **Тест:** 5 клиентов на общем источнике. Клиент 2 удаляет свой проект → проверить: общий supplier-проект жив (4 ещё работают), заказ пересчитан без клиента 2, лиды/списания остальных целы.
|
||||
- [ ] **Чистка №1.**
|
||||
- [ ] **Фикс (если нужно):** удаление участника группы не удаляет общий supplier-проект, только отвязывает + пересчитывает заказ.
|
||||
- [ ] **Ре-тест.**
|
||||
- [ ] **Чистка №2.**
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П9 — Двое одновременно сохраняют общий источник → гонка (сценарий C6)
|
||||
|
||||
- [ ] **Тест:** клиент 1 и клиент 2 (общий источник) почти одновременно жмут «Сохранить» с разными изменениями → проверить: оба изменения учтены или последний затёр первого / дубль у поставщика.
|
||||
- [ ] **Чистка №1.**
|
||||
- [ ] **Фикс (если нужно):** блокировка/идемпотентность на уровне группы (advisory-lock по identifier).
|
||||
- [ ] **Ре-тест.**
|
||||
- [ ] **Чистка №2.**
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П10 — Двое одновременно создают один новый источник → два проекта вместо одного (сценарий C7, родственно #8)
|
||||
|
||||
- [ ] **Тест:** клиент 4 и клиент 5 одновременно создают проект с одним новым источником `qa-liderra-test-race.ru` → проверить `listProjects()`: один supplier-проект или два.
|
||||
- [ ] **Чистка №1.**
|
||||
- [ ] **Фикс (если нужно):** идемпотентное создание (advisory-lock/уникальный ключ на identifier).
|
||||
- [ ] **Ре-тест.**
|
||||
- [ ] **Чистка №2.**
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П11 — Смена источника на занятый другим клиентом → слияние групп (сценарий C8)
|
||||
|
||||
- [ ] **Тест:** клиент 1 (источник X) меняет источник на Y, который уже у клиента 2 → проверить: группы слились в одну у поставщика (заказ = сумма) или создался дубль.
|
||||
- [ ] **Чистка №1.**
|
||||
- [ ] **Фикс (если нужно):** смена источника на существующий → присоединение к группе (зависит от П2).
|
||||
- [ ] **Ре-тест.**
|
||||
- [ ] **Чистка №2.**
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
---
|
||||
|
||||
## ГРУППА 3 — Внешний вид и удобство страницы «Проекты»
|
||||
|
||||
> UI чистить не нужно (нет данных у поставщика). Цикл: проверил визуально на боевом → фикс → перепроверил.
|
||||
|
||||
### П12 — После Сохранить/Приостановить панель и галочка не исчезают (замечание #4)
|
||||
|
||||
**Подозрение:** `onDrawerSaved` делает только fetch без снятия выбора ([ProjectsView.vue:147-149](app/resources/js/views/ProjectsView.vue#L147-L149)); `onPause` в drawer не закрывает панель ([ProjectDetailsDrawer.vue:58-61](app/resources/js/components/projects/ProjectDetailsDrawer.vue#L58-L61)).
|
||||
|
||||
- [ ] **Тест:** выбрать проект (галочка) → открылась правая панель. Нажать «Сохранить» → панель и галочка остаются? То же для «Приостановить», «Удалить», «Отмена».
|
||||
- [ ] **Фикс:** после любого из 4 действий — закрыть панель + снять галочку (clearSelection).
|
||||
- [ ] **Ре-тест:** все 4 кнопки закрывают панель и снимают галочку.
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П13 — Отступ от тёмных границ как в канбане (замечание #5)
|
||||
|
||||
- [ ] **Тест:** сравнить отступ страницы «Проекты» с канбаном на боевом.
|
||||
- [ ] **Фикс:** выровнять отступы (CSS `.projects-view`).
|
||||
- [ ] **Ре-тест:** визуально совпадает.
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П14 — Селектор «показывать по 20/50/100/200» (замечание #6)
|
||||
|
||||
**Текущее:** бэкенд режет `per_page` до max 100 ([ProjectController.php:70](app/app/Http/Controllers/Api/ProjectController.php#L70)); фронт без селектора (есть только пагинация на боевом).
|
||||
|
||||
- [ ] **Тест:** проверить, есть ли выбор количества на боевом (нет).
|
||||
- [ ] **Фикс:** селектор 20/50/100/200 (как на «Сделках»); поднять серверный лимит до 200.
|
||||
- [ ] **Ре-тест:** выбор работает, список перезагружается.
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
### П15 — Сортировка по лидам + фильтры регион/дни + дефолт-сортировка (замечание #7)
|
||||
|
||||
**Текущее:** бэкенд сортирует жёстко `created_at desc` ([ProjectController.php:71](app/app/Http/Controllers/Api/ProjectController.php#L71)); фильтров регион/дни нет.
|
||||
|
||||
- [ ] **Тест:** убедиться, что сортировки/фильтров нет.
|
||||
- [ ] **Фикс:** сортировка по кол-ву лидов; фильтр по региону; фильтр по дням; дефолт-сортировка по доставленным лидам за текущий день (`delivered_today`).
|
||||
- [ ] **Ре-тест:** при заходе сразу сортировка по лидам за сегодня; фильтры/сортировки работают.
|
||||
- [ ] **Закрыто.**
|
||||
|
||||
---
|
||||
|
||||
## ФАЗА ЗАВЕРШЕНИЯ
|
||||
|
||||
- [ ] **Финальная чистка:** удалить всех 5 тест-клиентов и все их проекты (Лидерра + поставщик). `listProjects()` == снимку 0.2.
|
||||
- [ ] **Снимок поставщика «после»** — сверить с «до»: ни одного лишнего проекта.
|
||||
- [ ] **Деплой фиксов** на боевой (копированием, по процедуре ПИЛОТ §2: фронт — локальный build, бэк — config:cache + restart queue + reload fpm).
|
||||
- [ ] **Обновить ПИЛОТ.md** («обнови пилот» от заказчика).
|
||||
@@ -1,320 +0,0 @@
|
||||
# Spec: Roadmap до production — Лидерра
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 10.05.2026
|
||||
**Автор:** Claude Code (skill: superpowers:brainstorming)
|
||||
**Заказчик:** Дмитрий
|
||||
**Статус:** черновик, ждёт review заказчика
|
||||
**Базовый HEAD:** `6c2f0ce` (Sprint 3 Phase C closed)
|
||||
**Источники:** [docs/audit_2026-05-09.md](../../audit_2026-05-09.md), [docs/Открытые_вопросы_v8_3.md](../../Открытые_вопросы_v8_3.md) v1.76, memory `project_state.md` (вне репо, Claude auto-memory)
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Довести Лидерру от текущего состояния (Post-MVP, 13/13 экранов handoff покрыты, 416 Pest / 416 Vitest зелёные, MVP-фаза признана закрытой 09.05.2026) **до production-запуска в Yandex Cloud** под доменом `liderra.ru`.
|
||||
|
||||
**Pre-condition:** `git push` 18 застрявших коммитов разблокирован (см. §3.0). Без этого CI на GitHub отстаёт от факта на полтора месяца разработки и любая работа в roadmap'е накапливается локально.
|
||||
|
||||
**Definition of done «production launch»:**
|
||||
|
||||
- Приложение развёрнуто в Yandex Cloud (compute + Managed PG 16 + Managed Redis + Object Storage + Container Registry)
|
||||
- Домен `liderra.ru` + SSL активны
|
||||
- SSO `Yandex 360` для saas-admin работает
|
||||
- Все backend-routes защищены auth/tenant middleware (P1-10/P1-11 закрыты)
|
||||
- 0 known P0/P1, audit-хвост O-* закрыт или явно отложен с условием снятия
|
||||
- Phase 3 tooling (#25–29) активен (Semgrep + Trivy + Dependabot + pg_audit + pg_anonymizer)
|
||||
- Pa11y на live URL = 0 violations
|
||||
- Sentry self-hosted получает события из prod
|
||||
- Backup протестирован (restore из snapshot работает)
|
||||
- Runbook и incident-response готовы
|
||||
- Smoke-тесты на staging-домене зелёные
|
||||
- Регрессия зелёная (Pest / Vitest / Larastan / vue-tsc / ESLint / squawk / SAST / Trivy)
|
||||
|
||||
## 2. Scope и non-goals
|
||||
|
||||
### В scope
|
||||
|
||||
- **Категория A:** хвосты audit O-* (~10 пунктов, не вошли в Sprint 1–3)
|
||||
- **Категория B:** Phase 3 tooling (#25 Semgrep, #26 Trivy, #27 Dependabot, #28 pg_audit, #29 pg_anonymizer)
|
||||
- **Категория C:** Post-MVP backend backlog (Reports доделки, AdminTenantDetail доделки, Notification integrations)
|
||||
- **Категория D:** Б-1-зависимые фичи (SSO #6, P1-11 admin auth, лендинг, Диз-3/4, DO-2/4)
|
||||
- **Категория E:** Production deployment в Yandex Cloud
|
||||
- **Категория G:** push 18 застрявших коммитов (precondition)
|
||||
|
||||
### Не в scope
|
||||
|
||||
- **Категория F:** юридические тексты (Прил. Ж оферта + Политика + Прил. З уведомление РКН) — внешний исполнитель (юрист), Claude может только импортировать готовый текст
|
||||
- 5 🟦 структурных вопросов реестра (Биз-/Ю-/Диз-) — ждут заказчика
|
||||
- Post-launch фичи (новые модули CRM, аналитика второго порядка, мобильное приложение)
|
||||
- «Refactor ради рефактора» вне списка O-*
|
||||
|
||||
### Допущения
|
||||
|
||||
- **Б-1 ETA:** «недели/месяц» (по словам заказчика). План построен **двухдорожечно**, чтобы не зависеть от точной даты — Track A работает параллельно с ожиданием Б-1, Track B стартует по триггеру «реквизиты получены».
|
||||
- **Native Windows-стенд** остаётся dev-средой до prod-миграции. На нём недоступны Docker, WSL2, Hyper-V, pg_partman, pg_audit, pg_anonymizer, Pest browser-driver (ext-sockets). Все инструменты, требующие Linux-стенда, переносятся на Track B (после получения staging YC).
|
||||
- **Push разблокирован** (категория G) — без этого Sprint 4 не стартует.
|
||||
|
||||
## 3. Архитектура roadmap'а — три дорожки
|
||||
|
||||
```
|
||||
[Sprint 0 / Pre-condition]
|
||||
G. push 18 commits → unblock CI
|
||||
↓
|
||||
├─────────── Track A (параллельно ожиданию Б-1) ───────────────┐
|
||||
│ │
|
||||
│ Sprint 4 «Audit tail» │
|
||||
│ A. O-perf-04, O-perf-07, O-refactor-04(хвост), -05, │
|
||||
│ -06, -07, O-stack-02, O-stack-03 │
|
||||
│ │
|
||||
│ Sprint 5 «Pre-prod tooling — что возможно без YC» │
|
||||
│ B. #25 Semgrep + Semgrep MCP │
|
||||
│ #26 Trivy (готовим, активируем после YC) │
|
||||
│ #27 Dependabot (.github/dependabot.yml) │
|
||||
│ (#28/#29 ждут Managed PG) │
|
||||
│ │
|
||||
│ Sprint 6 «Post-MVP backend» │
|
||||
│ C. Reports backend доделки (3 providers, │
|
||||
│ file download, S3 abstraction) │
|
||||
│ AdminTenantDetail доделки (inn/phone/address, │
|
||||
│ user roles) │
|
||||
│ Notification integrations (new_device_login, │
|
||||
│ deep-link bell, ЮKassa webhook scaffold) │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼ (когда Б-1 закрыт)
|
||||
┌───── Track B (после Б-1) ────────────────────────────────────┐
|
||||
│ │
|
||||
│ Sprint 7 «YC infrastructure setup» │
|
||||
│ E. Compute (VM или Managed K8s) │
|
||||
│ Managed PG 16 (snapshots, replicas, pg_partman) │
|
||||
│ Managed Redis │
|
||||
│ Object Storage (S3-compatible) для report files │
|
||||
│ Container Registry (YCR) │
|
||||
│ Sentry self-hosted (отдельная VM) │
|
||||
│ Domain liderra.ru + SSL (Let's Encrypt) │
|
||||
│ │
|
||||
│ Sprint 8 «CI/CD + Б-1-features» │
|
||||
│ E. CI/CD pipeline (GitHub Actions → docker → YCR → deploy)│
|
||||
│ Secrets (Yandex Lockbox) │
|
||||
│ Unisender Go SMTP-relay (реальные креды) │
|
||||
│ D. SSO Yandex 360 (#6) │
|
||||
│ P1-11: auth-middleware на /api/admin/* │
|
||||
│ Реальный лендинг (DO-2/4 + Диз-3/4) │
|
||||
│ JivoSite виджет │
|
||||
│ │
|
||||
│ Sprint 9 «Hardening + soft-launch» │
|
||||
│ B. #28 pg_audit + #29 pg_anonymizer (на Managed PG) │
|
||||
│ #26 Trivy полный pipeline активирован │
|
||||
│ E. Backup strategy (snapshots + S3 versioning) │
|
||||
│ Monitoring + alerting (YC Monitoring + Sentry) │
|
||||
│ Runbook (incidents + restore + migrations) │
|
||||
│ Pre-launch security review (Semgrep + Trivy + manual) │
|
||||
│ Pa11y на live URL │
|
||||
│ Smoke-тесты на staging-домене │
|
||||
│ Soft-launch (закрытая бета на доверенных tenant'ах) │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
|
||||
Track C (external — вне Claude):
|
||||
F. Юр. тексты (Прил. Ж/З) — нужны для Sprint 8 (лендинг footer)
|
||||
Б-1: регистрация ООО — триггер Track B
|
||||
5 🟦 структурных вопросов реестра — ждут заказчика
|
||||
```
|
||||
|
||||
### 3.0 Sprint 0 / Pre-condition (категория G)
|
||||
|
||||
**Действие заказчика (≈5 минут):**
|
||||
|
||||
1. GitHub → Settings → Developer settings → Personal access tokens (fine-grained)
|
||||
2. Найти токен с доступом к `CoralMinister/lidpotok`
|
||||
3. Edit → Repository permissions → **Workflows: Read & Write** (отдельная permission, не Contents)
|
||||
4. Save (новый секрет не выпускается, permission добавляется к существующему)
|
||||
|
||||
**Действие Claude:** `git push origin main` после подтверждения от заказчика. 18 коммитов уезжают, GitHub Actions подхватывают.
|
||||
|
||||
**Acceptance:** `origin/main == 6c2f0ce`, `git status` clean, GitHub Actions «green» на последнем коммите.
|
||||
|
||||
### 3.1 Track A: что не зависит от Б-1 (Sprint 4–6)
|
||||
|
||||
| Sprint | Категория | Длительность (агент-часы) | Wall-clock (calendar) | Регрессия после |
|
||||
|---|---|---|---|---|
|
||||
| **4** «Audit tail» | A | 8–12 | 1 неделя | Pest зелёный, Vitest +N (на split'ах), Larastan + ESLint + vue-tsc 0 |
|
||||
| **5** «Pre-prod tooling local» | B (#25/#26-prep/#27) | 6–8 | 0.5 недели | + SAST CI зелёный + Dependabot активен |
|
||||
| **6** «Post-MVP backend» | C | 12–16 | 1.5 недели | + Pest +30, Vitest +20, новые миграции в schema |
|
||||
|
||||
**Sprint 4 содержание (детально, ревизия после audit фактического состояния):**
|
||||
|
||||
- O-perf-04: keyset pagination в `DealController::index` по `(received_at, id)` — M
|
||||
- O-refactor-04 хвост: 8 Vue-компонентов >300 строк (AppLayout 466, AdminTenantDetailView 436, BillingView 416, AdminTenantsView 377, SecurityTab 354, RemindersView 345, ErrorView 320, DashboardView 302) — split на sub-компоненты <200 строк через выделение sections. Группами: 3 admin views (1 коммит), 3 user views (1 коммит), 2 layout/utility (1 коммит). ImpersonationDialog (291) уже <300 — out of scope
|
||||
- O-refactor-06: `npm run build --analyze` (rollup-plugin-visualizer или vite --analyze) + удаление unused exports в `utils/` и `helpers/` — S+M
|
||||
|
||||
**Уже закрыто ранее (исключено из Sprint 4):**
|
||||
|
||||
- O-perf-07 — Larastan cache закрыт в Sprint 2 Phase A (`tmpDir: .phpstan-cache` в `phpstan.neon`); pre-push move не нужен при работающем cache
|
||||
- O-refactor-05 — ESLint `no-restricted-imports` против `vuetify/components` закрыт в Sprint 1 (`eslint.config.js`)
|
||||
- O-refactor-07 — шапка CLAUDE.md фактически 30 строк до §1 (после `5b13c95` вынос changelog); audit-метрика «244 строки» устарела
|
||||
- O-stack-02 — `infection/infection ^0.32.7` установлен в Sprint 2 Phase A + `composer mutation` script + `infection.json`
|
||||
- O-stack-03 — Laravel 13 string-based lazy-loading закрыт в Sprint 2 Phase A (`routes/web.php` все 16 контроллеров на строки `'FQN@method'`)
|
||||
|
||||
**Sprint 5 содержание (детально):**
|
||||
|
||||
- #25 Semgrep + Semgrep MCP: `.semgrep/` config + `npm run sast` + GitHub Action в `.github/workflows/`
|
||||
- #26 Trivy: подготовка скрипта `bin/trivy-scan.sh` + GitHub Action stub (активируется в Sprint 9 когда есть Docker-образы)
|
||||
- #27 Dependabot: `.github/dependabot.yml` с конфигом для composer + npm + github-actions
|
||||
|
||||
**Sprint 6 содержание (детально):**
|
||||
|
||||
- Reports backend:
|
||||
- 3 Provider-классы: `ManagersSummaryProvider`, `SourcesSummaryProvider`, `BillingSummaryProvider` (по analogii с `DealsExportProvider`)
|
||||
- `GET /api/reports/jobs/{id}/file` — endpoint скачивания (auth + ownership check + StreamedResponse + Content-Disposition)
|
||||
- S3-driver абстракция: `Storage::disk('reports')` с config-driven driver (local на dev, s3 на prod)
|
||||
- AdminTenantDetail доделки:
|
||||
- Schema migration: `tenants` + `inn`, `contact_phone`, `legal_address` или связь на `legal_entities` (брейнсторм отдельно — что лучше)
|
||||
- `user_roles` enum-колонка в `users` (или таблица — решить в реализации) + миграция данных (default 'manager'), убрать UI-хардкод
|
||||
- Notification integrations:
|
||||
- `new_device_login`: расширить `user_sessions` на fingerprint (IP + UA hash), trigger при новой комбинации
|
||||
- Deep-link bell: `notification.context.deal_id` → `<router-link to="/deals/{id}">` (вместо общего push на `/deals`)
|
||||
- ЮKassa webhook scaffold: `POST /webhook/yookassa` контроллер с HMAC + сигнатурой ЮKassa, вызывающий готовый `notifyTopupSuccess`/`notifyInvoicePaid`
|
||||
|
||||
### 3.2 Track B: после Б-1 (Sprint 7–9)
|
||||
|
||||
**Триггер запуска Track B:** заказчик передаёт реквизиты ООО + DO-2 (`liderra.ru` куплен/перенесён) + Прил. Ж/З получены от юриста.
|
||||
|
||||
**Sprint 7 содержание (детально):**
|
||||
|
||||
- Yandex Cloud setup:
|
||||
- Решение «VM vs Managed K8s» — для MVP-стадии достаточно 2 VM (web + db replica), K8s overkill
|
||||
- Managed PostgreSQL 16: 2 ноды (master + sync replica), backup window, pg_partman pre-install, миграция schema.sql v8.11 (через `migrate:fresh` + seed)
|
||||
- Managed Redis: cluster mode off, 1 нода + replica для failover
|
||||
- Object Storage bucket `liderra-reports` (private), bucket `liderra-static` (public) для будущего
|
||||
- Container Registry `cr.yandex/<registry-id>/liderra-app`
|
||||
- Sentry self-hosted: отдельная VM, docker-compose, домен `sentry.liderra.ru`
|
||||
- Domain + SSL:
|
||||
- DNS: A-record `liderra.ru` + `*.liderra.ru` → load balancer
|
||||
- SSL: Let's Encrypt через certbot (автоматическое продление)
|
||||
|
||||
**Sprint 8 содержание (детально):**
|
||||
|
||||
- CI/CD:
|
||||
- `.github/workflows/deploy.yml`: build PHP-FPM image + nginx image + frontend assets → push в YCR → `ssh deploy@vm` + docker-compose up
|
||||
- Secrets: Yandex Lockbox для DB password, ЮKassa keys, Yandex 360 client_secret, Sentry DSN
|
||||
- Email:
|
||||
- Unisender Go: SMTP-relay креды в `.env.production`, тестовая отправка → mail.ru/yandex.ru/gmail.com (3 провайдера)
|
||||
- Б-1-features:
|
||||
- Yandex 360 SSO (#6): OAuth2 client config, `/auth/yandex/callback` route, `saas_admin_users` синхронизация, list разрешённых email из `liderra.ru`-домена
|
||||
- P1-11: `Route::middleware(['auth:saas-admin'])` на all `/api/admin/*` — переоткрытие routes без middleware-bypass
|
||||
- Лендинг: реализация по `лендинг/TZ_landing_v1_0.md` v1.0 (Vue компоненты или статика — решить отдельно), footer-контакты (Диз-3), контент (Диз-4)
|
||||
- JivoSite виджет: snippet в base layout
|
||||
|
||||
**Sprint 9 содержание (детально):**
|
||||
|
||||
- DB hardening:
|
||||
- #28 pg_audit: `CREATE EXTENSION pg_audit;` + конфиг log_catalog/log_level/log_relation
|
||||
- #29 pg_anonymizer: маскирование `users.email`, `users.phone`, `deals.lead_phone` в дампах для разработки
|
||||
- CI/CD finalization:
|
||||
- #26 Trivy полный pipeline: scan → fail на CVE high+ → block merge
|
||||
- Monitoring:
|
||||
- Yandex Monitoring: dashboards (CPU, RAM, disk, PG connections, Redis hit-rate)
|
||||
- Alerting: PagerDuty или Telegram bot на critical
|
||||
- Sentry integration: `sentry/sentry-laravel` + Vue release notify
|
||||
- Backup strategy:
|
||||
- PG snapshots: daily, retention 7 дней
|
||||
- Object Storage versioning: enabled на reports + uploads
|
||||
- Restore drill: воспроизвести prod-БД в staging из snapshot, проверить целостность
|
||||
- Security:
|
||||
- Pre-launch review: Semgrep + Trivy clean, manual check OWASP top-10
|
||||
- Pa11y на live URL (после CDN + font-loading): 0 violations
|
||||
- Launch:
|
||||
- Smoke на staging-домене (`staging.liderra.ru`): 5 ключевых flow (login → создать deal → import webhook → оплатить → отчёт)
|
||||
- Soft-launch: 3–5 доверенных tenant'ов на 2 недели, мониторинг ошибок
|
||||
- Public launch после 0 P0 за 2 недели soft-launch'а
|
||||
|
||||
### 3.3 Track C: external (вне Claude)
|
||||
|
||||
| Что | Кто | Когда нужно |
|
||||
|---|---|---|
|
||||
| Регистрация ООО → реквизиты | заказчик + юрист | Триггер Track B |
|
||||
| Прил. Ж: оферта + Политика конфиденциальности | юрист | Sprint 8 (footer лендинга) |
|
||||
| Прил. З: уведомление в РКН (152-ФЗ) | юрист | Sprint 9 (до запуска) |
|
||||
| 5 🟦 структурных вопросов реестра | заказчик | по мере появления |
|
||||
|
||||
## 4. Зависимости и критический путь
|
||||
|
||||
```
|
||||
G (push) → Sprint 4 → Sprint 5 → Sprint 6 ─┐
|
||||
├─→ Sprint 9 → launch
|
||||
Б-1 → Sprint 7 → Sprint 8 ─┘
|
||||
```
|
||||
|
||||
**Критический путь** (longest path):
|
||||
|
||||
- Если Б-1 закрывается **раньше** конца Sprint 6: критический путь = G → 4 → 5 → 6 → 9 (Sprint 7+8 параллельно с 5+6, насколько позволят руки)
|
||||
- Если Б-1 закрывается **позже** конца Sprint 6: критический путь = G → Б-1 → 7 → 8 → 9, Sprint 4–6 уже закрыты
|
||||
|
||||
**Жёсткие зависимости:**
|
||||
|
||||
- Sprint 7 ⇐ Б-1 (нельзя купить compute от ООО без ООО)
|
||||
- Sprint 9 ⇐ Sprint 7 (pg_audit/pg_anonymizer только на Managed PG)
|
||||
- Sprint 9 ⇐ Прил. Ж + Прил. З (нельзя запустить без РКН-уведомления и оферты)
|
||||
- Sprint 8 ⇐ Прил. Ж (footer лендинга нужен оферту)
|
||||
|
||||
**Мягкие зависимости (можно параллелить):**
|
||||
|
||||
- Sprint 5 || Sprint 4 (разные подсистемы)
|
||||
- Sprint 6 после Sprint 5 (полезно иметь Semgrep clean перед расширением API)
|
||||
|
||||
## 5. Метрики и acceptance
|
||||
|
||||
### Per-sprint exit criteria
|
||||
|
||||
| Sprint | Acceptance |
|
||||
|---|---|
|
||||
| 0 | `git push` зелёный, GitHub Actions зелёные на 6c2f0ce, `git status` clean |
|
||||
| 4 | Pest +0 / Vitest +N (split'ы добавили tests на новые компоненты) / Larastan + ESLint + vue-tsc 0 ошибок / 0 Vue-компонентов >300 строк (либо ≤2 с обоснованием) |
|
||||
| 5 | Semgrep CI зелёный, 0 high-severity findings; Dependabot создал хотя бы 1 PR на тестовом обновлении; Trivy script готов к активации |
|
||||
| 6 | 3 Provider-класса покрыты Pest-тестами; file-download endpoint работает; AdminTenantDetail все поля без хардкода; ЮKassa webhook принимает signed payload |
|
||||
| 7 | `pg_isready` на Managed PG OK; Redis ping OK; `liderra.ru` резолвится; SSL валиден; Sentry принимает test event |
|
||||
| 8 | CI deploy на staging проходит; SSO admin-login работает; `/api/admin/*` без auth = 401; лендинг доступен по `liderra.ru/`; 3 провайдера почты получили test mail |
|
||||
| 9 | pg_audit логи пишутся; Trivy в pipeline блокирует CVE high+; backup restore проверен; Pa11y 0 violations на live; soft-launch tenant'ы залогинились |
|
||||
| **launch** | Все § 1 «Definition of done» закрыты + 14 дней soft-launch без P0 |
|
||||
|
||||
### Регрессия каждого Sprint'а
|
||||
|
||||
```bash
|
||||
composer test # Pest
|
||||
composer stan # Larastan 0
|
||||
composer pint # без diff
|
||||
npm run test:vue # Vitest
|
||||
npm run lint:vue # ESLint 0
|
||||
npm run type-check # vue-tsc 0
|
||||
npm run check:docs # markdownlint + cspell + lychee + pa11y
|
||||
npm run sast # Semgrep (с Sprint 5)
|
||||
```
|
||||
|
||||
## 6. Риски и mitigations
|
||||
|
||||
| Риск | Вероятность | Impact | Mitigation |
|
||||
|---|---|---|---|
|
||||
| Б-1 затягивается на 2+ месяца | средняя | блокирует Track B | Track A автономен на 3 спринта; после исчерпания — заказчик решает: ждать / перейти на ИП / прод-стенд под физлицом |
|
||||
| Push-blocker (Workflows R&W) не разрешён | низкая | блокирует ВСЁ | заказчик уже в курсе, действие 5 минут |
|
||||
| Histoire ↔ Vite 8 несовместимость не разрешится | низкая | низкое | продолжаем `--legacy-peer-deps` workaround; после Vite 9 решать заново |
|
||||
| Pest 4 browser-tests blocked on Windows | высокая | низкое | Playwright взамен (Sprint 5 опционально), full Pest browser — на Linux staging Sprint 7+ |
|
||||
| Yandex Cloud quota / новый аккаунт verification долгий | средняя | удлиняет Sprint 7 | заявка на quota подаётся как только Б-1 закрыт, parallel со Sprint 6 |
|
||||
| Юрист задерживает Прил. Ж/З | средняя | блокирует Sprint 8/9 | заказчик ставит юристу deadline под Sprint 7 закрытие |
|
||||
| Managed PG миграция данных (если уже есть тестовые) | низкая | средний | на момент миграции data prod = пусто, dev-data выкидываем |
|
||||
| Pre-launch security review находит P0 | средняя | удлиняет Sprint 9 | Semgrep + Trivy в Sprint 5/9 заранее ловят 80% |
|
||||
| Soft-launch выявит UX-проблему | высокая | средний | плановое: 2 недели запас, ловим итеративно |
|
||||
|
||||
## 7. Open questions для будущей детализации
|
||||
|
||||
Эти вопросы решатся в момент написания implementation plan'а каждого Sprint'а — не блокируют design этого roadmap'а:
|
||||
|
||||
- **Sprint 6:** `tenants.inn/phone/address` — колонки или JOIN на `legal_entities`? Зависит от того, есть ли отдельный flow «тенант — юр. лицо» (M-вопрос)
|
||||
- **Sprint 6:** `users.role` — enum-колонка или таблица `user_roles`? Если только 1 роль на user — enum проще; если несколько — таблица. Нужно уточнить product requirement
|
||||
- **Sprint 7:** VM vs Managed K8s — для MVP-нагрузки (≤100 tenant'ов, ≤10K leads/day) хватит 2 VM. K8s overkill, отложить до post-launch масштабирования
|
||||
- **Sprint 9:** какой alerting-канал — Telegram bot, PagerDuty, email? Дешёвый default — Telegram bot
|
||||
- **Sprint 9:** soft-launch tenants — кто они? Заказчик подбирает 3–5 проверенных компаний
|
||||
|
||||
## 8. История версий
|
||||
|
||||
- **v1.0 от 10.05.2026** — первичный roadmap, двухдорожечная структура (Track A не блокировано Б-1, Track B после Б-1, Track C external). 9 sprint'ов всего (Sprint 0 + 4–9). Базовый HEAD `6c2f0ce`.
|
||||
@@ -1,612 +0,0 @@
|
||||
# Spec: Plan 5 — Frontend Project UI + Backend CRUD
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 11.05.2026
|
||||
**Автор:** Claude Code (skill: superpowers:brainstorming)
|
||||
**Заказчик:** Дмитрий
|
||||
**Базовый HEAD:** `4bc488e` (origin/main, Plan 4 закрыт + display-fix)
|
||||
**Источники:**
|
||||
|
||||
- [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](2026-05-10-supplier-integration-design.md) — общий дизайн интеграции с supplier portal (Plan 2/3/4 база)
|
||||
- [db/schema.sql](../../db/schema.sql) v8.19 — `projects`, `supplier_projects`
|
||||
- [docs/CRM_bp-gr_Инструкция_v8_5.md](../../CRM_bp-gr_Инструкция_v8_5.md) — продуктовое ТЗ, §6 (проекты)
|
||||
|
||||
## 1. Контекст
|
||||
|
||||
После Plans 1-4 у Лидерры есть:
|
||||
|
||||
- Schema `projects` с полным набором полей (signal_type, signal_identifier, sms_senders/keyword, daily_limit_target, supplier_b1/b2/b3_project_id, region_mask, delivery_days_mask, assignment_strategy, ttfr_target_minutes).
|
||||
- Schema `supplier_projects` (sync_status, last_synced_at).
|
||||
- Backend supplier-sync инфраструктура (Plan 3): `SupplierPortalClient`, `SupplierWebhookController`, `RouteSupplierLeadJob`.
|
||||
- `ProjectController@index` — **только GET-list-stub** (4 поля: id/name/tag/type) для NewDealDialog dropdown'а.
|
||||
|
||||
Чего **нет**:
|
||||
|
||||
- Backend: full CRUD на `projects` (POST/PATCH/DELETE/sync-trigger/bulk/toggle-active).
|
||||
- Frontend: **ProjectsView с нуля**. Нет route `/projects`, нет пункта в nav-tree, нет компонентов.
|
||||
|
||||
Tenant Лидерры не может управлять своими источниками лидов через UI. Plan 5 закрывает этот разрыв.
|
||||
|
||||
## 2. Scope и non-goals
|
||||
|
||||
### В scope
|
||||
|
||||
- Backend CRUD: GET list/show, POST/PATCH/DELETE, sync-trigger, toggle-active, bulk operations
|
||||
- Frontend ProjectsView: карточки с прогресс-баром «доставлено/лимит сегодня», фильтры, поиск
|
||||
- NewProjectDialog: 3 таба (Сайт / Звонок / СМС) + общие поля (name, daily_limit, regions, workdays)
|
||||
- EditProjectDialog: re-use NewProjectDialog в режиме edit (одна форма для create + edit)
|
||||
- Async sync flow: SyncSupplierProjectJob + polling sync_status каждые 5 сек
|
||||
- Schema delta v8.19 → v8.20: `projects.archived_at TIMESTAMPTZ NULL`
|
||||
- Bulk operations: pause/resume/archive нескольких проектов через один endpoint
|
||||
- Pause/Resume toggle (быстрый toggle прямо на карточке)
|
||||
- Manual «Sync now» кнопка (для retry после sync_status='failed')
|
||||
|
||||
### Не в scope
|
||||
|
||||
- **SSE/WebSocket** для sync-статусов — polling 5 сек достаточно для MVP (5-50 проектов на tenant).
|
||||
- **Карта РФ** для выбора регионов — chip-multiselect с поиском (см. §6.4).
|
||||
- **Hard delete** — только soft (`is_active=false` + `archived_at`). Schema RESTRICT FK от deals на projects, hard delete сломал бы историю.
|
||||
- **Project templates** (создание из шаблона) — Plan 6+.
|
||||
- **Шаблонизация sms_senders** (один sender для нескольких проектов) — поведение supplier portal на B3 сделает это автоматически (Plan 3 базис).
|
||||
- **Project analytics** (history, conversion stats) — задача ReportsView, Plan 6+.
|
||||
- **Manual sender-conflict resolution UI** — если sender уже занят, пользователь видит ошибку, должен выбрать другой. Auto-merge на B3 — следующий план.
|
||||
|
||||
### Допущения
|
||||
|
||||
- Tenant Лидерры лимитирован `tenants.limits.max_projects` (по умолчанию 10 для тарифа «Команда»).
|
||||
- `delivered_today` / `delivered_in_month` обновляются существующим RouteSupplierLeadJob — Plan 5 их не трогает, только читает.
|
||||
- На MVP RLS изоляция: `auth()->user()->tenant_id` устанавливается в `app.current_tenant_id` через middleware `tenant`.
|
||||
|
||||
## 3. Архитектура
|
||||
|
||||
### Backend компоненты
|
||||
|
||||
```
|
||||
app/Http/Controllers/Api/ProjectController.php ← расширение (~250 строк)
|
||||
- index() GET /api/projects
|
||||
- show($id) GET /api/projects/{id}
|
||||
- store() POST /api/projects
|
||||
- update($id) PATCH /api/projects/{id}
|
||||
- destroy($id) DELETE /api/projects/{id}
|
||||
- sync($id) POST /api/projects/{id}/sync
|
||||
- toggle($id) PATCH /api/projects/{id}/toggle-active
|
||||
- bulk() POST /api/projects/bulk
|
||||
|
||||
app/Http/Requests/StoreProjectRequest.php ← новый
|
||||
app/Http/Requests/UpdateProjectRequest.php ← новый
|
||||
app/Http/Requests/BulkProjectActionRequest.php ← новый
|
||||
|
||||
app/Services/Project/ProjectService.php ← новый
|
||||
- create(array $data): Project
|
||||
- update(Project $p, array $data): Project
|
||||
- archive(Project $p): void
|
||||
- triggerSync(Project $p): void
|
||||
- bulkAction(string $action, array $ids): int
|
||||
|
||||
app/Jobs/SyncSupplierProjectJob.php ← новый (Plan 3 уже имеет похожие jobs)
|
||||
- handle(): резолвит платформы, дёргает SupplierPortalClient,
|
||||
привязывает supplier_projects, выставляет sync_status
|
||||
|
||||
database/migrations/2026_05_..._add_archived_at_to_projects.php
|
||||
- ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL
|
||||
```
|
||||
|
||||
### Frontend компоненты
|
||||
|
||||
```
|
||||
resources/js/router/index.ts ← +route /projects
|
||||
resources/js/components/layout/AppLayout.vue ← +пункт «Проекты» в nav-tree
|
||||
|
||||
resources/js/views/ProjectsView.vue ← новый (~200 строк)
|
||||
- header: title, фильтры (signal_type/status), search input, кнопка «+ Создать»
|
||||
- body: grid карточек ProjectCard + BulkActionsBar (если selected.length > 0)
|
||||
- state: pagination, фильтры, выбранные ids
|
||||
|
||||
resources/js/views/projects/NewProjectDialog.vue ← новый (~250 строк)
|
||||
- <v-tabs>: Сайт / Звонок / СМС
|
||||
- под каждым табом — соответствующие поля + общие
|
||||
- validation через VeeValidate или нативный (на выбор имплементатора)
|
||||
|
||||
resources/js/views/projects/EditProjectDialog.vue ← новый (~30 строк)
|
||||
- re-use NewProjectDialog с :initial-data и :mode="edit"
|
||||
- signal_type readonly после создания (нельзя поменять)
|
||||
|
||||
resources/js/components/projects/ProjectCard.vue ← новый (~120 строк)
|
||||
- имя, type-chip, identifier, прогресс-бар (delivered_today / daily_limit_target)
|
||||
- sync-status indicator (color-coded по 4 значениям)
|
||||
- меню действий ⋮: Edit, Pause/Resume toggle, Sync now, Archive
|
||||
- чекбокс для bulk
|
||||
|
||||
resources/js/components/projects/BulkActionsBar.vue ← новый (~80 строк)
|
||||
- toolbar fixed-bottom с кнопками: Pause, Resume, Archive
|
||||
- confirm-dialog перед action
|
||||
|
||||
resources/js/stores/projectsStore.ts ← новый (~150 строк)
|
||||
- state: items, filters, pagination, selectedIds, pendingIds (Set)
|
||||
- actions: fetch, create, update, archive, sync, toggle, bulkAction
|
||||
- polling: пока pendingIds.size > 0, refetch по этим ids каждые 5 сек
|
||||
- cleanup polling на unmount
|
||||
|
||||
resources/js/views/ProjectsView.story.vue ← новый (Histoire)
|
||||
resources/js/views/projects/NewProjectDialog.story.vue
|
||||
resources/js/components/projects/ProjectCard.story.vue
|
||||
resources/js/components/projects/BulkActionsBar.story.vue
|
||||
```
|
||||
|
||||
## 4. Data model
|
||||
|
||||
### 4.1. Schema delta v8.19 → v8.20
|
||||
|
||||
```sql
|
||||
ALTER TABLE projects ADD COLUMN archived_at TIMESTAMPTZ NULL;
|
||||
```
|
||||
|
||||
**Зачем колонка отдельно от `is_active`:**
|
||||
|
||||
- `is_active=false` означает «приостановлен» — обратимо, статус виден в UI как «На паузе».
|
||||
- `archived_at IS NOT NULL` — «архивирован», не отображается в default-list, требует `?show=archived` для просмотра. Restore возможен через POST `/api/projects/{id}/restore` (отдельный endpoint, Plan 6 если нужен).
|
||||
|
||||
В Plan 5 restore-endpoint **не реализуется** — архивный проект восстанавливается только через прямой SQL или admin SaaS UI (out-of-scope).
|
||||
|
||||
### 4.2. Использование существующих полей `projects`
|
||||
|
||||
| Поле | Использование в Plan 5 |
|
||||
|---|---|
|
||||
| `id`, `tenant_id`, `name` | Базовое |
|
||||
| `signal_type` | enum: `site` / `call` / `sms` (зашит в валидации) |
|
||||
| `signal_identifier` | Для site/call: домен или 11-значный номер с 7 |
|
||||
| `sms_senders` (jsonb) | Для sms: `array<string max:11>` |
|
||||
| `sms_keyword` (text) | Для sms: nullable, при пустом — только B3 |
|
||||
| `daily_limit_target` | Целевой лимит в день |
|
||||
| `effective_daily_limit_today` | **Read-only в UI** (вычисляется существующим backend'ом на основании balance) |
|
||||
| `delivered_today`, `delivered_in_month` | **Read-only** (обновляется RouteSupplierLeadJob) |
|
||||
| `supplier_b1/b2/b3_project_id` | **Read-only** (обновляется SyncSupplierProjectJob) |
|
||||
| `is_active` | pause/resume toggle |
|
||||
| `region_mask` | int (bitmask 89 регионов; 0 = вся РФ) |
|
||||
| `region_mode` | string: `include` / `exclude` (значения зашиты в CHECK-constraint schema.sql v8.20). В Plan 5 используем только `include` (default; пустой `region_mask=0` = все РФ; ненулевой `region_mask` = whitelist выбранных регионов). `exclude` (blacklist-семантика) остаётся в schema на будущее (Plan 6+), сейчас в UI недоступен. **Note:** изначально spec/plan использовали `all`/`whitelist` semantic-naming, но при имплементации Task 3 обнаружено что schema CHECK требует `include`/`exclude` — schema = источник истины, поэтому propagate во все Task'и.|
|
||||
| `delivery_days_mask` | int 1-127 (bitmask 7 дней; например 31 = пн-пт) |
|
||||
| `assignment_strategy` | string (по умолчанию `round-robin`) — Plan 5 не редактирует, дефолт |
|
||||
| `ttfr_target_minutes` | int — Plan 5 не редактирует, дефолт из schema |
|
||||
| `archived_at` | **новое**: timestamp архивирования |
|
||||
|
||||
### 4.3. Связь с `supplier_projects`
|
||||
|
||||
В Plan 5 при создании/редактировании Лидерра-проекта `SyncSupplierProjectJob`:
|
||||
|
||||
1. Резолвит набор платформ:
|
||||
- site/call → B1 + B2 + B3
|
||||
- sms с keyword → B2 + B3
|
||||
- sms без keyword → только B3
|
||||
2. На каждой платформе ищет existing `supplier_projects` по `unique_key`:
|
||||
- site: домен
|
||||
- call: номер
|
||||
- sms B2: `<senders[0]>+<keyword>`
|
||||
- sms B3: `<senders[0]>` (если sms_senders.length > 1 — несколько supplier_projects)
|
||||
3. Если не нашёл — создаёт через SupplierPortalClient → INSERT в `supplier_projects`.
|
||||
4. Записывает foreign keys в `projects.supplier_b1_project_id|b2|b3`.
|
||||
5. Выставляет `supplier_projects.sync_status='ok'` (или `'failed'` с `last_error`).
|
||||
|
||||
UI читает `sync_status` через `GET /api/projects/{id}` (включая joined supplier_projects).
|
||||
|
||||
## 5. API контракт
|
||||
|
||||
### 5.1. GET /api/projects
|
||||
|
||||
**Запрос:**
|
||||
|
||||
```
|
||||
GET /api/projects?signal_type=site&status=active&search=окна&page=1&per_page=20
|
||||
```
|
||||
|
||||
**Query parameters:**
|
||||
|
||||
| Параметр | Тип | Значение |
|
||||
|---|---|---|
|
||||
| `signal_type` | string | `site` / `call` / `sms` (фильтр; пусто = все) |
|
||||
| `status` | string | `active` / `paused` / `archived` (default — `active`+`paused`, исключая archived) |
|
||||
| `search` | string | LIKE-поиск по `name` + `signal_identifier` (debounce 300ms на фронте) |
|
||||
| `page`, `per_page` | int | пагинация (default 1 / 20; max per_page=100) |
|
||||
| `ids` | csv-string | batch-fetch для polling: `?ids=1,5,7` — возвращает только эти ids (без пагинации, без фильтров) |
|
||||
|
||||
**Ответ 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Окна СПб",
|
||||
"signal_type": "site",
|
||||
"signal_identifier": "okna-spb.ru",
|
||||
"sms_senders": null,
|
||||
"sms_keyword": null,
|
||||
"daily_limit_target": 50,
|
||||
"effective_daily_limit_today": 50,
|
||||
"delivered_today": 32,
|
||||
"delivered_in_month": 412,
|
||||
"is_active": true,
|
||||
"archived_at": null,
|
||||
"region_mask": 0,
|
||||
"region_mode": "include",
|
||||
"delivery_days_mask": 127,
|
||||
"sync_status": "ok",
|
||||
"last_synced_at": "2026-05-11T13:30:00Z"
|
||||
}
|
||||
],
|
||||
"meta": { "current_page": 1, "per_page": 20, "total": 12 }
|
||||
}
|
||||
```
|
||||
|
||||
`sync_status` — агрегированный (worst-of по supplier_projects): если хоть один `failed` → `failed`; иначе если хоть один `pending` → `pending`; иначе `ok`.
|
||||
|
||||
### 5.2. GET /api/projects/{id}
|
||||
|
||||
Включает массив `supplier_links` с детализацией:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"...": "поля как в index",
|
||||
"supplier_links": [
|
||||
{ "platform": "b1", "supplier_project_id": 42, "sync_status": "ok", "last_synced_at": "..." },
|
||||
{ "platform": "b2", "supplier_project_id": 43, "sync_status": "ok", "last_synced_at": "..." },
|
||||
{ "platform": "b3", "supplier_project_id": 44, "sync_status": "ok", "last_synced_at": "..." }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3. POST /api/projects
|
||||
|
||||
**Тело (site):**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Окна СПб",
|
||||
"signal_type": "site",
|
||||
"signal_identifier": "okna-spb.ru",
|
||||
"daily_limit_target": 50,
|
||||
"region_mask": 0,
|
||||
"region_mode": "include",
|
||||
"delivery_days_mask": 127
|
||||
}
|
||||
```
|
||||
|
||||
**Тело (sms с keyword):**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Ипотека Тинькофф",
|
||||
"signal_type": "sms",
|
||||
"sms_senders": ["TINKOFF"],
|
||||
"sms_keyword": "ипотека",
|
||||
"daily_limit_target": 100,
|
||||
"region_mask": 0,
|
||||
"region_mode": "include",
|
||||
"delivery_days_mask": 127
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ 201:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": { "id": 13, "...": "...", "sync_status": "pending" }
|
||||
}
|
||||
```
|
||||
|
||||
После 201 Pinia store добавляет `13` в `pendingIds` → polling запускается.
|
||||
|
||||
### 5.4. PATCH /api/projects/{id}
|
||||
|
||||
Partial body — любое подмножество полей кроме `signal_type` (immutable после create), `tenant_id` (immutable), `delivered_*` / `supplier_b*_project_id` (read-only).
|
||||
|
||||
Если изменился `signal_identifier` / `sms_senders` / `sms_keyword` — диспатчится `SyncSupplierProjectJob` (sync_status переходит в pending).
|
||||
|
||||
### 5.5. DELETE /api/projects/{id}
|
||||
|
||||
Soft delete:
|
||||
|
||||
```
|
||||
UPDATE projects SET is_active=false, archived_at=NOW() WHERE id=:id AND tenant_id=current_tenant_id();
|
||||
```
|
||||
|
||||
Возвращает **204 No Content**. SyncSupplierProjectJob НЕ диспатчится — supplier_projects остаются как есть (могут шарить другие Лидерра-проекты). Если supplier_projects больше никем не используются — это уберёт отдельный `CleanupOrphanSupplierProjectsJob` (out-of-scope Plan 5).
|
||||
|
||||
### 5.6. POST /api/projects/{id}/sync
|
||||
|
||||
Re-dispatch sync job. Возвращает:
|
||||
|
||||
```json
|
||||
{ "queued": true, "sync_status": "pending" }
|
||||
```
|
||||
|
||||
Status 202 Accepted. UI добавляет id в pendingIds → polling.
|
||||
|
||||
### 5.7. PATCH /api/projects/{id}/toggle-active
|
||||
|
||||
```json
|
||||
{ "is_active": false }
|
||||
```
|
||||
|
||||
Не диспатчит sync job. Возвращает 200 + project. UI обновляет state.
|
||||
|
||||
### 5.8. POST /api/projects/bulk
|
||||
|
||||
```json
|
||||
{ "action": "pause", "ids": [1, 5, 7] }
|
||||
```
|
||||
|
||||
`action` ∈ `{"pause", "resume", "archive"}`. Возвращает:
|
||||
|
||||
```json
|
||||
{ "updated": 3 }
|
||||
```
|
||||
|
||||
Все ids фильтруются по tenant_id (RLS) — если в массиве чужие — они молча отбрасываются (см. §7).
|
||||
|
||||
## 6. UI/UX детали
|
||||
|
||||
### 6.1. Layout списка (карточки)
|
||||
|
||||
- Vue 3 + Vuetify 3, palette Forest (`#0F6E56` primary)
|
||||
- Grid responsive: 1 колонка мобильно, 2 — tablet, 3 — desktop
|
||||
- ProjectCard:
|
||||
- Header: name (bold) + type-chip (по цветам) + `⋮` action menu
|
||||
- Sub: signal_identifier (или `<senders>·<keyword>` для sms) — text-medium-emphasis
|
||||
- Прогресс: `delivered_today / daily_limit_target` + horizontal `v-progress-linear` (Forest при OK, серый при paused, красный при failed)
|
||||
- Footer: sync-status indicator с цветом и текстом
|
||||
- Архивные не показываются по умолчанию; фильтр включает их по `?show=archived`
|
||||
|
||||
### 6.2. NewProjectDialog (3 таба)
|
||||
|
||||
- `<v-tabs>` сверху: Сайт / Звонок / СМС (с иконками)
|
||||
- **Общие поля** размещаются **под `<v-tabs>` в стационарной секции** (не внутри `<v-tab-item>`-ов): `name`, `daily_limit_target`, `regions`, `workdays`. Так они не «плывут» при переключении таба.
|
||||
- Поля по табу (внутри `<v-tabs-window-item>` соответствующего signal_type):
|
||||
- **Сайт**: signal_identifier (label «Домен конкурента», placeholder «okna-konkurent.ru», hint про формат)
|
||||
- **Звонок**: signal_identifier (label «Номер конкурента», маска `+7 (XXX) XXX-XX-XX`, валидация 11-значный)
|
||||
- **СМС**: sms_senders (chip-input до 11 символов, multiple), sms_keyword (optional, hint про B2/B3)
|
||||
|
||||
Кнопки внизу: «Отмена» / «Создать». При submit — POST, success → close dialog + add to pendingIds.
|
||||
|
||||
### 6.3. EditProjectDialog
|
||||
|
||||
Тот же компонент с `:mode="edit"`. Различия:
|
||||
|
||||
- `<v-tabs>` disabled (signal_type не меняется)
|
||||
- signal_identifier readonly для site/call
|
||||
- sms_senders / sms_keyword редактируется (но триггерит resync)
|
||||
- name / daily_limit / regions / workdays — редактируется свободно
|
||||
|
||||
### 6.4. Регионы
|
||||
|
||||
- `<v-autocomplete multiple chips clearable>` с поиском
|
||||
- Items — массив 89 регионов РФ из локального constants (`resources/js/constants/regions.ts`)
|
||||
- Каждый регион = `{ code: int, name: string }` (например `{ code: 78, name: 'Санкт-Петербург' }`)
|
||||
- Пусто = вся РФ (region_mask=0, region_mode='include')
|
||||
- Выбраны — chip'ы; bit-mask вычисляется на фронте перед submit
|
||||
|
||||
### 6.5. Workdays
|
||||
|
||||
- 7 toggle-buttons (Пн Вт Ср Чт Пт Сб Вс)
|
||||
- Shortcut buttons: «Будни» (1-5), «Все дни» (1-7), «Только выходные» (6-7)
|
||||
- bit-mask: bit0=Пн, ..., bit6=Вс
|
||||
|
||||
### 6.6. Sync-status indicator
|
||||
|
||||
| Status | Цвет | Текст |
|
||||
|---|---|---|
|
||||
| `ok` | `#0F6E56` (Forest primary) | «● Sync OK» |
|
||||
| `pending` | `#FF9800` (orange) | «● Sync pending» + small spinner |
|
||||
| `failed` | `#D32F2F` (error red) | «● Sync failed» + tooltip с `last_error` |
|
||||
| paused (is_active=false) | `#999` (grey) | «● На паузе» |
|
||||
|
||||
### 6.7. Polling-логика
|
||||
|
||||
```typescript
|
||||
// projectsStore.ts (псевдокод)
|
||||
const pendingIds = ref<Set<number>>(new Set());
|
||||
let pollTimeout: number | null = null;
|
||||
let currentDelay = 5000; // ms; растёт при ошибках до 30000 макс
|
||||
const DELAY_OK = 5000;
|
||||
const DELAY_MAX = 30000;
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimeout) return;
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
function scheduleNext() {
|
||||
pollTimeout = setTimeout(async () => {
|
||||
if (pendingIds.value.size === 0) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const ids = Array.from(pendingIds.value);
|
||||
const { data } = await axios.get('/api/projects', { params: { ids: ids.join(',') } });
|
||||
for (const project of data.data) {
|
||||
updateItem(project);
|
||||
if (project.sync_status === 'ok' || project.sync_status === 'failed') {
|
||||
pendingIds.value.delete(project.id);
|
||||
}
|
||||
}
|
||||
currentDelay = DELAY_OK; // reset на success
|
||||
} catch (e) {
|
||||
currentDelay = Math.min(currentDelay * 2, DELAY_MAX); // exponential backoff
|
||||
// если currentDelay уже на потолке после 3-й ошибки подряд — show snackbar и стоп
|
||||
}
|
||||
scheduleNext();
|
||||
}, currentDelay);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimeout) clearTimeout(pollTimeout);
|
||||
pollTimeout = null;
|
||||
currentDelay = DELAY_OK;
|
||||
}
|
||||
```
|
||||
|
||||
Используем `setTimeout`-recursion (не `setInterval`) чтобы переменный delay при backoff'е работал корректно.
|
||||
|
||||
GET endpoint должен поддерживать `?ids=1,5,7` для batch-fetch (расширение в §5.1).
|
||||
|
||||
## 7. Validation rules
|
||||
|
||||
### 7.1. StoreProjectRequest
|
||||
|
||||
```php
|
||||
public function rules(): array
|
||||
{
|
||||
$signalType = $this->input('signal_type');
|
||||
|
||||
$rules = [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'signal_type' => ['required', Rule::in(['site', 'call', 'sms'])],
|
||||
'daily_limit_target' => ['required', 'integer', 'min:1', 'max:10000'],
|
||||
'region_mask' => ['required', 'integer', 'min:0'],
|
||||
'region_mode' => ['required', Rule::in(['include', 'exclude'])],
|
||||
'delivery_days_mask' => ['required', 'integer', 'min:1', 'max:127'],
|
||||
];
|
||||
|
||||
if ($signalType === 'site') {
|
||||
$rules['signal_identifier'] = ['required', 'string', 'regex:/^[a-z0-9.-]+\.[a-z]{2,}$/i'];
|
||||
}
|
||||
if ($signalType === 'call') {
|
||||
$rules['signal_identifier'] = ['required', 'string', 'regex:/^7\d{10}$/'];
|
||||
}
|
||||
if ($signalType === 'sms') {
|
||||
$rules['sms_senders'] = ['required', 'array', 'min:1'];
|
||||
$rules['sms_senders.*'] = ['string', 'max:11'];
|
||||
$rules['sms_keyword'] = ['nullable', 'string', 'max:50'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2. UpdateProjectRequest
|
||||
|
||||
Тот же набор кроме `signal_type` (immutable) и `signal_identifier` для site/call (immutable; для sms `sms_senders/keyword` редактируется).
|
||||
|
||||
### 7.3. BulkProjectActionRequest
|
||||
|
||||
```php
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'action' => ['required', Rule::in(['pause', 'resume', 'archive'])],
|
||||
'ids' => ['required', 'array', 'min:1', 'max:100'],
|
||||
'ids.*' => ['integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Max 100 ids — защита от DoS-payload'ов.
|
||||
|
||||
## 8. Обработка ошибок
|
||||
|
||||
| Место падения | Что может упасть | Поведение |
|
||||
|---|---|---|
|
||||
| `StoreProjectRequest` | Невалидный домен / телефон / senders | 422 + payload `{errors: {field: [msg]}}` (стандарт Laravel). UI рендерит ошибки в `<v-text-field error-messages>`. |
|
||||
| `ProjectService::create` | Лимит `tenants.limits.max_projects` исчерпан | 403 `{message: "Достигнут лимит проектов (X) тарифа «Y»"}`. UI: alert с ссылкой на /billing «Смените тариф». |
|
||||
| `SyncSupplierProjectJob` | Supplier portal недоступен | sync_status='failed', `last_error="Не удалось подключиться к порталу поставщика"`. Retry 3 раза с backoff 15с/60с/300с. После 3-го — финальный fail. UI отображает кнопку «Sync now» для retry. |
|
||||
| `SyncSupplierProjectJob` | Sender уже занят (B3 не разрешает дубликаты) | sync_status='failed', `last_error="Sender '<X>' уже зарегистрирован на платформе B3 — выберите другой"`. UI отображает текст из tooltip'а. |
|
||||
| `ProjectService::update` | daily_limit_target < delivered_today | 422 `{errors: {daily_limit_target: ["Лимит не может быть меньше уже доставленных лидов сегодня (X)"]}}`. |
|
||||
| `ProjectService::archive` | Project уже архивирован | 409 Conflict `{message: "Project уже архивирован"}`. |
|
||||
| `bulk` action | Некоторые ids чужие (другой tenant) | RLS отфильтрует через `WHERE tenant_id=current_tenant_id()`. Чужие ids молча игнорируются. Ответ `{updated: N}` где N = только свои. |
|
||||
| Polling 5xx | Backend временно недоступен | Frontend exponential backoff: 5с → 10с → 30с → стоп с `v-snackbar` «Не удалось проверить статус. Обновите страницу.». |
|
||||
|
||||
## 9. Тестирование
|
||||
|
||||
### 9.1. Pest (Feature, integration)
|
||||
|
||||
```
|
||||
tests/Feature/Projects/
|
||||
├── ProjectsCrudTest.php # index/show/store/update/destroy happy path
|
||||
├── ProjectsValidationTest.php # все 422-ветки (3 signal_type × invalid поля)
|
||||
├── ProjectsTenantIsolationTest.php # RLS: tenant X не видит проекты tenant Y
|
||||
├── ProjectsSyncTriggerTest.php # POST /sync → job dispatched, status pending
|
||||
├── ProjectsToggleActiveTest.php # PATCH /toggle-active
|
||||
├── ProjectsBulkTest.php # bulk pause/resume/archive + mixed-tenant отфильтровывается
|
||||
├── ProjectsLimitTest.php # max_projects превышен → 403
|
||||
└── ProjectsArchiveTest.php # destroy → is_active=false, archived_at != null
|
||||
|
||||
tests/Feature/Jobs/
|
||||
└── SyncSupplierProjectJobTest.php # happy + 3 failure scenarios (timeout/conflict/partial)
|
||||
```
|
||||
|
||||
### 9.2. Vitest (unit-frontend)
|
||||
|
||||
```
|
||||
tests/Frontend/
|
||||
├── ProjectsView.spec.ts # рендер, фильтры, search-debounce, empty state
|
||||
├── NewProjectDialog.spec.ts # переключение табов, валидация, submit
|
||||
├── EditProjectDialog.spec.ts # readonly-поля, prefill initial-data
|
||||
├── ProjectCard.spec.ts # sync-status цвета, прогресс-бар, меню действий
|
||||
├── BulkActionsBar.spec.ts # selection-flow, confirm-dialog
|
||||
└── projectsStore.spec.ts # polling (mock setInterval / fake timers)
|
||||
```
|
||||
|
||||
### 9.3. Histoire (visual regression baseline)
|
||||
|
||||
```
|
||||
resources/js/views/ProjectsView.story.vue # empty/5 projects/50 projects
|
||||
resources/js/views/projects/NewProjectDialog.story.vue # 3 tabs × 3 states (valid/error/loading)
|
||||
resources/js/views/projects/EditProjectDialog.story.vue # site/call/sms readonly states
|
||||
resources/js/components/projects/ProjectCard.story.vue # ok/pending/failed/paused
|
||||
resources/js/components/projects/BulkActionsBar.story.vue # 1/3/many selected
|
||||
```
|
||||
|
||||
### 9.4. TDD-цикл
|
||||
|
||||
Каждая task = vertical-slice: failing test → minimal code → green → refactor → commit. По паттерну Plan 4.
|
||||
|
||||
## 10. Acceptance criteria
|
||||
|
||||
- **AC-1.** `GET /api/projects` возвращает paginated list текущего tenant'а с фильтрами по signal_type, status, search.
|
||||
- **AC-2.** `POST /api/projects` для site/call/sms создаёт запись + диспатчит SyncSupplierProjectJob (sync_status='pending').
|
||||
- **AC-3.** `PATCH /api/projects/{id}` обновляет разрешённые поля, не трогает immutable (signal_type, supplier_b*_project_id).
|
||||
- **AC-4.** `DELETE /api/projects/{id}` ставит `is_active=false, archived_at=NOW()`.
|
||||
- **AC-5.** `POST /api/projects/{id}/sync` re-dispatch'ит job.
|
||||
- **AC-6.** `PATCH /api/projects/{id}/toggle-active` переключает is_active без resync.
|
||||
- **AC-7.** `POST /api/projects/bulk` атомарно выполняет action на до 100 ids, чужие отфильтровывает.
|
||||
- **AC-8.** ProjectsView отображает карточки с прогресс-баром и sync-status indicator.
|
||||
- **AC-9.** NewProjectDialog имеет 3 таба (Сайт/Звонок/СМС) с правильной валидацией по типу.
|
||||
- **AC-10.** EditProjectDialog re-use NewProjectDialog с readonly signal_type.
|
||||
- **AC-11.** Polling каждые 5 сек обновляет sync_status pending → ok/failed, останавливается когда pendingIds пуст.
|
||||
- **AC-12.** Bulk-toolbar появляется при selected.length>0, confirm-dialog перед действием.
|
||||
- **AC-13.** Все 422-ошибки валидации рендерятся в `<v-text-field error-messages>`.
|
||||
- **AC-14.** Pest зелёный (всё новое + не сломано существующее). Vitest зелёный. Histoire 5 новых story'ев билдятся.
|
||||
- **AC-15.** Pa11y zero violations на /projects (с уже существующими story.vue baseline).
|
||||
- **AC-16.** lychee 0 broken links в новых .md (plan + spec).
|
||||
|
||||
## 11. Открытые вопросы
|
||||
|
||||
- **OPEN-Plan5-01.** `assignment_strategy` (round-robin / by-region / по приоритету tenant'а) — в Plan 5 фиксируется как `round-robin` по schema-дефолту. Расширение — Plan 6.
|
||||
- **OPEN-Plan5-02.** `ttfr_target_minutes` — фиксируется по schema-дефолту. Редактирование через admin SaaS.
|
||||
- **OPEN-Plan5-03.** Restore из архива — endpoint не делаем в Plan 5. Если нужно — Plan 6 или admin SaaS интерфейс.
|
||||
- **OPEN-Plan5-04.** Что показывать в `EditProjectDialog` если `last_synced_at` старше N дней — оставляем как есть, без warning. Если потребуется — Plan 6.
|
||||
|
||||
## 12. Зависимости
|
||||
|
||||
- **Plans 1-4** — все merged на origin/main `4bc488e`. Schema v8.19, supplier sync backend, billing работают.
|
||||
- **Plan 3 supplier backend** — `SupplierPortalClient` уже умеет всё что нужно для SyncSupplierProjectJob. Plan 5 интегрируется как consumer.
|
||||
- **Plans 1-4 frontend stack** — Vue 3.5 + Vuetify 3.12 + Pinia 3.0 + axios 1.16 + Histoire 1.0 — без изменений.
|
||||
|
||||
## 13. Оценка объёма
|
||||
|
||||
Сопоставимо с Plan 4 (12 vertical-slice tasks):
|
||||
|
||||
| Категория | Tasks (ориентир) |
|
||||
|---|---|
|
||||
| Schema delta + миграция | 1 |
|
||||
| Backend controllers + requests + service + job | 4-5 |
|
||||
| Frontend ProjectsView + ProjectCard | 2 |
|
||||
| NewProjectDialog (3 таба + валидация) | 2 |
|
||||
| BulkActionsBar + polling store | 1-2 |
|
||||
| Pest + Vitest + Histoire | покрыты внутри каждой task'и (TDD) |
|
||||
| Wire-up: router + nav-tree + smoke | 1 |
|
||||
| **Итого:** | **~11-13 tasks** |
|
||||
|
||||
Точная декомпозиция — отдельный план через `superpowers:writing-plans` после approval этого spec'а.
|
||||
@@ -1,139 +0,0 @@
|
||||
# Spec: Roadmap post-Plan 5 — birdseye до production launch
|
||||
|
||||
**Версия:** 1.0
|
||||
**Дата:** 11.05.2026
|
||||
**Автор:** Claude Code (skill: superpowers:brainstorming)
|
||||
**Заказчик:** Дмитрий
|
||||
**Статус:** черновик, ждёт review заказчика
|
||||
**Базовый HEAD:** `48f27b4` (Plan 5 specs+plans on origin/main; реализация на ветке `plan5-frontend-projects`)
|
||||
**Отношение к v1.0:** [roadmap-to-production-design.md](2026-05-10-roadmap-to-production-design.md) v1.0 (320 строк, deep-reference) остаётся в силе для детализации Sprint 5–9; этот документ — birdseye-обзор поверх v1.0 с учётом supplier-линии Plans 1–5 и факта закрытия Sprint 0 / Sprint 4
|
||||
|
||||
## 1. Что уже закрыто (state на 11.05.2026)
|
||||
|
||||
- **MVP закрыт 09.05.2026** (`830a652`): 13/13 экранов handoff покрыты, Pest/Vitest зелёные
|
||||
- **Sprint 0 push разблокирован**: fine-grained PAT `Workflows: Read & Write` активен
|
||||
- **Sprint 4 «Audit tail» ✅** (`ffe34e1`): keyset pagination в DealController + 8 Vue split'ов + bundle analyzer + knip
|
||||
- **Plans 1+2+2.5+2.6+3+4 supplier integration ✅** merged на origin/main `48f27b4`: schema v8.11→v8.19 (62 базовые таблицы / 117 индексов / 39 RLS), 7-ступенчатый pricing-tier биллинг, CSV reconcile hourly, auto-pause, 3 admin/tenant UI экрана
|
||||
- **Plan 5 «Frontend Projects UI + Backend CRUD»** в работе на ветке `plan5-frontend-projects` (12 Tasks TDD; design+plan на main, реализация в progress)
|
||||
|
||||
## 2. Линейная лента «от Plan 5 до public launch»
|
||||
|
||||
```
|
||||
[NOW] Plan 5 «Frontend Projects UI» (12 Tasks, in progress)
|
||||
│
|
||||
▼
|
||||
[Sprint 5] Pre-prod tooling local (0.5 нед.)
|
||||
• Semgrep SAST + Semgrep MCP
|
||||
• GitHub Dependabot (.github/dependabot.yml)
|
||||
• Trivy script + workflow stub (активация в Sprint 9)
|
||||
• plan готов: docs/superpowers/plans/2026-05-10-sprint5-preprod-tooling-plan.md
|
||||
│
|
||||
▼
|
||||
[Sprint 6] Post-MVP backend (1.5 нед.)
|
||||
• Reports backend providers (Managers/Sources/Billing) + file download endpoint
|
||||
• AdminTenantDetail доделки (inn/contact_phone/legal_address; user_roles)
|
||||
• Notification integrations (new_device_login, deep-link bell, ЮKassa webhook scaffold)
|
||||
• Phase-A plan готов: docs/superpowers/plans/2026-05-10-sprint6-phase-a-reports-plan.md
|
||||
│
|
||||
▼ ←─── жёсткая зависимость от Б-1: если ещё не закрыт — пауза
|
||||
[Sprint 7] YC infrastructure (1–1.5 нед., требует Б-1)
|
||||
• Compute (2 VM: web + db replica) + Managed PG 16 + Managed Redis
|
||||
• Object Storage (reports) + Container Registry (YCR)
|
||||
• Sentry self-hosted (отдельная VM, sentry.liderra.ru)
|
||||
• Домен liderra.ru + Let's Encrypt SSL
|
||||
│
|
||||
▼
|
||||
[Sprint 8] CI/CD + Б-1-features (1.5–2 нед.)
|
||||
• CI deploy: GitHub Actions → docker build → YCR push → ssh deploy
|
||||
• Secrets: Yandex Lockbox (DB / ЮKassa / Yandex 360 / Sentry DSN)
|
||||
• Unisender Go SMTP-relay (реальные креды)
|
||||
• SSO Yandex 360 (#6) + auth-middleware на /api/admin/*
|
||||
• Лендинг (по лендинг/TZ_landing_v1_0.md) + JivoSite виджет
|
||||
│
|
||||
▼
|
||||
[Sprint 9] Hardening + soft-launch (1–2 нед. work + 2 нед. soft-launch)
|
||||
• pg_audit + pg_anonymizer (на Managed PG)
|
||||
• Trivy полный pipeline (block на CVE high+)
|
||||
• Backup strategy + restore drill
|
||||
• Yandex Monitoring + alerting (Telegram bot default)
|
||||
• Pre-launch security review (Semgrep + Trivy + OWASP top-10 manual)
|
||||
• Pa11y на live URL
|
||||
• Soft-launch на 3–5 доверенных tenant'ах
|
||||
│
|
||||
▼
|
||||
[PUBLIC LAUNCH]
|
||||
```
|
||||
|
||||
**Параллельная линия (Track C, external):** регистрация ООО → Б-1 / Прил. Ж юр. оферта + Политика конфиденциальности / Прил. З уведомление в РКН (152-ФЗ). Триггеры для Sprint 7 / 8 footer / 9 pre-launch.
|
||||
|
||||
**Deferred / non-blocking:**
|
||||
|
||||
- Plan 3 Tasks 1–2 (CSV discovery) — BLOCKED credentials заказчика, не на критическом пути к launch'у
|
||||
- 7 новых Биз-25..31 (от Plan 4) — pre-launch review
|
||||
- 5 🟦 структурных вопросов реестра — ждут заказчика
|
||||
- OPEN-FE-1 (Histoire ↔ Vite 8) — non-blocking, ждёт релиз Histoire с peerDep `vite ^8`
|
||||
|
||||
## 3. Definition of done public launch
|
||||
|
||||
- Приложение развёрнуто в Yandex Cloud (compute + Managed PG 16 + Managed Redis + Object Storage + Container Registry)
|
||||
- Домен `liderra.ru` + SSL активны, `*.liderra.ru` wildcard для subdomain'ов
|
||||
- SSO Yandex 360 для saas-admin работает, `/api/admin/*` без auth = 401
|
||||
- 0 known P0 / P1 в реестре; audit O-* закрыт или явно отложен с условием снятия
|
||||
- Phase 3 tooling активен: Semgrep + Trivy + Dependabot + pg_audit + pg_anonymizer
|
||||
- Pa11y на live URL = 0 violations
|
||||
- Sentry self-hosted принимает события из prod
|
||||
- Backup restore проверен на staging (drill пройден)
|
||||
- Smoke на `staging.liderra.ru` зелёный: 5 ключевых flow (login → создать deal → import webhook → оплатить → отчёт)
|
||||
- Soft-launch 14 дней без P0 на 3–5 tenant'ах
|
||||
|
||||
## 4. Критический путь и зависимость от Б-1
|
||||
|
||||
**Если Б-1 закрыт до конца Sprint 6:**
|
||||
|
||||
```
|
||||
Plan 5 → Sprint 5 → 6 → 7 → 8 → 9 → launch
|
||||
~8–11 недель от Plan 5 finish (включая 2 недели soft-launch)
|
||||
```
|
||||
|
||||
**Если Б-1 задерживается:**
|
||||
|
||||
```
|
||||
Plan 5 → Sprint 5 → 6 → [пауза, ожидание Б-1] → 7 → 8 → 9 → launch
|
||||
Track A автономен ~2 недели; затем упор в Б-1
|
||||
```
|
||||
|
||||
**Жёсткие зависимости:**
|
||||
|
||||
- Sprint 7 ⇐ Б-1 (нельзя купить YC compute / Managed PG без реквизитов ООО)
|
||||
- Sprint 9 ⇐ Sprint 7 (pg_audit + pg_anonymizer только на Managed PG)
|
||||
- Sprint 9 ⇐ Прил. Ж + Прил. З (нельзя запустить без РКН-уведомления и оферты)
|
||||
- Sprint 8 footer ⇐ Прил. Ж (оферта в подвале лендинга)
|
||||
|
||||
**Мягкие зависимости:**
|
||||
|
||||
- Sprint 6 после Sprint 5 (Semgrep clean полезен до расширения API)
|
||||
- YC quota application можно подать параллельно со Sprint 6 (до Sprint 7 старта)
|
||||
|
||||
## 5. Риски одной строкой
|
||||
|
||||
| Риск | Mitigation |
|
||||
|---|---|
|
||||
| Б-1 затягивается на 2+ месяца | Track A автономен 2 спринта (5+6); затем заказчик решает: ждать / перейти на ИП / физлицо |
|
||||
| Юрист задерживает Прил. Ж / Прил. З | Заказчик ставит юристу deadline под закрытие Sprint 7 |
|
||||
| YC quota / verification нового аккаунта долгий | Заявка подаётся параллельно со Sprint 6 |
|
||||
| Pa11y на live находит violations (CDN / fonts) | Включить Pa11y в Sprint 9 acceptance до soft-launch |
|
||||
| Pre-launch security review (Semgrep + Trivy + manual) находит P0 | Sprint 5 / 9 ловят 80% заранее; запас 1 неделя в Sprint 9 |
|
||||
| Soft-launch выявляет UX-проблему | Плановое: 2 недели запас |
|
||||
| Plan 5 finish задерживается | Sprint 5 не стартует пока Plan 5 не на main; план compose'нут так что Plan 5 = pre-requisite |
|
||||
|
||||
## 6. Что вне scope этого birdseye
|
||||
|
||||
- Детализация Sprint-задач до Task-level — см. v1.0 deep-reference или конкретные sprint-plans в `docs/superpowers/plans/`
|
||||
- Plan 6+ supplier integration (если потребуются новые supplier-фичи поверх Plan 4) — отдельный brainstorm по запросу
|
||||
- 5 🟦 структурных вопросов реестра — ждут заказчика
|
||||
- Post-launch фичи (новые модули CRM, аналитика второго порядка, мобильное приложение)
|
||||
- «Refactor ради рефактора» вне списка O-*
|
||||
|
||||
## 7. История версий
|
||||
|
||||
- **v1.0 от 11.05.2026** — birdseye обзор post-Plan 5 до public launch. Линейная лента Sprint 5 → 6 → 7 → 8 → 9 → soft-launch → public launch. Учитывает закрытые Sprint 0 / Sprint 4 и факт supplier-линии Plans 1–5. Базовый HEAD `48f27b4`.
|
||||
@@ -1,251 +0,0 @@
|
||||
# Dev Element Indices — design
|
||||
|
||||
**Дата:** 2026-05-12
|
||||
**Статус:** утверждено заказчиком в обсуждении (12.05.2026 ночь)
|
||||
**Контекст:** Расширение существующего `DevIndexBadge` (route-level, 28 индексов) до per-element покрытия по всему порталу. Цель — чтобы заказчик в feedback ссылался на конкретный элемент по числу («1030 измени цвет», «543 поменяй текст», «2386 удали»), а Claude однозначно знал, на что указывают.
|
||||
|
||||
## 1. Требования
|
||||
|
||||
### 1.1. Функциональные
|
||||
|
||||
- На **каждом** DOM-элементе в `.vue` шаблонах есть атрибут `data-dx="<N>"` в dev/staging.
|
||||
- При hover мыши показывается badge с номером самого глубокого элемента под курсором + краткой меткой (tag + text snippet).
|
||||
- Клавиши `Alt+↑` / `Alt+↓` переключают badge на родителя / первого ребёнка с `data-dx`.
|
||||
- `Alt+Shift+I` — toggle режим: все индексы видны overlay'ем одновременно (мини-badges 8px в углу каждого элемента).
|
||||
- Click по badge — копирует `#<N>` в clipboard.
|
||||
- `Esc` — скрывает текущий badge и выключает overlay-режим.
|
||||
|
||||
### 1.2. Стабильность ID
|
||||
|
||||
- ID, присвоенный элементу, **не меняется** при:
|
||||
- Реордере соседей **другого** тега
|
||||
- Добавлении / удалении соседей **другого** тега
|
||||
- Изменении классов / стилей / атрибутов, не входящих в signature
|
||||
- Минорных правках текста (signature использует первые 24 символа)
|
||||
- ID **может** измениться при:
|
||||
- Перемещении элемента в другой parent / другой файл
|
||||
- Реордере элементов **того же** тега в одном parent (изменение ordinal-among-same-tag)
|
||||
- Полной переписке текста, если текст был единственным distinctive признаком
|
||||
- **Удалённые ID не переиспользуются** — заносятся в tombstone-секцию manifest'а.
|
||||
|
||||
### 1.3. Production safety
|
||||
|
||||
- Plugin не запускается при `NODE_ENV === 'production'`.
|
||||
- `<DevIndexOverlay>` обёрнут в `v-if="import.meta.env.DEV"` → tree-shake'ится production-bundle'ом.
|
||||
- Ноль `data-dx` атрибутов в production HTML. Ноль bytes overhead в production JS.
|
||||
|
||||
### 1.4. Доступность manifest для Claude
|
||||
|
||||
- `dev-indices.json` commit'ится в репо.
|
||||
- Claude читает manifest через `Read` / `Grep` tools — мгновенный lookup `1030 → file:line:tag:text`.
|
||||
- Дополнительно: CLI `npm run dx <id>` для CLI-lookup.
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
Две независимые подсистемы:
|
||||
|
||||
### 2.1. Build-time: Vite plugin
|
||||
|
||||
**Файлы:** `app/vite-plugins/dev-indices/{index,signature,manifest}.ts`
|
||||
|
||||
**Hook:** `transform()` для `*.vue` источников. Использует `@vue/compiler-sfc` для извлечения `<template>` и `@vue/compiler-dom` для AST-walk'а.
|
||||
|
||||
**Алгоритм на каждый build / HMR-update:**
|
||||
|
||||
1. При старте Vite — однократно загрузить `dev-indices.json` в память (cached map: `signature → id` + `id → entry`).
|
||||
2. Для каждого `.vue` файла, для каждого element-node в template:
|
||||
- Вычислить structural signature (см. §3).
|
||||
- Если signature есть в cache → переиспользовать ID.
|
||||
- Иначе → assign `manifest.lastId + 1`, добавить entry в cache.
|
||||
- Инжектировать `data-dx="<N>"` как обычный атрибут в node.
|
||||
3. По окончании transform'а — atomic write обновлённого manifest на диск (через temp-file + rename).
|
||||
|
||||
**Активация:**
|
||||
|
||||
```
|
||||
plugin enabled ⇔ (NODE_ENV !== 'production') && (VITE_DEV_INDICES !== '0')
|
||||
```
|
||||
|
||||
`VITE_DEV_INDICES=0` — явное отключение (для случаев когда нужен «чистый» dev-build без attributes).
|
||||
|
||||
### 2.2. Runtime: DevIndexOverlay
|
||||
|
||||
**Файлы:** `app/resources/js/components/DevIndexOverlay.vue`, `app/resources/js/composables/useDevIndices.ts`
|
||||
|
||||
**Mount:** в `App.vue` под `v-if="import.meta.env.DEV"`.
|
||||
|
||||
**Состояние (composable `useDevIndices`):**
|
||||
|
||||
- `currentTarget: HTMLElement | null` — текущий выделенный элемент
|
||||
- `currentId: number | null` — его `data-dx`
|
||||
- `overlayMode: boolean` — режим «show all»
|
||||
- `hoverEnabled: boolean` — выключается на время Alt+стрелка / клик
|
||||
|
||||
**События:**
|
||||
|
||||
- `mousemove` (throttle 16ms) → `document.elementFromPoint(x, y)` → ближайший ancestor с `data-dx` → `currentTarget`.
|
||||
- `keydown Alt+ArrowUp` → walk parent chain, найти ближайший ancestor с `data-dx` → `currentTarget`. Пауза hover'а на 800ms (чтобы случайное движение мыши не сбросило выбор).
|
||||
- `keydown Alt+ArrowDown` → walk first-descendant chain (DFS), найти ближайший потомок с `data-dx`.
|
||||
- `keydown Alt+Shift+I` → `overlayMode = !overlayMode`.
|
||||
- `click` по badge → `navigator.clipboard.writeText('#' + currentId)` + кратко мигнуть зелёным.
|
||||
- `keydown Escape` → `currentTarget = null; overlayMode = false`.
|
||||
|
||||
**Рендер:**
|
||||
|
||||
- Default mode: floating badge возле курсора (или возле currentTarget при Alt-navigation). Содержание: `#1030 · v-btn · "Создать"` (truncate text до 24 char).
|
||||
- Overlay mode: для каждого `[data-dx]` элемента — мини-badge 8px в верхнем-левом углу через absolute positioning.
|
||||
|
||||
**Стили:** Forest palette (`--ld-teal: #0F6E56` фон, `#fff` текст, JetBrains Mono 11px). Консистентно с существующим `DevIndexBadge`.
|
||||
|
||||
## 3. Signature
|
||||
|
||||
```
|
||||
signature = `${normalizedPath}::${ancestorTagChain}::${tagName}[${distinctiveAttrs}]::${textSnippet24}::${ordinalAmongSameTag}`
|
||||
```
|
||||
|
||||
**Компоненты:**
|
||||
|
||||
- `normalizedPath` — относительный путь от `app/resources/js/`, без расширения. Пример: `components/AppSidebar`.
|
||||
- `ancestorTagChain` — chain тегов родителей в шаблоне, не включая корень компонента. Пример: `nav>v-list-item`. Slot-content считается принадлежащим объявляющему компоненту (chain строится от template-root объявляющего файла, не от `<slot>`-сайта потребителя) — это держит signature стабильным при перемещении компонента между потребителями.
|
||||
- `tagName` — имя тега: `v-btn`, `div`, `span`, etc.
|
||||
- `distinctiveAttrs` — `key=plus,role=button` (только атрибуты из allowlist: `key`, `id`, `name`, `type`, `icon`, `role`, `data-dev-name`). Style/class не входят. **Только статические значения** (string-literal); связанные `:icon="x"` / `v-bind` игнорируются (значение неизвестно на build-time).
|
||||
- `textSnippet24` — первые 24 символа **статического** текстового содержимого элемента, trim'нутые, lowercase. Mustache-выражения `{{ count }}` и v-text атрибуты игнорируются (значение runtime). Пустая строка если все text-children динамические или их нет.
|
||||
- `ordinalAmongSameTag` — порядковый номер среди соседей **того же тега** в том же parent'е. 0-based.
|
||||
|
||||
**Escape-hatch:** атрибут `data-dev-name="<unique-slug>"` в исходнике делает signature = `${normalizedPath}::data-dev-name::${slug}`. ID привязывается к slug, переживает любые структурные изменения внутри файла.
|
||||
|
||||
## 4. Manifest schema
|
||||
|
||||
**Файл:** `app/dev-indices.json` (commit'ится в репо, ~3.5 МБ при 30k entries).
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./dev-indices.schema.json",
|
||||
"version": 1,
|
||||
"lastId": 5832,
|
||||
"entries": {
|
||||
"1030": {
|
||||
"file": "resources/js/components/AppSidebar.vue",
|
||||
"line": 42,
|
||||
"tag": "v-btn",
|
||||
"parentChain": ["AppSidebar", "nav", "v-list-item"],
|
||||
"signature": "components/AppSidebar::nav>v-list-item::v-btn[icon=plus]::создать::0",
|
||||
"text": "Создать проект",
|
||||
"key": null,
|
||||
"ref": null,
|
||||
"createdAt": "2026-05-12T19:00:00Z"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"1029": {
|
||||
"lastSignature": "components/AppSidebar::nav::v-btn[icon=user]::профиль::0",
|
||||
"lastFile": "resources/js/components/AppSidebar.vue",
|
||||
"deletedAt": "2026-05-13T10:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`dev-indices.schema.json`** — JSON Schema для валидации manifest'а на pre-commit.
|
||||
|
||||
**Tombstones:** удалённые ID не переиспользуются. Если элемент удалён → entry перемещается в `deleted`. `lastId` продолжает расти монотонно.
|
||||
|
||||
## 5. CLI: `npm run dx <id>`
|
||||
|
||||
**Файл:** `app/scripts/dev-indices-lookup.mjs`
|
||||
|
||||
Читает `dev-indices.json`, печатает entry в человекочитаемом виде:
|
||||
|
||||
```
|
||||
$ npm run dx 1030
|
||||
#1030 → resources/js/components/AppSidebar.vue:42
|
||||
v-btn[icon=plus] "Создать проект"
|
||||
parent: AppSidebar > nav > v-list-item
|
||||
created: 2026-05-12
|
||||
```
|
||||
|
||||
Если ID в tombstones — печатает `deleted at <date>, last seen in <file>` с подстановкой реальных значений.
|
||||
|
||||
## 6. Изменения файлов
|
||||
|
||||
**Новые:**
|
||||
|
||||
- `app/vite-plugins/dev-indices/index.ts` — plugin core (~300 строк)
|
||||
- `app/vite-plugins/dev-indices/signature.ts` — signature computation (~100 строк)
|
||||
- `app/vite-plugins/dev-indices/manifest.ts` — JSON IO + tombstones (~150 строк)
|
||||
- `app/vite-plugins/dev-indices/__tests__/signature.test.ts` — unit tests
|
||||
- `app/vite-plugins/dev-indices/__tests__/manifest.test.ts` — unit tests
|
||||
- `app/vite-plugins/dev-indices/__tests__/integration.test.ts` — fixture-based
|
||||
- `app/dev-indices.json` — manifest (commit, ~3.5 МБ финально)
|
||||
- `app/dev-indices.schema.json` — JSON Schema
|
||||
- `app/resources/js/components/DevIndexOverlay.vue` — runtime UI (~250 строк)
|
||||
- `app/resources/js/composables/useDevIndices.ts` — state bus (~80 строк)
|
||||
- `app/scripts/dev-indices-lookup.mjs` — CLI
|
||||
|
||||
**Изменённые:**
|
||||
|
||||
- `app/vite.config.ts` — регистрация плагина с dev-guard
|
||||
- `app/resources/js/App.vue` — `<DevIndexOverlay v-if="$dev" />`
|
||||
- `app/package.json` — `"dx": "node scripts/dev-indices-lookup.mjs"`
|
||||
|
||||
**Без изменений:**
|
||||
|
||||
- Существующий `app/resources/js/components/DevIndexBadge.vue` — остаётся как есть (route-level overlay в углу страницы, дополняет per-element overlay, не дублирует).
|
||||
- Существующий `route.meta.devIndex` (1-28) — остаётся, не конфликтует (separate namespace).
|
||||
|
||||
## 7. Testing strategy
|
||||
|
||||
### 7.1. Unit (Vitest)
|
||||
|
||||
- `signature.test.ts`:
|
||||
- Same template → same signature (idempotent).
|
||||
- Реордер разных тегов → signature не меняется.
|
||||
- Реордер одинаковых тегов → ordinal меняется.
|
||||
- `data-dev-name` overrides structural signature.
|
||||
- Минорные правки текста (внутри первых 24 char) не меняют signature.
|
||||
- `manifest.test.ts`:
|
||||
- Read / write roundtrip.
|
||||
- `lastId` инкрементируется монотонно.
|
||||
- Tombstones: удалённый entry не переиспользует ID.
|
||||
- Schema validation на изменённом manifest'е.
|
||||
|
||||
### 7.2. Plugin integration
|
||||
|
||||
- `integration.test.ts`:
|
||||
- Fixture `.vue` файл → run plugin → assert injected `data-dx` атрибуты.
|
||||
- Assert manifest entries матчат ожидаемые signature.
|
||||
- Повторный run на том же файле → ID идентичны.
|
||||
|
||||
### 7.3. E2E (Pest browser / Playwright — на усмотрение в плане)
|
||||
|
||||
- Hover element → badge появляется.
|
||||
- `Alt+↑` → переход к parent badge.
|
||||
- Click → clipboard содержит `#<N>`.
|
||||
- `Alt+Shift+I` → все элементы получают мини-badges.
|
||||
- `Esc` → overlay скрывается.
|
||||
|
||||
## 8. Производительность
|
||||
|
||||
- **Plugin run time:** ~500ms-2s на full build для ~80 `.vue` файлов (один раз, при старте Vite). HMR incremental — ~20ms на изменение одного файла.
|
||||
- **Manifest size:** ~3.5 МБ JSON при ~30k entries. Парсится один раз при старте plugin'а, держится в памяти.
|
||||
- **Browser overlay:** mousemove throttled 16ms; `elementFromPoint` O(1); re-render только при смене целевого элемента (не каждое движение мыши).
|
||||
- **Production bundle:** ноль overhead — `import.meta.env.DEV` гарантирует tree-shake.
|
||||
|
||||
## 9. Известные ограничения (осознанно принимаем)
|
||||
|
||||
1. **Реордер одинаковых тегов сдвигает ID.** 3 `<v-btn>` подряд → IDs 1030, 1031, 1032. Поменял местами 1031↔1032 → ID меняются местами. **Mitigation:** добавлять `data-dev-name` на важные кнопки.
|
||||
2. **Manifest merge-конфликты при параллельных PR.** Оба бранча добавляют новые IDs → конфликт в `entries` + `lastId`. **Решение на v1:** manual resolve через `npm run dev-indices:rebuild` (опционально lefthook hook). На v2 — auto-rebase pre-merge.
|
||||
3. **Динамические v-for с переменным количеством детей.** Plugin видит только статический template — runtime-добавленные элементы получают ID того template-узла, который их генерирует. Все 20 строк таблицы будут иметь ОДИН `data-dx` на `<v-data-table>` элемент. Это известное ограничение build-time подхода. **Mitigation в overlay:** badge показывает «#1030 · v-data-table-row [item 5/20]» с индексом ряда из dataset.
|
||||
|
||||
## 10. Out-of-scope (YAGNI v1)
|
||||
|
||||
- Filter overlay по типу тега («показать только кнопки»).
|
||||
- Diff manifest между ветками.
|
||||
- Auto-suggest ID при добавлении элемента до commit.
|
||||
- UI для bulk-rename / migration ID при крупном рефакторинге (решается через `data-dev-name`).
|
||||
- Дублирующий overlay для Histoire (plugin сработает автоматически на её dev-server, отдельный UI не нужен).
|
||||
- Включение plugin в staging-build по умолчанию (явный opt-in через `VITE_DEV_INDICES=1` при необходимости).
|
||||
|
||||
## 11. Открытые вопросы (для плана)
|
||||
|
||||
Нет открытых вопросов на момент утверждения дизайна. Дизайн утверждён в полной форме.
|
||||
@@ -1,361 +0,0 @@
|
||||
# Portal Redesign — Quiet Luxury (Forest extended)
|
||||
|
||||
**Дата:** 2026-05-12
|
||||
**Статус:** Approved (через `superpowers:brainstorming`)
|
||||
**Целевая ветка реализации:** `plan5-frontend-projects` (текущая) → план реализации создаётся отдельно через `superpowers:writing-plans`
|
||||
**Источники истины:**
|
||||
|
||||
- Бренд: `liderra_v8_handoff/docs/BRANDBOOK_v2.md` (v2 Forest) — hard-override на цвет/шрифт/иконографию/aesthetic (PSR_v1 R6.1)
|
||||
- Тулчейн: `docs/Tooling_v8_3.md` Прил. Н v1.16
|
||||
- Правила: `docs/Pravila_raboty_Claude_v1_1.md` v1.11, `docs/Plugin_stack_rules_v1.md` v2.0
|
||||
- Текущая палитра/тема: `app/resources/js/plugins/vuetify.ts`
|
||||
- Статус-слаги: `db/schema.sql` (таблица `lead_statuses`, 14 строк)
|
||||
|
||||
---
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Превратить интерфейс Лидерры из функционального Vuetify-дефолта в **узнаваемо «дорогой и технологичный»** портал, **не утомляющий при 8-часовой работе**. Без перестройки IA: 28 существующих view'ов, 3 layout'а, маршруты, store, фичи — все остаются. Меняется только визуальный слой: токены, типографика, shell, components defaults, status-pills, motion.
|
||||
|
||||
---
|
||||
|
||||
## 2. Эстетическое направление
|
||||
|
||||
**Quiet Luxury** (направление A из брейншторминга) с точечным заимствованием Pro Console (направление B) для sidebar/cmdk. Editorial Bento (C) отложен до post-MVP для Reports/Dashboard.
|
||||
|
||||
| Принцип | Реализация |
|
||||
|---|---|
|
||||
| Whitespace > graphics | 4pt grid, 8 spacing-токенов, page-padding 24–28px, row-height 44px |
|
||||
| Типографика — главный декор | Inter (variable, axis `opsz` 14..32) + JetBrains Mono с `tnum` для всех цифр/ID/timestamp/телефонов |
|
||||
| Один акцент на экран | Только primary-CTA + активный статус в основном цвете Teal `#0F6E56` |
|
||||
| Двухтоновый shell | Sidebar теало-нуар `#012019` + main ivory `#F6F3EC` (уже намечено в Brandbook) |
|
||||
| Motion = смысл | 7 паттернов, длительность 200–600ms, easing `cubic-bezier(0.16,1,0.3,1)`, обязательный `prefers-reduced-motion` wrapper |
|
||||
| WCAG AA | Контраст ≥ 4.5 на body, ≥ 3.0 на large; проверяется Pa11y в CI |
|
||||
|
||||
---
|
||||
|
||||
## 3. Палитра — Forest, расширенная (12 токенов)
|
||||
|
||||
Базовая палитра Brandbook v2 + 5 расширений для data viz и тонких поверхностей.
|
||||
|
||||
| Токен | HEX | Использование |
|
||||
|---|---|---|
|
||||
| `--liderra-teal` | `#0F6E56` | Primary CTA, активные элементы, status `new`/`postponed`, акценты |
|
||||
| `--liderra-teal-deep` | `#0A5A47` | Hover/pressed state primary, gradient sidebar bottom |
|
||||
| `--liderra-noir` | `#012019` | Sidebar, pill `archived`, modal overlay base |
|
||||
| `--liderra-ivory` | `#F6F3EC` | Page background, sidebar text, surface accent |
|
||||
| `--liderra-surface` | `#FFFFFF` | Cards, table, drawer, input bg |
|
||||
| `--liderra-muted` | `#6B6356` | Secondary text, captions, placeholders, label uppercase |
|
||||
| `--liderra-success` | `#2E8B57` | Status `quality`/`won`, delta positive |
|
||||
| `--liderra-saffron` | `#D9A441` | Status `callback`, channel CALL, warning |
|
||||
| `--liderra-error` | `#B83A3A` | Status `junk`, delta negative, destructive |
|
||||
| `--liderra-info` | `#3F7C95` | Status `in_progress`, channel SITE, info banner |
|
||||
| `--liderra-plum` | `#7A5BA3` | Status `meeting_set`, channel SMS, data viz |
|
||||
| `--liderra-salmon` | `#CC6E50` | Status `refund`, data viz accent |
|
||||
|
||||
**Тонкие поверхности:**
|
||||
|
||||
- `--liderra-line: rgba(1,32,25,0.08)` — стандартные разделители
|
||||
- `--liderra-line-strong: rgba(1,32,25,0.14)` — фокус-границы input, hover-border на чипах
|
||||
|
||||
**Vuetify theme:** перечисленные токены маппятся в `liderraForest` ThemeDefinition. Раздел `colors` файла [`app/resources/js/plugins/vuetify.ts`](../../../app/resources/js/plugins/vuetify.ts) расширяется до 12 ключей; CSS vars (`--liderra-*`) объявляются в одном корневом файле `app/resources/css/tokens.css` (новый).
|
||||
|
||||
---
|
||||
|
||||
## 4. Типографика
|
||||
|
||||
**Inter** (variable, `opsz` 14..32) — UI/контент. **JetBrains Mono** (с feature `tnum`) — цифры, ID, timestamp, телефоны, коды.
|
||||
|
||||
| Уровень | Размер / Высота строки / Вес / Tracking | Применение |
|
||||
|---|---|---|
|
||||
| Label-XS | 11 / 14 / 500 / +0.10em / uppercase | Колонки таблиц, labels форм, breadcrumb-меток |
|
||||
| Body-S | 13 / 20 / 400 / 0 | Текст таблиц, форм, описаний (база) |
|
||||
| Body-M | 15 / 22 / 500 / 0 | Активные пункты sidebar, заголовки секций |
|
||||
| Heading-3 | 17 / 24 / 600 / -0.01em | Карточки, заголовки модалок |
|
||||
| Heading-2 | 22 / 28 / 600 / -0.015em | Заголовок страницы (Сделки, Биллинг, и т.д.) |
|
||||
| Heading-1 | 28 / 36 / 600 / -0.02em | Dashboard, Reports |
|
||||
| Hero-Display | `clamp(30px, 5vw, 48px)` / 600 / -0.025em | Только эмпти-стейты и landing-секции в портале (если будут) |
|
||||
| Mono-XL | 28 (JetBrains) / -0.02em / 500 / `tnum` | Большие KPI-числа, балансы |
|
||||
| Mono-S | 11 (JetBrains) / `tnum` | Timestamp, ID `#LDR-2841-X`, телефоны |
|
||||
|
||||
**font-feature-settings:** `'tnum' 1, 'cv11' 1` для Inter; `'tnum' 1` для JetBrains Mono.
|
||||
|
||||
Vuetify font defaults меняются через `defaults.global.style` + добавление SCSS overrides в `app/resources/css/typography.css` (новый файл).
|
||||
|
||||
---
|
||||
|
||||
## 5. Сетка и плотность
|
||||
|
||||
**База:** 4pt grid. **8 spacing-токенов:** `4 / 8 / 12 / 16 / 24 / 32 / 48 / 64` (CSS vars `--space-1..16`).
|
||||
|
||||
**Density:**
|
||||
|
||||
- **Comfortable (default):** row-height 44px, card-padding 16–18px, button-height 38px.
|
||||
- **Compact (toggle):** row-height 36px, card-padding 12px, button-height 32px. Доступен в Сделках/Канбане/Отчётах через `density-toggle` в filterbar; сохраняется в `localStorage['liderra:density']`.
|
||||
|
||||
**Контент-ширина:** max 1440px, центрируется в main.
|
||||
|
||||
**Радиусы:** 6 (мелкие чипы) / 8 (кнопки, inputs, чипы) / 10 (стенды на ivory) / 12 (карточки, таблицы) / 14 (drawer/modal) / 999 (pills, avatars).
|
||||
|
||||
**Тени:** двухслойные ambient + key.
|
||||
|
||||
- `--shadow-1: 0 1px 2px rgba(1,32,25,0.04)` — карточки/таблицы по умолчанию
|
||||
- `--shadow-2: 0 4px 12px rgba(1,32,25,0.06), 0 1px 2px rgba(1,32,25,0.04)` — hover lift на карточках
|
||||
- `--shadow-3: 0 12px 28px rgba(1,32,25,0.10)` — модалки, всплывающие меню
|
||||
- `--shadow-4: 0 24px 48px rgba(1,32,25,0.16)` — drawer, command palette
|
||||
|
||||
---
|
||||
|
||||
## 6. Layout — двухтоновый shell
|
||||
|
||||
| Зона | Цвет | Размер | Содержание |
|
||||
|---|---|---|---|
|
||||
| Sidebar | gradient `#012019 → #04261E` | 232px шир. | Brand + avatar, ⌘K stub, nav-groups (Работа / Аналитика / Профиль), nav-items с badge-счётчиками |
|
||||
| Topbar | ivory | 56px выс., нижняя граница `--line` | Breadcrumb (JetBrains Mono Label-XS) + utility-buttons (notifications, user-menu) |
|
||||
| Main | ivory | flex-1 | Page-head + KPI strip (если есть) + filter bar + content (table/cards/canvas) |
|
||||
|
||||
**Sidebar — детали:**
|
||||
|
||||
- Nav-item active: фон `rgba(15,110,86,0.22)`, текст `#F6F3EC`, левая вертикальная полоса 2px Teal `#0F6E56` (motion #7 — `transform: scaleY(0→1)` 250ms ease-out при смене раздела).
|
||||
- Badge: `font-family: JetBrains Mono`, `font-size: 10px`, `background: rgba(255,255,255,0.08)`.
|
||||
- Group-label: `font-size: 10px`, `text-transform: uppercase`, `letter-spacing: 0.14em`, `color: #6B7470`.
|
||||
- ⌘K stub: `background: rgba(255,255,255,0.06)`, hover до `rgba(255,255,255,0.10)`. В Iteration 1 это stub без real-search; в Iteration 2 — Cmdk.
|
||||
|
||||
**AppLayout/AuthLayout/AdminLayout:** архитектура без изменений. AdminLayout получает теало-нуар sidebar тоже, но nav-group метка «SaaS-уровень»; AuthLayout без sidebar.
|
||||
|
||||
---
|
||||
|
||||
## 7. Компоненты — Vuetify defaults override
|
||||
|
||||
Глобальный override через `createVuetify({ defaults: { ... } })` + кастомные SCSS-фрагменты, где Vuetify не покрывает.
|
||||
|
||||
### 7.1. Buttons (3 уровня)
|
||||
|
||||
| Уровень | Variant | Цвет | Hover |
|
||||
|---|---|---|---|
|
||||
| Primary | `flat` | bg Teal, text white | bg Teal-deep + `translateY(-1px)` + Teal-glow shadow |
|
||||
| Secondary | `outlined` | text default, border `--line` | border `--line-strong`, bg `rgba(1,32,25,0.04)` |
|
||||
| Ghost | `text` | text Muted | text default, bg `rgba(1,32,25,0.04)` |
|
||||
|
||||
Height: 38px comfortable / 32px compact. Padding: 14px L/R. Radius: 8.
|
||||
|
||||
### 7.2. Inputs (VTextField, VTextarea)
|
||||
|
||||
- Variant: `outlined`
|
||||
- Density: `comfortable` default
|
||||
- Border: `--line` resting, `--liderra-teal` focus + 3px focus-ring `rgba(15,110,86,0.10)`
|
||||
- Radius: 8
|
||||
- Font-size: 13 (Body-S)
|
||||
- Error: border `--liderra-error`, helper-text Error
|
||||
- Success: border `--liderra-success` (только для validated state, не сразу при mount)
|
||||
|
||||
### 7.3. Cards (VCard)
|
||||
|
||||
- Default: `variant: 'flat'`, `border: 1px solid --line`, `border-radius: 12`, `padding: 16`
|
||||
- Hover (если интерактивная): `translateY(-2px)` 200ms ease-out + `--shadow-2`
|
||||
|
||||
### 7.4. Chips & Pills (VChip)
|
||||
|
||||
- Status pill: `border-radius: 999`, `padding: 3px 9px`, `font-size: 11`, `font-weight: 500`. Цветовая карта — см. §8.
|
||||
- Filter chip (в filterbar): `border-radius: 8`, `padding: 7px 12px`, `font-size: 12`, `background: --liderra-surface`, `border: 1px solid --line`. Active: Teal border + Teal-tinted bg.
|
||||
|
||||
### 7.5. Tables (VDataTable)
|
||||
|
||||
- Header: `height: 38px`, `background: rgba(1,32,25,0.025)`, Label-XS uppercase, `letter-spacing: 0.12em`.
|
||||
- Row: `height: 44/36 px` (density), `border-bottom: 1px solid --line`.
|
||||
- Hover: `background: rgba(15,110,86,0.03)`, more-actions menu opacity 0→1 200ms.
|
||||
- Selected: `background: rgba(15,110,86,0.06)` + checkbox Teal-filled.
|
||||
- Stagger animation на mount (motion #2, см. §9).
|
||||
|
||||
### 7.6. Dialogs & Drawers
|
||||
|
||||
- VDialog: `border-radius: 14`, `--shadow-3`, overlay `rgba(1,32,25,0.32)`. Mount-анимация: scale 0.94→1 + 8px translateY, 350ms spring `cubic-bezier(0.34,1.4,0.64,1)`.
|
||||
- VNavigationDrawer: `border-radius: 14 0 0 14` если right-side, `--shadow-4`.
|
||||
|
||||
### 7.7. Toasts (VSnackbar)
|
||||
|
||||
- Background: `--liderra-noir`, text `--liderra-ivory`, `border-radius: 10`, `--shadow-3`. Mount: slide-up 400ms cubic-bezier.
|
||||
- Variants: default (Teal dot), success (Success dot), error (Error dot), warning (Saffron dot).
|
||||
|
||||
---
|
||||
|
||||
## 8. 14 статус-пилюль (точные slugs из `db/schema.sql`)
|
||||
|
||||
| Slug | bg | text | Особенность |
|
||||
|---|---|---|---|
|
||||
| `new` | `rgba(15,110,86,0.12)` | `#0F6E56` | — |
|
||||
| `in_progress` | `rgba(63,124,149,0.12)` | `#3F7C95` | — |
|
||||
| `callback` | `rgba(217,164,65,0.18)` | `#A07820` | — |
|
||||
| `quality` | `rgba(46,139,87,0.15)` | `#2E8B57` | — |
|
||||
| `meeting_set` | `rgba(122,91,163,0.15)` | `#7A5BA3` | — |
|
||||
| `won` | `rgba(46,139,87,0.22)` | `#1F6940` | `font-weight: 600` |
|
||||
| `refund` | `rgba(204,110,80,0.15)` | `#B0563D` | — |
|
||||
| `duplicate` | `rgba(1,32,25,0.08)` | `#3A3A3A` | — |
|
||||
| `junk` | `rgba(184,58,58,0.10)` | `#B83A3A` | — |
|
||||
| `no_answer` | `rgba(107,99,86,0.15)` | `#6B6356` | — |
|
||||
| `cancelled` | `rgba(107,99,86,0.18)` | `#6B6356` | `text-decoration: line-through` |
|
||||
| `closed` | `rgba(1,32,25,0.10)` | `#3A3A3A` | — |
|
||||
| `postponed` | `rgba(15,110,86,0.06)` | `#6B6356` | — |
|
||||
| `archived` | `#012019` | `#E8E2D4` | Контрастный inverse |
|
||||
|
||||
Маппинг хранится в `app/resources/js/composables/useStatusPill.ts` (новый): функция возвращает `{ bg, color, fontWeight?, textDecoration? }` по slug; используется в `<StatusPill :slug="deal.status" />` (новый компонент, replace text-only chip).
|
||||
|
||||
---
|
||||
|
||||
## 9. Motion — 7 паттернов
|
||||
|
||||
Все паттерны — CSS `@keyframes` + Vue native `<Transition>` + View Transitions API + `prefers-reduced-motion` global wrapper. Без motion-runtime библиотек на Iteration 1 (mot-v добавляется в Iteration 2 только если потребуется FLIP для канбана).
|
||||
|
||||
| # | Имя | Длительность | Easing | Триггер | Применение |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | **Count-up KPI** | 600ms | `cubic-bezier(0.16,1,0.3,1)` (ease-out) | onMount + on value-change >5% | Dashboard, Reports, KPI strip |
|
||||
| 2 | **Stagger list** | 400ms + stagger 50ms | `cubic-bezier(0.16,1,0.3,1)` | onMount only (не на сорт/пагинацию) | Сделки, Reports таблицы |
|
||||
| 3 | **Status pill morph** | 300ms | `cubic-bezier(0.34,1.56,0.64,1)` (bounce on target) | onChange `status` prop | StatusPill компонент |
|
||||
| 4 | **Hover lift** | 200ms | `cubic-bezier(0.16,1,0.3,1)` | `:hover` | Карточки, строки таблиц, primary-кнопки |
|
||||
| 5 | **Skeleton shimmer** | 1400ms loop | `linear` | На время `pending` запроса | Все async-зоны (deals list, dashboard KPI) |
|
||||
| 6 | **Page transition** | 280ms | `cubic-bezier(0.16,1,0.3,1)` | Vue Router beforeEach | Router-view (с fallback на CSS fade-up) |
|
||||
| 7 | **Sidebar active marker** | 250ms | `cubic-bezier(0.16,1,0.3,1)` | onChange active nav-item | Sidebar nav-item active state |
|
||||
|
||||
**Глобальный wrapper** (`app/resources/css/motion.css`):
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Count-up имплементация:** новый composable `useCountUp(targetRef, options)` использует `requestAnimationFrame` для 600ms tween от текущего до target value; учитывает `prefers-reduced-motion` (мгновенный set без анимации).
|
||||
|
||||
**Page transition:** через CSS View Transitions API когда доступно (Chrome 111+, Safari 18+), CSS-fallback на `@keyframes fadeup` для остальных. Активируется через router-meta `transition: 'page'`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Charts — минималистичная data viz
|
||||
|
||||
- Тип по умолчанию: **area** (линия + градиент-заливка `linear-gradient(180deg, --liderra-teal alpha 0.15 → transparent)`) для трендов; **horizontal bar** для рейтингов.
|
||||
- **Нет grid-lines.** Только baseline по X.
|
||||
- Tooltip: card `border-radius: 10`, `background: --liderra-noir`, `color: --liderra-ivory`, `padding: 10 14`, `font-family: JetBrains Mono`, `font-size: 12`, `--shadow-3`.
|
||||
- Palette для категорий: Teal → Saffron → Plum → Salmon → Info (Sea-blue). Никогда не использовать Error/Success в чартах — они для семантики.
|
||||
- Library: `chart.js` (уже в стеке) либо `apexcharts` — решение в плане реализации (после оценки текущей зависимости).
|
||||
|
||||
---
|
||||
|
||||
## 11. Scope первой итерации vs Iteration 2
|
||||
|
||||
### Iteration 1 (текущая работа)
|
||||
|
||||
1. CSS-токены (`tokens.css`) — 12 цветов + 8 spacing + 6 радиусов + 4 shadow
|
||||
2. Vuetify theme — расширение `liderraForest` до 12 ключей + global defaults для VBtn/VTextField/VCard/VChip/VDataTable
|
||||
3. Типографика — `typography.css` + подключение Inter variable (`opsz` axis) + JetBrains Mono `tnum`
|
||||
4. AppLayout — двухтоновый shell, sidebar с ⌘K stub, nav-groups, badge-счётчики, active marker (motion #7)
|
||||
5. StatusPill компонент + `useStatusPill` composable (14 slugs)
|
||||
6. Filter chip + density toggle компоненты
|
||||
7. Motion-инфраструктура: `motion.css` + `useCountUp` composable + Vue Transition wrappers для page/list
|
||||
8. Применение к 4 ключевым view'ам: Dashboard, Сделки (DealsView), Канбан, Проекты (ProjectsView с Plan 5)
|
||||
|
||||
### Iteration 2 (post-MVP, после стабилизации I1)
|
||||
|
||||
1. ⌘K command palette (real fuzzy-search, маршруты + actions)
|
||||
2. Применение к остальным view'ам (Reports, Billing, Admin/*, Settings/*, Reminders)
|
||||
3. Drag-feedback в Канбане (опционально motion-v FLIP)
|
||||
4. Иллюстрации empty states (SVG)
|
||||
5. Onboarding tour
|
||||
6. Расширенный data viz (heatmap, sparkline в строках таблиц)
|
||||
|
||||
---
|
||||
|
||||
## 12. Файлы — план изменений (high-level)
|
||||
|
||||
Новые:
|
||||
|
||||
- `app/resources/css/tokens.css` — CSS vars (цвета, spacing, radii, shadows)
|
||||
- `app/resources/css/typography.css` — font-family, font-feature-settings, font-imports
|
||||
- `app/resources/css/motion.css` — keyframes + reduced-motion wrapper
|
||||
- `app/resources/js/composables/useStatusPill.ts` — slug → стилевые токены
|
||||
- `app/resources/js/composables/useCountUp.ts` — RAF tween для KPI
|
||||
- `app/resources/js/components/ui/StatusPill.vue`
|
||||
- `app/resources/js/components/ui/FilterChip.vue`
|
||||
- `app/resources/js/components/ui/DensityToggle.vue`
|
||||
- `app/resources/js/components/ui/Kbd.vue` — клавишные стабы (для ⌘K, Esc)
|
||||
|
||||
Изменяемые:
|
||||
|
||||
- `app/resources/js/plugins/vuetify.ts` — расширить theme + defaults
|
||||
- `app/resources/js/layouts/AppLayout.vue` — двухтоновый shell, новый sidebar, ⌘K stub, breadcrumb
|
||||
- `app/resources/js/components/layout/AppSidebar.vue` — nav-groups, badges, active marker (motion #7)
|
||||
- `app/resources/js/views/DashboardView.vue` — KPI count-up (motion #1), live pulse
|
||||
- `app/resources/js/views/DealsView.vue` — filterbar, density toggle, table-stagger (motion #2), hover lift (motion #4)
|
||||
- `app/resources/js/views/KanbanView.vue` — pill цвета, hover lift
|
||||
- `app/resources/js/views/ProjectsView.vue` — карточки в новом стиле
|
||||
|
||||
---
|
||||
|
||||
## 13. Acceptance criteria (Iteration 1)
|
||||
|
||||
- [ ] 12 CSS-токенов определены, все компоненты используют только их (никакого хардкода цветов в `.vue`)
|
||||
- [ ] Inter variable + JetBrains Mono `tnum` подключены, все цифры/ID/timestamp на JetBrains Mono
|
||||
- [ ] AppLayout: sidebar теало-нуар + main ivory, ⌘K stub, nav-groups, badge-счётчики, active marker анимируется
|
||||
- [ ] StatusPill применён ко всем 14 slugs в Сделках, Канбане, Dashboard
|
||||
- [ ] Все 7 motion-паттернов работают; `prefers-reduced-motion` отключает их полностью (проверка в DevTools)
|
||||
- [ ] Density toggle сохраняется в localStorage и применяется к row-height/padding/button-height
|
||||
- [ ] Pa11y в CI — 0 violations на 4 ключевых view'ах (Dashboard, Deals, Kanban, Projects)
|
||||
- [ ] Vitest unit-тесты на `useStatusPill`, `useCountUp`, `StatusPill.vue` (snapshot + 14 slugs)
|
||||
- [ ] Histoire stories для всех новых ui-компонентов
|
||||
- [ ] Lighthouse Performance ≥ 90 (mobile profile)
|
||||
|
||||
---
|
||||
|
||||
## 14. Out of scope
|
||||
|
||||
- Перестройка IA (маршруты, store, deals.* модель, формы добавления сделки).
|
||||
- Backend changes — все 28 ProjectController/DealController/* endpoints остаются.
|
||||
- Schema changes — `lead_statuses` slugs и `projects.signal_type` enum значения не меняются.
|
||||
- Замена иконографии — остаёмся на Lucide (уже в стеке).
|
||||
- Темизация (light/dark toggle) — отложено в Iteration 2.
|
||||
- Cmdk real-search (только stub на I1).
|
||||
- Изменение Plan 5 frontend кода вне UI/UX layer (поведение `ProjectsView`, `projectsStore`, dialog flow — без изменений).
|
||||
|
||||
---
|
||||
|
||||
## 15. Iteration-1 Known Issues / Iteration-2 Backlog
|
||||
|
||||
Captured during Subagent-Driven Development of the Iteration 1 plan (commits `38b985a..<final>`).
|
||||
|
||||
### Functional regressions / gaps to address
|
||||
|
||||
1. **Slug reconciliation (Task 5 finding).** Spec §8 defines 14 design-language slugs (`new`, `in_progress`, `callback`, ...), but `db/schema.sql` `lead_statuses` seed uses different slugs (`viewed`, `worked`, `base`, `missed`, `negotiations`, `waiting_payment`, ...). `StatusPill` falls back to neutral style for unknown slugs — in production, all real deals will hit the fallback. **Resolution path (Iteration 2):** either (a) add a slug-translation map in `useStatusPill`, OR (b) rename `lead_statuses` slugs to match spec, OR (c) update `useStatusPill` STYLES keys to real schema slugs.
|
||||
|
||||
2. **AppSidebar mobile drawer collapse regression (Task 12 finding).** Old `<v-navigation-drawer>` was replaced with plain `<aside>` + `data-open` attribute. `drawerOpen` model still wired, but there's no CSS rule to hide/translate the sidebar when `data-open="false"`. Mobile drawer toggle button has no visual effect.
|
||||
**Resolution path:** add `.ld-sidebar[data-open="false"] { transform: translateX(-100%); transition: transform 280ms; }` OR restore a thin Vuetify wrapper for drawer mechanics.
|
||||
|
||||
3. **DealsView filter chip stubs (Task 15 finding).** 3 redesign FilterChips (Статус/Проект/Менеджер) coexist with existing `DealsFilters` (VSelects). Chip click handlers are `console.log` placeholders. **Resolution:** either wire chips as popover triggers for VSelects, OR remove redesign chiprow if VSelects suffice.
|
||||
|
||||
4. **DealsView status-legend strip (Task 15 finding).** 4 sample StatusPills rendered as visual legend; partially driven by test design (VDataTable stub). **Resolution:** decide whether to keep as UX-meaningful palette legend OR move behind a "show legend" toggle.
|
||||
|
||||
5. **KanbanCard hover overlap (Task 16 finding).** Existing `.kanban-card:hover { transform: translateY(-1px) }` may double up with `.ld-hover-lift:hover { translateY(-2px) }`. **Resolution:** remove inline hover from KanbanCard.vue, let `.ld-hover-lift` own it.
|
||||
|
||||
6. **Token discipline leak — `useStatusPill.ts` hardcodes 14 hex/rgba values (final review finding).** STYLES table duplicates Forest palette values (`#0F6E56`, `rgba(15,110,86,0.12)`, etc.) instead of referencing `tokens.css` (.ts files can't read CSS vars at runtime, but the duplication risks token drift). **Resolution:** add comment cross-referencing tokens.css OR generate STYLES from a shared TS palette module.
|
||||
|
||||
7. **AppSidebar hardcodes sidebar dark-mode palette (final review finding).** `#9B9484`, `#6B7470`, `#B8B0A0`, `#04261E`, `#E8E2D4` are inlined in `AppSidebar.vue` scoped CSS — sidebar-specific dark tokens missing from `tokens.css`. **Resolution:** add `--liderra-sidebar-text`, `--liderra-sidebar-eyebrow`, `--liderra-sidebar-bg-bottom` etc. to tokens.css, OR document as "sidebar-local" with a `/* sidebar dark palette */` comment block.
|
||||
|
||||
8. **DealsView `.page-stats .sep { color: #92907b }` one-off hex (final review finding).** Should be `var(--liderra-muted)` (#6B6356) or a new `--liderra-muted-soft` token.
|
||||
|
||||
9. **Stagger animation breaks past row 11 (final review finding).** `motion.css` `.ld-stagger-row:nth-child(N)` rules cover rows 1–10 only; row 11+ has no animation-delay (instant appearance). Acceptable for pagination ≤10 rows; consider CSS `:nth-child(n+11)` catch-all or JS-driven approach if pagination size increases.
|
||||
|
||||
10. **`useStatusPill` naming suggests reactivity (final review finding).** Function is pure (returns snapshot, not reactive). The `use*` prefix is the Vue Composable convention which typically returns refs. **Resolution:** rename to `getStatusPillStyle` OR add JSDoc clarifying it's a pure mapping function.
|
||||
|
||||
### Test infrastructure / coverage gaps
|
||||
|
||||
1. **Sidebar marker test regex (Task 12 finding).** `KanbanRedesign.spec.ts` regex `/ld-mono|nav-item__badge/` is overly permissive — `nav-item__badge` always matches the selector even if `ld-mono` were dropped. Tighten to `/\bld-mono\b/`.
|
||||
|
||||
2. **No prefers-reduced-motion test for `ld-marker-grow` keyframe (Task 12 finding).** Global motion.css wrapper does target `*::before` selectors, so it IS caught — but no dedicated test asserts the override applies.
|
||||
|
||||
### Skipped in Iteration 1 per plan scope
|
||||
|
||||
1. **Pa11y CI sweep on 4 ключевых view'ах** — current Pa11y config covers static HTML only; needs SPA-mode setup (dev server + URL targets). Iteration 2 task.
|
||||
|
||||
2. **Lighthouse Performance ≥ 90 (mobile)** — not validated in I1.
|
||||
@@ -1,293 +0,0 @@
|
||||
# Projects Bulk Actions — design spec
|
||||
|
||||
**Дата:** 2026-05-12
|
||||
**Статус:** approved by Дмитрий, готов к writing-plans
|
||||
**Контекст:** `/projects` (ProjectsView.vue + ProjectCard.vue #817). Развитие Plan 5 frontend.
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Расширить bulk-операции на странице «Проекты» так, чтобы пользователь мог:
|
||||
|
||||
1. Выбрать **группу** проектов через чекбоксы на карточках (уже есть).
|
||||
2. Выбрать **все** проекты по текущим фильтрам через единый чекбокс над гридом (новое).
|
||||
3. Применить к выбранному набору 4 категории изменений:
|
||||
- Регионы — добавить и/или убрать
|
||||
- Дни сбора лидов — добавить и/или убрать
|
||||
- Лимит лидов в день — изменить (delta или replace)
|
||||
- Сбор лидов вкл/выкл (pause/resume) — уже есть, остаётся
|
||||
|
||||
Существующие bulk-действия (pause/resume/archive) сохраняются — расширяется набор операций и UX.
|
||||
|
||||
## 2. Текущее состояние
|
||||
|
||||
| Артефакт | Что есть | Что меняется |
|
||||
|---|---|---|
|
||||
| `ProjectCard.vue` | v-checkbox с emit `toggle-select` (#817 — обёртка v-card-item) | Без изменений |
|
||||
| `ProjectsView.vue` | Грид карточек + BulkActionsBar при `selectedIds.size > 0` | + toolbar с select-all чекбоксом и счётчиком |
|
||||
| `BulkActionsBar.vue` | 3 кнопки: Приостановить / Возобновить / Архивировать | + 3 кнопки-диалога: Регионы / Дни сбора / Лимит лидов; визуальная группировка по 4 категориям |
|
||||
| `projectsStore.ts` | `selectedIds`, `toggleSelect`, `clearSelection`, `bulkAction(pause/resume/archive)` | + `selectAllByFilter: boolean`, `bulkUpdate(payload)` |
|
||||
| `ProjectController::bulk` | `BulkProjectActionRequest`: action ∈ `pause/resume/archive`, `ids: [int, max 100]` | Расширяется discriminator + payload, scope (ids OR filter) |
|
||||
| `ProjectService::bulkAction` | Простой `Project::whereIn->update(['is_active' => ...])` | Расширяется: bitmask-операции, delta для лимита, валидация |
|
||||
|
||||
## 3. UX
|
||||
|
||||
### 3.1. Toolbar над гридом
|
||||
|
||||
Появляется НАД гридом карточек, ниже фильтров (Тип / Статус / Поиск):
|
||||
|
||||
```
|
||||
[☐] Выбрать все по фильтрам · Выбрано: 0 из 47 (по текущим фильтрам)
|
||||
```
|
||||
|
||||
Состояния чекбокса:
|
||||
|
||||
- `none` — выбрано 0
|
||||
- `partial` (indeterminate) — выбрана часть из видимых
|
||||
- `all` — выбраны все из текущего набора по фильтрам
|
||||
|
||||
Клик при `none` → выбрать все по фильтрам (`selectAllByFilter = true`, `selectedIds` обновляется списком ID для отображения чекбоксов на видимых карточках).
|
||||
Клик при `partial` или `all` → снять выбор (`clearSelection`).
|
||||
|
||||
При смене фильтров — выбор сбрасывается.
|
||||
|
||||
### 3.2. BulkActionsBar (расширенный)
|
||||
|
||||
Появляется снизу при `selectedIds.size > 0` или `selectAllByFilter`. Структура:
|
||||
|
||||
```
|
||||
[Выбрано: 5] | 1. Регионы: [Регионы…] | 2. Дни: [Дни сбора…] | 3. Лимит: [Лимит лидов…] | 4. Сбор: [Выключить] [Включить] | [Архивировать] [Снять выбор]
|
||||
```
|
||||
|
||||
Категории 1-3 открывают диалог Add/Remove (см. §3.3).
|
||||
Категория 4 — два инстант-кнопки без диалога (action: `pause`/`resume`).
|
||||
«Архивировать» — с `window.confirm` (как сейчас).
|
||||
|
||||
### 3.3. Диалоги Add/Remove
|
||||
|
||||
Единый паттерн: одна форма, две секции — «Добавить» (зелёная) и «Убрать» (красная).
|
||||
|
||||
**RegionsBulkDialog:**
|
||||
|
||||
- Два multi-select с региональными chips.
|
||||
- Конфликт «один регион в обеих секциях» — Remove побеждает (валидация на клиенте предупреждает).
|
||||
- Превью: «У 5 проектов будет добавлено: А, Б. Убрано: В.» (без диффа per-project — слишком шумно).
|
||||
|
||||
**DaysBulkDialog:**
|
||||
|
||||
- Две группы из 7 weekday-кнопок (Пн-Вс). Toggle-state.
|
||||
- В секции «Добавить» выбраны дни для OR-маски. В «Убрать» — для AND-NOT.
|
||||
- Превью: «У 5 проектов добавлены: Вт, Чт. Убраны: Сб, Вс.»
|
||||
|
||||
**LimitBulkDialog:**
|
||||
|
||||
- Два числовых input'а: «➕ Прибавить к лимиту» и «➖ Убавить лимит» (можно один, можно оба).
|
||||
- Чекбокс **«Заменить на абсолютное значение»** — переключает в режим replace с одним числовым input'ом.
|
||||
- Превью: «У 5 проектов лимит изменится на +100 (новые значения от 300 до 500).»
|
||||
|
||||
Все три диалога — `<v-dialog>`. Кнопки «Отмена» / «Применить ко всем N».
|
||||
|
||||
### 3.4. Пустые состояния
|
||||
|
||||
- Если ни Add, ни Remove не заполнено в диалоге — кнопка «Применить» disabled.
|
||||
- Если выбрано 0 проектов — BulkActionsBar скрыт.
|
||||
|
||||
## 4. Backend API
|
||||
|
||||
### 4.1. Расширение `POST /api/projects/bulk`
|
||||
|
||||
Discriminator по `action`. Payload-варианты:
|
||||
|
||||
```ts
|
||||
// 1. Instant actions (без диалога) — как сейчас
|
||||
{ action: 'pause' | 'resume' | 'archive', ids: number[] }
|
||||
|
||||
// 2. Regions: Add/Remove (region_mask bitmask с region_mode)
|
||||
{
|
||||
action: 'update_regions',
|
||||
ids: number[],
|
||||
add?: number[], // массив region_id, превращается в bitmask на сервере
|
||||
remove?: number[],
|
||||
}
|
||||
|
||||
// 3. Days: Add/Remove (delivery_days_mask bitmask Пн=1..Вс=64)
|
||||
{
|
||||
action: 'update_days',
|
||||
ids: number[],
|
||||
add?: number, // bitmask 0..127
|
||||
remove?: number,
|
||||
}
|
||||
|
||||
// 4. Limit: delta ИЛИ replace (взаимоисключающие)
|
||||
{
|
||||
action: 'update_limit',
|
||||
ids: number[],
|
||||
delta?: number, // целое (отрицательное = убавить)
|
||||
replace?: number, // абсолютное значение
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2. Scope: ids vs filter
|
||||
|
||||
Любой action может приниматься в одном из двух scope-вариантов:
|
||||
|
||||
```ts
|
||||
// 1. По списку id (ручной выбор)
|
||||
{ ..., ids: [3, 7, 12] }
|
||||
|
||||
// 2. По фильтрам (select-all)
|
||||
{
|
||||
...,
|
||||
scope: {
|
||||
filter: {
|
||||
signal_type?: string,
|
||||
status?: 'active' | 'paused' | 'archived',
|
||||
search?: string,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Сервис резолвит filter → list of ids внутри транзакции (snapshot in time). Лимит **MAX 500** проектов за один bulk-вызов (защита от runaway updates); если фильтр захватывает больше — 422 «Уточните фильтры или выберите вручную».
|
||||
|
||||
### 4.3. Ответ
|
||||
|
||||
```json
|
||||
{ "updated": 5, "skipped": [], "warnings": [] }
|
||||
```
|
||||
|
||||
`skipped` — массив `{ id, reason }` если какие-то проекты не обновились (например, конфликт лимита с delivered_today).
|
||||
`warnings` — массив сообщений (например, «Регион XYZ не найден, пропущен»).
|
||||
|
||||
При фатальной ошибке валидации (несовпадение типов, > 500 проектов, конфликт всего лимита) — 422 без частичного применения.
|
||||
|
||||
### 4.4. Валидация (FormRequest)
|
||||
|
||||
`BulkProjectActionRequest` расширяется:
|
||||
|
||||
- `action` ∈ enum (5 значений: pause/resume/archive/update_regions/update_days/update_limit)
|
||||
- `ids`/`scope.filter` — хотя бы одно из двух обязательно
|
||||
- Для каждого action — свой `prepareForValidation` + правила по полям payload'а
|
||||
- Для `update_limit`: `delta` XOR `replace` (нельзя оба)
|
||||
- Для `update_days`: `add | remove` ≤ 127, оба ≥ 0
|
||||
- Для `update_regions`: id из массивов существуют в `regions` (single query)
|
||||
|
||||
### 4.5. Семантика на БД
|
||||
|
||||
Внутри транзакции:
|
||||
|
||||
- **regions:** `region_mask = (region_mask | computed_add_mask) & ~computed_remove_mask` — применяется ко всем выбранным без skip.
|
||||
- **days:** `delivery_days_mask = (delivery_days_mask | :add) & ~:remove` — применяется ко всем выбранным без skip.
|
||||
- **limit delta:** для каждого проекта вычисляется `new_value = daily_limit_target + :delta`; если `new_value < delivered_today`, проект попадает в `skipped` с `reason: 'below_delivered_today'`. Иначе `daily_limit_target = new_value`.
|
||||
- **limit replace:** для каждого проекта если `:value < delivered_today` → skip с тем же reason. Иначе `daily_limit_target = :value`.
|
||||
|
||||
Реализация — `Project::whereIn(...)->update(...)` для regions/days (атомарный bitmask-апдейт); для limit — выборка с проверкой, разделение на updatable/skipped, затем `whereIn(updatable_ids)->update(...)`.
|
||||
|
||||
`region_mode` — не меняется bulk-операцией (остаётся как было). Add/remove применяются к `region_mask` независимо от текущего `region_mode` проекта; интерпретация маски (include vs exclude) сохраняется согласно сохранённому `region_mode`.
|
||||
|
||||
## 5. Frontend store
|
||||
|
||||
```ts
|
||||
// projectsStore.ts добавления
|
||||
|
||||
const selectAllByFilter = ref<boolean>(false);
|
||||
|
||||
function toggleSelectAllByFilter() {
|
||||
if (selectAllByFilter.value || selectedIds.value.size > 0) {
|
||||
clearSelection();
|
||||
selectAllByFilter.value = false;
|
||||
} else {
|
||||
selectAllByFilter.value = true;
|
||||
// Заполняем selectedIds id'ами текущей страницы для отображения чекбоксов
|
||||
items.value.forEach(p => selectedIds.value.add(p.id));
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkUpdate(payload: BulkPayload): Promise<BulkResponse> {
|
||||
const body = selectAllByFilter.value
|
||||
? { ...payload, scope: { filter: serializeFilters() } }
|
||||
: { ...payload, ids: Array.from(selectedIds.value) };
|
||||
const { data } = await axios.post('/api/projects/bulk', body);
|
||||
clearSelection();
|
||||
selectAllByFilter.value = false;
|
||||
await fetch();
|
||||
return data;
|
||||
}
|
||||
|
||||
// При смене фильтров — сброс
|
||||
watch(() => [filters.signal_type, filters.status, filters.search], () => {
|
||||
clearSelection();
|
||||
selectAllByFilter.value = false;
|
||||
});
|
||||
```
|
||||
|
||||
Существующий `bulkAction(action)` остаётся для pause/resume/archive (тонкая обёртка над `bulkUpdate({ action, ...})`).
|
||||
|
||||
## 6. Тестирование
|
||||
|
||||
### Pest (backend)
|
||||
|
||||
Новый файл `tests/Feature/Api/ProjectBulkActionsTest.php`:
|
||||
|
||||
- **Smoke per action:** 5 actions × 2 scope-варианта (ids/filter) = 10 happy-cases
|
||||
- **Edge:**
|
||||
- `update_limit` delta где у проекта delivered_today > new — попадает в skipped
|
||||
- `update_limit` replace с value < delivered_today для всех — 422
|
||||
- `update_regions` с несуществующим region_id — warning
|
||||
- `scope.filter` захватывает > 500 проектов — 422
|
||||
- `delta` И `replace` одновременно — 422
|
||||
- Пустой `ids` И пустой `scope` — 422
|
||||
- **RLS:** bulk не пересекает tenant_id (FactoryTraits + assertGuardedByRls helper)
|
||||
|
||||
### Vitest (frontend)
|
||||
|
||||
Новые spec'и:
|
||||
|
||||
- `tests/Frontend/ProjectsToolbar.spec.ts` — 3 состояния select-all (none/partial/all), сброс на смену фильтров
|
||||
- `tests/Frontend/RegionsBulkDialog.spec.ts` — открытие, валидация, отправка payload
|
||||
- `tests/Frontend/DaysBulkDialog.spec.ts` — toggle weekday-кнопок, формирование bitmask
|
||||
- `tests/Frontend/LimitBulkDialog.spec.ts` — delta vs replace toggle, валидация
|
||||
- `tests/Frontend/projectsStore.bulkUpdate.spec.ts` — scope-discriminator (ids vs filter)
|
||||
|
||||
Расширить `tests/Frontend/BulkActionsBar.spec.ts`: проверка появления 3 новых кнопок, открытие диалогов.
|
||||
|
||||
### Histoire
|
||||
|
||||
- `BulkActionsBar.story.vue` — расширить вариантами: `selected=0` (скрыт), `=1`, `=5`, `=all-by-filter`
|
||||
- `RegionsBulkDialog.story.vue`, `DaysBulkDialog.story.vue`, `LimitBulkDialog.story.vue` — open state
|
||||
|
||||
## 7. Schema
|
||||
|
||||
Без изменений. Используются существующие колонки:
|
||||
|
||||
- `projects.region_mask: integer`
|
||||
- `projects.region_mode: text` ('include'/'exclude') — не меняется bulk-ом
|
||||
- `projects.delivery_days_mask: integer`
|
||||
- `projects.daily_limit_target: integer`
|
||||
- `projects.delivered_today: integer`
|
||||
- `projects.is_active: boolean`
|
||||
|
||||
## 8. Out of scope
|
||||
|
||||
- Undo bulk-операций (можно в Plan 6 если нужно)
|
||||
- Изменение `region_mode` bulk-ом (всегда сохраняется текущий mode проекта)
|
||||
- Hard-delete архивных проектов через bulk (есть только archive)
|
||||
- Импорт/экспорт настроек проектов
|
||||
- Bulk-операции по статусам отличным от текущих 4 категорий (signal_type, sms_senders, и т.п.)
|
||||
|
||||
## 9. Открытые вопросы (на будущее)
|
||||
|
||||
- **Q-1:** Audit log bulk-операций — нужно ли логировать в `audit_logs` каждое изменение per-project, или хватит одной строки на bulk? Решение: одна строка с `affected_ids`, для MVP.
|
||||
- **Q-2:** Уведомления тенанта при bulk-pause всех проектов (потенциальная потеря лидов) — добавлять `ZeroBalancePausedMail`-style? Не в этом скоупе.
|
||||
|
||||
## 10. Acceptance criteria
|
||||
|
||||
- [ ] Toolbar с select-all чекбоксом виден над гридом, корректно показывает 3 состояния
|
||||
- [ ] BulkActionsBar показывает все 4 категории + Архив + Снять выбор
|
||||
- [ ] 3 диалога (Regions/Days/Limit) открываются, валидируют, отправляют корректный payload
|
||||
- [ ] Pause/Resume инстант-кнопки работают без диалога
|
||||
- [ ] При смене фильтров — выбор сбрасывается
|
||||
- [ ] Backend принимает оба scope (ids/filter), отдаёт `{updated, skipped, warnings}`
|
||||
- [ ] Лимит ниже `delivered_today` попадает в `skipped`, не блокирует остальных
|
||||
- [ ] > 500 проектов по filter → 422 с message «Уточните фильтры»
|
||||
- [ ] Pest 100% pass, Vitest 100% pass, Histoire stories рендерятся
|
||||
- [ ] RLS: bulk не пересекает tenant
|
||||
@@ -1,271 +0,0 @@
|
||||
# Quiet Luxury редизайн элементов #1440 и #896 — design spec
|
||||
|
||||
**Дата:** 2026-05-12
|
||||
**Скоуп:** буквально два элемента из Dev Element Indices.
|
||||
**Цель:** привести два визуально «тяжёлых» / выпадающих из Quiet Luxury (Portal Redesign I1) элемента к языку токенов `tokens.css`.
|
||||
**Связанные документы:** [Portal Redesign — Quiet Luxury (I1)](2026-05-12-portal-redesign-quiet-luxury-design.md), [Dev Element Indices](2026-05-12-dev-element-indices-design.md).
|
||||
**Status:** approved sections (1–5), pending user review of written spec.
|
||||
|
||||
---
|
||||
|
||||
## 1. Контекст и проблема
|
||||
|
||||
### 1.1. Элемент #1440 — `span.card-check__box` в `ProjectCard.vue`
|
||||
|
||||
- Pin: [app/resources/js/components/projects/ProjectCard.vue:11](../../app/resources/js/components/projects/ProjectCard.vue#L11).
|
||||
- Сейчас: `20×20px`, бордер `2px solid var(--liderra-noir)` (= `#012019`), checked-стейт — полная заливка `var(--liderra-teal)` (`#0F6E56`) + белая `::after` галочка.
|
||||
- Проблема: на ivory-карточке тёмная толстая `2px noir` обводка читается как «штамп». В сетке из 10+ карточек чек-боксы доминируют визуально и противоречат языку `tokens.css`, где базовая сетка границ — `--liderra-line` (`rgba(1, 32, 25, 0.08)`, 1px) и `--liderra-line-strong` (`rgba(1, 32, 25, 0.14)`, 1px).
|
||||
|
||||
### 1.2. Элемент #896 — `v-text-field` «Название проекта» в `NewProjectDialog.vue`
|
||||
|
||||
- Pin: [app/resources/js/views/projects/NewProjectDialog.vue:59](../../app/resources/js/views/projects/NewProjectDialog.vue#L59).
|
||||
- Сейчас: `<v-text-field>` использует global Vuetify-defaults из [app/resources/js/plugins/vuetify.ts:50-54](../../app/resources/js/plugins/vuetify.ts#L50-L54) — `variant=outlined density=comfortable color=primary`. Outline-border берёт дефолтный Vuetify-цвет `rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity))` = ≈ `rgba(1, 32, 25, 0.6)` — заметно темнее, чем `--liderra-line` / `--liderra-line-strong`.
|
||||
- Проблема: тёмный outline-border выпадает из Quiet Luxury языка (1px @ 6–8–14 % noir в `tokens.css`). На ivory-диалоге все v-text-field читаются как «офисные», а не «премиум». То же касается v-combobox и v-autocomplete (они тоже используют `.v-field__outline__*` под капотом), но они out-of-scope этой задачи и должны быть переделаны отдельным PR.
|
||||
- **Post-discovery rescope (2026-05-12):** при Task 0 имплементации обнаружено global default `variant=outlined`, поэтому первоначальная гипотеза «#896 — единственное filled поле, выпадающее среди других» оказалась неверна. Заказчик расширил область до **всех 5 `<v-text-field>`** в `NewProjectDialog.vue` (lines 21, 30, 48, 59, 61). См. §3.2 для актуального списка правок.
|
||||
|
||||
### 1.3. Известные близнецы (вне области по решению заказчика)
|
||||
|
||||
- `.toolbar-check__box` в [app/resources/js/views/ProjectsView.vue:50](../../app/resources/js/views/ProjectsView.vue#L50) — идентичные CSS-правила. Останется в текущем виде; следствие: после применения этой спеки на странице будет два разных стиля checkbox (карточка ≠ toolbar). Это **осознанный trade-off**.
|
||||
- `v-combobox` (sms_senders, line 40), `v-autocomplete` (regions, line 70), `v-btn-toggle` (days, line 83) в `NewProjectDialog.vue` — останутся с дефолтным Vuetify-стилем. Они используют те же `.v-field__outline__*` (combobox / autocomplete) и должны быть переделаны отдельной задачей; в текущей области НЕ затрагиваются.
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
Узкая CSS / prop-правка, **без новых компонентов и primitives**:
|
||||
|
||||
- **`ProjectCard.vue`** — переписать `<style scoped>` (текущие строки 132–172). Template / script нетронуты **в рамках этой задачи**. Имена классов сохранены (`.card-check`, `.card-check__box`) → существующие Vitest-тесты с `findByTestId('card-select')` не ломаются, dev-indices signature для #1440 (line 11) не сдвигается.
|
||||
|
||||
**Note (post-impl audit `3fc90f1`):** на момент начала сессии working dir уже содержал uncommitted template change `<v-checkbox>` → `<label><input type="checkbox"><span class="card-check__box" /></label>` (см. session-start `git status: M app/resources/js/components/projects/ProjectCard.vue`). Branch-HEAD `88a13e2` имел `<v-checkbox>`. Сессия работала поверх pre-modified working dir, и impl-коммит `3fc90f1` забрал этот template change вместе с CSS. Архитектурно изменение **необходимо** для работы Quiet Luxury CSS — `<v-checkbox>` рендерит Vuetify-internal `<input>` без sibling-`<span>`, который требуется scoped `:checked + .card-check__box::after`-селектору. Тестирование (Vitest 4/4 + baseline-fix commit `84530d5` подготовлен под native input selector) подтверждает корректность.
|
||||
- **`NewProjectDialog.vue`** — на line 59 расширить props у `<v-text-field>`; добавить `<style scoped>` блок в конец файла (сейчас файл без scoped-стилей). Template/script — без структурных изменений → dev-indices signature для #896 не сдвигается.
|
||||
|
||||
Никаких изменений в `tokens.css`, `app.css`, `motion.css`, `typography.css`. Используем только существующие токены и Vuetify 3 API.
|
||||
|
||||
---
|
||||
|
||||
## 3. Точная реализация
|
||||
|
||||
### 3.1. #1440 — новый `<style scoped>` в `ProjectCard.vue`
|
||||
|
||||
Полная замена текущего блока (current lines 132–172):
|
||||
|
||||
```css
|
||||
<style scoped>
|
||||
.project-card.paused {
|
||||
opacity: 0.75;
|
||||
}
|
||||
.card-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
.card-check input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.card-check__box {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--liderra-line);
|
||||
border-radius: var(--radius-6);
|
||||
background: var(--liderra-surface);
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
transition:
|
||||
border-color 200ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.card-check:hover .card-check__box {
|
||||
border-color: var(--liderra-line-strong);
|
||||
}
|
||||
.card-check input:focus-visible + .card-check__box {
|
||||
outline: 2px solid var(--liderra-teal);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.card-check input:checked + .card-check__box {
|
||||
background: rgba(15, 110, 86, 0.10);
|
||||
border-color: var(--liderra-teal);
|
||||
}
|
||||
.card-check input:checked + .card-check__box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 0;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid var(--liderra-teal);
|
||||
border-width: 0 1.5px 1.5px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
Delta vs. текущий код:
|
||||
|
||||
- Размер `20→16px` (соответствует масштабу FilterChip / Kbd UI primitives).
|
||||
- Бордер `2px solid noir → 1px solid --liderra-line` (idle) + `--liderra-line-strong` (hover).
|
||||
- Checked-стейт `полная teal-заливка + белая stroke → tonal 10% teal-bg + teal-stroke` (как FilterChip-active в [app/resources/js/components/ui/FilterChip.vue:42-46](../../app/resources/js/components/ui/FilterChip.vue#L42-L46)).
|
||||
- Толщина stroke галочки `2px → 1.5px` (пропорция).
|
||||
- Координаты галочки пересчитаны под 16×16: `left: 4px; top: 0; width: 5px; height: 9px`.
|
||||
- Добавлен `:focus-visible` outline (раньше отсутствовал — keyboard-навигация невидима).
|
||||
- Transition расширен на `background-color` (для плавного входа в checked).
|
||||
|
||||
### 3.2. #896 + 4 близнеца — class на 5 v-text-field + новый `<style scoped>` в `NewProjectDialog.vue`
|
||||
|
||||
**Template edits** — добавить `class="ld-input-quiet"` к **каждому из 5** v-text-field в файле. Остальные props (variant/density/color) **НЕ нужны** — они уже задаются global default'ом в [vuetify.ts:50-54](../../app/resources/js/plugins/vuetify.ts#L50-L54). Точные правки:
|
||||
|
||||
| Line | Поле | Текущий | Целевой |
|
||||
|---|---|---|---|
|
||||
| 21 | Домен (site) | `<v-text-field v-model="form.signal_identifier" label="..." placeholder="..." :readonly="..." :error-messages="..." />` | `+ class="ld-input-quiet"` |
|
||||
| 30 | Номер (call) | `<v-text-field v-model="form.signal_identifier" label="..." placeholder="..." hint="..." :readonly="..." :error-messages="..." />` | `+ class="ld-input-quiet"` |
|
||||
| 48 | Ключевое слово (sms) | `<v-text-field v-model="form.sms_keyword" label="..." hint="..." :error-messages="..." />` | `+ class="ld-input-quiet"` |
|
||||
| 59 | Название проекта | `<v-text-field v-model="form.name" label="Название проекта" :error-messages="errors.name" />` | `+ class="ld-input-quiet"` |
|
||||
| 61 | Лимит лидов в день | `<v-text-field v-model.number="form.daily_limit_target" label="..." type="number" min="1" max="10000" :error-messages="..." />` | `+ class="ld-input-quiet"` |
|
||||
|
||||
Добавляется только один новый атрибут `class="ld-input-quiet"` — все существующие props сохраняются (v-model, label, placeholder, hint, readonly, type, min/max, error-messages).
|
||||
|
||||
**Новый `<style scoped>` блок** в конце файла (после `</script>` line 208):
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
.ld-input-quiet :deep(.v-field) {
|
||||
border-radius: var(--radius-8);
|
||||
}
|
||||
.ld-input-quiet :deep(.v-field__outline__start),
|
||||
.ld-input-quiet :deep(.v-field__outline__end),
|
||||
.ld-input-quiet :deep(.v-field__outline__notch::before),
|
||||
.ld-input-quiet :deep(.v-field__outline__notch::after) {
|
||||
border-color: var(--liderra-line);
|
||||
opacity: 1;
|
||||
transition: border-color 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.ld-input-quiet :deep(.v-field:hover .v-field__outline__start),
|
||||
.ld-input-quiet :deep(.v-field:hover .v-field__outline__end),
|
||||
.ld-input-quiet :deep(.v-field:hover .v-field__outline__notch::before),
|
||||
.ld-input-quiet :deep(.v-field:hover .v-field__outline__notch::after) {
|
||||
border-color: var(--liderra-line-strong);
|
||||
opacity: 1;
|
||||
}
|
||||
.ld-input-quiet :deep(.v-field--focused .v-field__outline__start),
|
||||
.ld-input-quiet :deep(.v-field--focused .v-field__outline__end),
|
||||
.ld-input-quiet :deep(.v-field--focused .v-field__outline__notch::before),
|
||||
.ld-input-quiet :deep(.v-field--focused .v-field__outline__notch::after) {
|
||||
border-color: var(--liderra-teal);
|
||||
opacity: 1;
|
||||
}
|
||||
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__start),
|
||||
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__end),
|
||||
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__notch::before),
|
||||
.ld-input-quiet :deep(.v-field--error:not(.v-field--disabled) .v-field__outline__notch::after) {
|
||||
border-color: currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
Обоснования:
|
||||
|
||||
- `class="ld-input-quiet"` — уникальный класс, не зацепляет v-combobox / v-autocomplete (они остаются с дефолтным border-цветом темы, см. §1.3).
|
||||
- `:deep(.v-field) { border-radius: var(--radius-8); }` — radius из tokens.css (8px). Заменяет первоначальный `rounded="lg"` prop — надёжнее (не зависит от Vuetify internal mapping) и работает на все 5 полей через один CSS rule.
|
||||
- `:deep()` нужен потому что Vuetify-classes находятся в shadow-children компонента (Vue scoped-стили не пробивают границу без `:deep()`).
|
||||
- **`opacity: 1` обязателен** на каждом override-блоке (Critical-1 от reviewer'а Task 2): Vuetify outlined использует `opacity: var(--v-field-border-opacity)` = `0.38` на `.v-field__outline` для idle-стейта. Без явного `opacity: 1` наш `--liderra-line` (alpha 0.08) даёт effective alpha 0.03 → border невидим.
|
||||
- **Hover селектор `.v-field:hover` внутри `:deep()` (НЕ `.ld-input-quiet:hover`)** (Important-3 от reviewer'а): root класс находится на `.v-input` wrapper, который включает hint/error-message-область под полем. Vuetify own hover targets `.v-field:hover` (inner box). Перенесли scope чтобы hover не триггерился при наведении на сообщение об ошибке.
|
||||
- **Error-стейт rule** (Critical-2 от reviewer'а): Vuetify применяет `color: rgb(var(--v-theme-error))` к `.v-field--error.v-field__outline`, а sub-элементы наследуют через `border: 0 solid currentColor`. Наш override `border-color` явным значением разрывает `currentColor`-наследование → red error border не появляется. Восстанавливаем через explicit `border-color: currentColor; opacity: 1` для `.v-field--error:not(.v-field--disabled)`.
|
||||
- Focused-стейт label / caret подсветится `var(--liderra-teal)` через `color="primary"` из global default.
|
||||
- `error-messages` биндинг (Vuetify-native) сохранён — error-стейт работает штатно.
|
||||
|
||||
---
|
||||
|
||||
## 4. Тестирование и верификация
|
||||
|
||||
### 4.1. Vitest
|
||||
|
||||
- Существующие spec'и для `ProjectCard.vue` и `NewProjectDialog.vue` (если есть в репозитории на ветке `plan5-frontend-projects` — проверить во время implementation; список тестов pin'нуть в plan) — должны проходить без правок, потому что:
|
||||
- Сохраняется атрибут `data-testid="card-select"` на `.card-check` ([ProjectCard.vue:5](../../app/resources/js/components/projects/ProjectCard.vue#L5)) и checkbox-input с `@change="$emit('toggle-select', project.id)"`.
|
||||
- Сохраняется `v-model="form.name"` и `:error-messages="errors.name"` на v-text-field — submit-флоу и валидация не задеты.
|
||||
- Не верифицировал на момент написания спеки: точное название и присутствие spec-файлов для этих двух компонентов. Plan уточнит.
|
||||
|
||||
### 4.2. Histoire
|
||||
|
||||
- Если есть `ProjectCard.story.vue` / `NewProjectDialog.story.vue` — visual smoke через `npm run story` (Histoire dev-сервер), проверить state'ы `idle / hover / focus-visible / checked / partial / error`. Если story файлов нет — не блокер, но добавлять их в этой задаче **не нужно** (out-of-scope).
|
||||
|
||||
### 4.3. A11y
|
||||
|
||||
- **Контраст border idle (#1440):** `--liderra-line` (`rgba(1, 32, 25, 0.08)`) на `#FFFFFF` → ~1.15:1. WCAG SC 1.4.11 (non-text contrast 3:1) применяется к UI-компонентам в активном состоянии и focus indicator'ам; idle border decorative-checkbox формально не входит в strict requirement, но граничный случай — фиксируем в trade-off (вариант B обеспечивал 1.14:1 тоже, выбор пользователем сделан осознанно).
|
||||
- **Контраст focus-visible outline:** `--liderra-teal` (`#0F6E56`) на `#FFFFFF` → 4.7:1, проходит SC 1.4.11.
|
||||
- **Контраст checked tonal-bg:** `rgba(15, 110, 86, 0.10)` поверх `#FFF` → effective `~#E7F1ED`. Галочка teal на этом фоне: `#0F6E56` vs `#E7F1ED` → ~5.5:1, проходит AA для non-text.
|
||||
- **Pa11y** (`npm run a11y`): прогон только если route доступен без auth. Иначе skip с явной отметкой в plan. Не верифицировал на момент написания спеки: текущий запуск Pa11y, route guard конфиг.
|
||||
|
||||
### 4.4. Lint / Type-check
|
||||
|
||||
- `npm run lint:vue` — ESLint flat-config 10 + plugin-vue 10 (не верифицировал: правила vs новые `:deep()` селекторы и `class binding`; expected pass).
|
||||
- `npm run type-check` — vue-tsc на src; `variant="outlined"` / `density="compact"` / `rounded="lg"` — стандартные Vuetify-3 props, types есть в `@types/vuetify` (входит в пакет).
|
||||
|
||||
### 4.5. Manual smoke
|
||||
|
||||
- Запустить `php artisan serve` + `npm run dev` параллельно (см. memory `feedback_environment.md` Plan 5 quirks).
|
||||
- Авторизоваться (см. memory `project_supplier_integration.md` для demo-tenant credentials).
|
||||
- `/projects` — проверить визуально: idle, hover, click (checked), keyboard Tab → focus-visible outline.
|
||||
- Открыть «+ Создать проект» → визуально проверить outlined-поле «Название проекта» rest / hover / focus / error.
|
||||
|
||||
### 4.6. Pre-commit
|
||||
|
||||
`lefthook` stages (job'ы из репозитория — `lefthook.yml`):
|
||||
|
||||
- pint (PHP — не задеты)
|
||||
- larastan (PHP — не задеты)
|
||||
- squawk (миграции — не задеты)
|
||||
- pgFormatter (SQL — не задеты)
|
||||
- gitleaks (всегда)
|
||||
- markdownlint (для spec.md этой задачи)
|
||||
- ESLint (vue/ts — задеты оба файла)
|
||||
- Pest (если в lefthook есть)
|
||||
|
||||
Полный прогон `lefthook run pre-commit` перед коммитом.
|
||||
|
||||
---
|
||||
|
||||
## 5. Out-of-scope (явно)
|
||||
|
||||
- НЕ редизайнится `.toolbar-check__box` в `ProjectsView.vue:50` — останется 2px-noir. Появится визуальная асимметрия (карточный checkbox ≠ toolbar checkbox). Заказчик принял.
|
||||
- НЕ редизайнятся `v-combobox` (sms_senders, line 40), `v-autocomplete` (regions, line 70), `v-btn-toggle` (days, line 83) внутри `NewProjectDialog.vue` — останутся с дефолтным border-цветом темы.
|
||||
- НЕ редизайнятся `v-select` в верхнем фильтр-баре `ProjectsView.vue` (lines 9, 19) и поисковый `v-text-field` (line 29). Скоуп user'ом обозначен как страничный диалог, не фильтр-бар.
|
||||
- НЕ создаётся `LdTextField` / `LdCheckbox` primitive. Если в будущем потребуется унифицировать всё семейство — отдельный design / plan.
|
||||
- НЕ обновляется `tokens.css`. Все правки — внутри scoped-стилей двух компонентов.
|
||||
- НЕ обновляется `dev-indices.json` (signature и line-числа сохраняются).
|
||||
- НЕ затрагивается логика валидации / emit'ов / store'а / API.
|
||||
|
||||
---
|
||||
|
||||
## 6. Риски и допущения
|
||||
|
||||
1. **`:deep()` против внутренних Vuetify-classes** — `.v-field__outline__start/end/notch::before/::after` зависит от внутренней Vuetify 3 SFC-структуры. Риск ломки при минорном bump'е Vuetify. Митигация: класс `.ld-input-quiet` локализует override, при обновлении Vuetify нужен manual recheck (не верифицировал точную совместимость с Vuetify 3.12 — assumption на основе официальной документации Vuetify 3.x).
|
||||
2. **focus-visible на скрытом `<input>`** — input `position: absolute; opacity: 0; pointer-events: none`, но focusable (нет `tabindex="-1"`, нет `disabled`). `:focus-visible` должен срабатывать при Tab. Если в браузере Safari < 15.4 — `:focus-visible` не поддерживается, fallback на `:focus` для тех браузеров (не добавляем — рынок Safari < 15.4 ничтожен, см. caniuse).
|
||||
3. ~~**`rounded="lg"` в Vuetify 3 = 8px**~~ — **снят**. Через `:deep(.v-field) { border-radius: var(--radius-8); }` фиксируем явное значение в CSS, не полагаясь на Vuetify internal mapping.
|
||||
4. ~~**`color="primary"` подсветит label и caret в teal**~~ — **подтверждено** в Task 0.1: [vuetify.ts:19](../../app/resources/js/plugins/vuetify.ts#L19) определяет `primary: '#0F6E56'`, global default [vuetify.ts:53](../../app/resources/js/plugins/vuetify.ts#L53) задаёт `color=primary` для всех VTextField.
|
||||
5. **Vitest snapshot-тесты, если есть** — могут зафейлиться на новом классе `ld-input-quiet` в render output. Task 0.3 показал, что snapshot-тестов нет; behavior-тесты не зависят от class. Митигация не нужна.
|
||||
6. **Pre-existing baseline test failure** — `app/tests/Frontend/ProjectCard.spec.ts:43` (change-trigger на label вместо input) failing с момента создания spec'а (commit `c9ee8d8`). Фиксится отдельным `test(projects)` атомарным коммитом ДО Task 1, чтобы baseline был зелёным.
|
||||
|
||||
---
|
||||
|
||||
## 7. Definition of Done
|
||||
|
||||
- [ ] Pre-existing baseline test failure починен (`ProjectCard.spec.ts:43`, отдельный коммит).
|
||||
- [ ] `ProjectCard.vue` `<style scoped>` заменён согласно §3.1.
|
||||
- [ ] `NewProjectDialog.vue` 5 v-text-field получили `class="ld-input-quiet"` (lines 21, 30, 48, 59, 61) и добавлен `<style scoped>` блок согласно §3.2.
|
||||
- [ ] Vitest зелёный (`npm run test:vue`, target 614 passed / 3 skipped / 0 failed).
|
||||
- [ ] vue-tsc зелёный (`npm run type-check`).
|
||||
- [ ] ESLint зелёный (`npm run lint:vue`).
|
||||
- [ ] Manual smoke: все стейты #1440 + все стейты 5 v-text-field verified в браузере на dev-сервере.
|
||||
- [ ] Histoire build (`npm run story:build`) green — story-файлы для обоих компонентов существуют.
|
||||
- [ ] `lefthook run pre-commit` + `pre-push` зелёные.
|
||||
- [ ] Атомарный коммит: «feat(projects-ui): Quiet Luxury redesign for card-check + 5 dialog v-text-fields».
|
||||
- [ ] Push на ветку `plan5-frontend-projects` (текущая active).
|
||||
|
||||
---
|
||||
|
||||
## 8. Открытые вопросы — отсутствуют
|
||||
|
||||
В этой спецификации нет TBD / TODO / placeholder. Область фиксирована.
|
||||
@@ -1,161 +0,0 @@
|
||||
---
|
||||
title: Снятие R15 motion-runtime restrictions из нормативной базы
|
||||
date: 2026-05-12
|
||||
status: approved-by-customer
|
||||
author: Claude (через superpowers:brainstorming)
|
||||
related-rules: PSR_v1 v1.7 → v2.0, CLAUDE.md v1.87 → v1.88, Tooling v1.15 → v1.16, Pravila v1.10 → v1.11
|
||||
---
|
||||
|
||||
# Design: Снятие R15 motion-runtime restrictions
|
||||
|
||||
## 1. Контекст и триггер решения
|
||||
|
||||
12.05.2026 заказчик запросил: «снять все запреты на использование framer-motion». В ходе brainstorming sanity-check'а согласовано:
|
||||
|
||||
- `framer-motion` (React-only, peerDep `react` + `react-dom`) — остаётся **technical block**, физически не работает в Vue 3 + Vuetify 3. Это свойство библиотеки, не regulatory rule.
|
||||
- Реальная цель — широкое использование motion-runtime библиотек в проекте. Vue-эквивалент framer-motion — `motion-v` (Vue 3 порт от того же автора, ~95% API-совместимость).
|
||||
- Сохранение R15 4-триггерного окна (PSR_v1 v1.7) делает любое использование motion-v / gsap / anime.js / lottie-web затруднённым; заказчик хочет снять барьер целиком.
|
||||
|
||||
**Решение**: удалить R15-секцию из PSR_v1 целиком и синхронизировать связанные документы.
|
||||
|
||||
Это **сознательный rollback** аудированной нормативной правки v1.83 (10.05.2026, когда R15 двухуровневая motion-конструкция была введена). Чтобы будущий аудит видел дату/причину снятия — фиксируется явно в changelog'е PSR_v1 v2.0.
|
||||
|
||||
## 2. Архитектура правки (4 документа)
|
||||
|
||||
### 2.1. `docs/Plugin_stack_rules_v1.md` v1.7 → v2.0 (major bump)
|
||||
|
||||
**Удалить целиком:**
|
||||
|
||||
- R15.1 — framer-motion hard-запрет навсегда (React-only архитектурно)
|
||||
- R15.2 — motion-v 4-триггерное условное окно
|
||||
- R15.3 — default motion stack как обязательная стойка
|
||||
- R15.7 — расширение R15.1 на gsap/anime.js/lottie-web/popmotion/@motionone/dom
|
||||
- R0.6 пункт 11 — «animation runtime hard-стоп Auto mode не отменяет»
|
||||
|
||||
**Изменить:**
|
||||
|
||||
- Шапка: «v1.7, 16 правил R0–R15» → «v2.0, 15 правил R0–R14»
|
||||
- Changelog v2.0 entry: «major bump — removal of R15 motion-runtime restrictions per user decision 12.05.2026; conscious rollback от v1.83 audited construction (10.05.2026 поздний вечер); rationale: customer trust в developer team's choice motion-runtime без regulatory guardrail»
|
||||
- Cross-refs шапки на актуальные версии связанных документов (CLAUDE.md v1.88, Pravila v1.11, Tooling v1.16)
|
||||
|
||||
### 2.2. `CLAUDE.md` v1.87 → v1.88
|
||||
|
||||
**Удалить:**
|
||||
|
||||
- §5 п.12 «Не устанавливать motion runtime библиотеки...» — абзац целиком
|
||||
|
||||
**Изменить:**
|
||||
|
||||
- §2 строка «Animation default stack» — переписать с «motion-v / framer-motion / gsap / anime.js / lottie-web — **не установлены** и условно разрешены только по R15.2 (4 триггера)» на: «Default motion stack — рекомендация (не hard-rule); motion-runtime библиотеки разрешены к установке без обоснования; `framer-motion` физически не работает в Vue (React-only) — см. Tooling §9.2 technical guidance»
|
||||
- §0 строка изменения — запись об v1.88
|
||||
- §9 «История версий» — entry v1.88
|
||||
|
||||
**§5 п.12 — резолюция противоречия:** контент абзаца удаляется; заголовок-маркер остаётся как:
|
||||
|
||||
```
|
||||
12. **Резерв.** Был «не устанавливать motion runtime библиотеки без прохождения R15.2». Снят 12.05.2026 (CLAUDE.md v1.88 + PSR_v1 v2.0). Подробности — docs/CHANGELOG_claude_md.md.
|
||||
```
|
||||
|
||||
Это сохраняет нумерацию п.10/п.11/п.12 → cross-refs в memory (`feedback_environment.md`, `feedback_plugin_paired_stack.md`) и других местах не ломаются.
|
||||
|
||||
**Не менять:**
|
||||
|
||||
- §5 п.10 (правки через claude-md-management — действует)
|
||||
- §5 п.11 (Superpowers hard rule — действует)
|
||||
|
||||
### 2.3. `docs/Tooling_v8_3.md` Прил. Н v1.15 → v1.16
|
||||
|
||||
**Изменить §9.2:**
|
||||
|
||||
- denylist motion-runtime — переформулировать. Удалить условные пометки на motion-v / gsap / anime.js / lottie-web / popmotion / motion-one / @motionone/dom.
|
||||
- `framer-motion` оставить с маркером **«technical guidance: React-only library, peerDep react+react-dom, runtime crash в .vue файлах. Не regulatory rule — свойство библиотеки»**.
|
||||
|
||||
**Шапка:** cross-refs на актуальные версии (PSR_v1 v2.0, CLAUDE.md v1.88, Pravila v1.11).
|
||||
|
||||
**Changelog v1.16:** запись.
|
||||
|
||||
### 2.4. `docs/Pravila_raboty_Claude_v1_1.md` v1.10 → v1.11
|
||||
|
||||
**Удалить:**
|
||||
|
||||
- §13.10 — transitive hard-link на R15 (R15 не существует, link не имеет цели)
|
||||
|
||||
**Изменить:**
|
||||
|
||||
- §13.2 счётчик «v1.6 (16 правил)» → «v1.7 (15)»
|
||||
- §0 строка изменения — запись об v1.11.
|
||||
|
||||
**Не задеты:**
|
||||
|
||||
- §13.9 (hard-link на R10, не R15 — verified через memory `feedback_superpowers_hard_rule.md`).
|
||||
- §11/§12 (Superpowers hard rule).
|
||||
- §0–§9 (продуктовые правила, не связаны с motion-runtime).
|
||||
|
||||
**Контрольная re-Read перед правкой:** перед применением патча — fresh Read актуального содержимого Pravila §13 (§13.9, §13.10, §13.2) и подтвердить факты grep'ом перед удалением. Если §13.9 фактически содержит cross-ref на R15 (memory может быть stale) — расширить scope правки.
|
||||
|
||||
## 3. Data flow / последствия после merge
|
||||
|
||||
| Действие | До | После |
|
||||
|---|---|---|
|
||||
| `npm install motion-v` | требует обоснование 4 триггеров R15.2 + AskUserQuestion | без обоснования, без AskUserQuestion |
|
||||
| `npm install gsap` / `anime.js` / `lottie-web` / `popmotion` / `@motionone/dom` | R15.7 запрет без обоснования | без обоснования |
|
||||
| `npm install framer-motion` | R15.1 hard-запрет, Auto mode не отменяет | npm install проходит с peer-dep warn; runtime crash при `import { motion } from 'framer-motion'` в .vue — это поведение библиотеки, не регуляторный блок |
|
||||
| Default motion stack (Vue Transition + Vuetify transitions + CSS @keyframes + View Transitions API) | обязательная стойка R15.3 | рекомендация |
|
||||
| ESLint / lefthook / pre-commit | нет хуков на motion-deps | без изменений |
|
||||
|
||||
## 4. Cross-doc consistency / sync
|
||||
|
||||
Все 4 документа синхронизируются в одном atomic-коммите. Дополнительно:
|
||||
|
||||
- **MEMORY.md** index entry «Plugin paired stack pattern + расширенный пул UPM/21st + R15 motion + tier-структура» — обновить hook: «R15 motion» → «R15 motion (снят 12.05.2026, v2.0)»
|
||||
- **`memory/feedback_plugin_paired_stack.md`** — поправить упоминания v1.7 на v2.0; удалить блок «R15 двухуровневая motion-конструкция» или маркировать как historical context
|
||||
- **`docs/CHANGELOG_claude_md.md`** — запись v1.88 с описанием removal + список затронутых файлов
|
||||
|
||||
## 5. Verification после правок
|
||||
|
||||
- **Lefthook полный прогон**:
|
||||
- lychee — 0 broken links (cross-refs шапок не сломаны)
|
||||
- gitleaks — 0 leaks (только .md правки)
|
||||
- markdownlint — clean на всех 4 файлах
|
||||
- **Семантический grep**:
|
||||
- `rg "R15" docs/ CLAUDE.md` — все вхождения либо удалены, либо переформулированы в changelog'ах как historical
|
||||
- `rg "motion-runtime hard-запрет"` — 0 matches
|
||||
- `rg "framer-motion hard-запрет"` — 0 matches
|
||||
- `rg "v1\.7\+|PSR_v1 v1\.7"` — все мигрировано на v2.0+ или явно historical
|
||||
- **Cross-ref проверка вручную**: шапки всех 4 документов содержат актуальные взаимные ссылки (PSR_v1 v2.0 ↔ CLAUDE.md v1.88 ↔ Tooling v1.16 ↔ Pravila v1.11).
|
||||
- **MEMORY sync**: re-Read memory файлов после правок, проверка что upstream-факты согласованы.
|
||||
|
||||
Нет runtime-тестов (правки только в `.md`, не задеты Pest / Vitest / Histoire).
|
||||
|
||||
## 6. Атомарность и канал исполнения
|
||||
|
||||
- **Ветка**: `rules/remove-r15` от `origin/main` (`48f27b4`). Не подмешивать в `plan5-frontend-projects` — нормативная правка должна быть отдельным PR.
|
||||
- **Канал**: `/claude-md-management:claude-md-improver` (CLAUDE.md §5 п.10 hard-rule — прямой Edit/Write по CLAUDE.md запрещён).
|
||||
- **Коммиты**:
|
||||
1. `docs(spec): add design for R15 removal` — этот design doc, отдельный коммит
|
||||
2. `chore(rules): remove R15 motion-runtime restrictions (PSR_v1 v2.0)` — все 4 .md + CHANGELOG + MEMORY sync, atomic
|
||||
- **Pre-commit**: lefthook все jobs прогоняются перед commit. Если падает — fix + новый коммит (не amend, CLAUDE.md §5 п.11).
|
||||
- **PR**: после push — `gh pr create`. Body содержит:
|
||||
- Section 1 (контекст и rollback)
|
||||
- Section 2 (что меняется в 4 файлах)
|
||||
- Section 3 (последствия)
|
||||
- Section 4 (cross-doc consistency)
|
||||
- Section 5 (verification список)
|
||||
- Ссылка на этот design doc
|
||||
|
||||
## 7. Open issues / out-of-scope
|
||||
|
||||
- **Не задето**: ТЗ v8.5 (стек Vue + Vuetify не меняется), DEVELOPER_HANDOFF (компоненты под Vue остаются), брендбук (палитра/шрифты не задеты), schema БД, backend Laravel.
|
||||
- **Не задето**: R0–R14 PSR_v1 (стек-фильтры R6.0/R6.1, paired-stack R10, иерархия источников R11, decision matrix R13, UI-pipeline R14 — все остаются в силе).
|
||||
- **Не задето**: hard rule §12 Pravila (Superpowers skill инвокация первой) — остаётся.
|
||||
|
||||
## 8. Decision trail
|
||||
|
||||
- 12.05.2026 ~16:00 — заказчик «установи framer motion» (economy 100%) → отказ с объяснением hard-запрета.
|
||||
- 12.05.2026 — заказчик «сними все запреты на использование framer motion, economy 0%».
|
||||
- 12.05.2026 — brainstorming sanity-check:
|
||||
- Q1: scope use case? → «весь фронт».
|
||||
- Q2: framer-motion именно или API? → «motion-v подойдёт» (sanity-check сработал, миграция фронта на React отменена).
|
||||
- Q3: какой вариант правки R15? → «B — полная отмена R15» (вопреки рекомендации A).
|
||||
- Section 1–5 design'а — все одобрены «да».
|
||||
- Ветка `rules/remove-r15` от main — одобрена.
|
||||
@@ -1,241 +0,0 @@
|
||||
---
|
||||
title: CTO-19 closure — Lucide icon system migration через Vuetify custom IconSet
|
||||
date: 2026-05-13
|
||||
status: approved-by-customer
|
||||
author: Claude (через superpowers:brainstorming)
|
||||
related-rules: CLAUDE.md §2 «Иконки: Lucide» — бренд-spec; Реестр CTO-19 ⏸ (v1.79); CTO-19 closure → v1.83.
|
||||
---
|
||||
|
||||
# Design: Lucide icon system migration (Vuetify custom IconSet)
|
||||
|
||||
## 1. Контекст и триггер
|
||||
|
||||
**Текущая проблема (CTO-19, реестр v1.79):**
|
||||
|
||||
- `app/package.json` НЕ содержит icon-related deps: ни `@mdi/font`, ни `lucide-vue-next`, ни любых других.
|
||||
- `app/resources/js/plugins/vuetify.ts` НЕ содержит `icons` config — Vuetify использует default MDI icon set (string names `mdi-*`), но MDI font CSS не загружен → все `<i class="mdi-...">` рендерятся пустыми (нет glyph'ов).
|
||||
- 51 файл (Vue/TS) использует 78 уникальных `mdi-*` имён в `icon`, `prepend-icon`, `append-icon`, `clearable`, etc. props.
|
||||
|
||||
**Бизнес-impact:** все кнопки/поля/индикаторы выглядят без glyph'ов: «+ Создать проект» без plus, search-input без magnify, notification bell пустой, dropdown chevron'ы невидимы.
|
||||
|
||||
**Решение:** Lucide migration per CLAUDE.md §2 (`Иконки: Lucide` — бренд-spec). Заказчик 13.05.2026 явно выбрал path α (aliases-only, views untouched) через AskUserQuestion brainstorming flow.
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
### 2.1. Стратегия — path α (aliases в vuetify.ts)
|
||||
|
||||
- **Views НЕ трогаются** — 51 файл с `icon="mdi-plus"` etc. остаются как есть; `mdi-*` строки становятся semantic-ID для Lucide компонентов.
|
||||
- Один core change в `app/resources/js/plugins/vuetify.ts` — добавление `icons` config с custom `IconSet`.
|
||||
- Lucide selective imports — tree-shakable bundle ~25-30 KB gzip для 78 icons.
|
||||
|
||||
### 2.2. vuetify.ts custom IconSet
|
||||
|
||||
```ts
|
||||
import { h, type Component } from 'vue';
|
||||
import {
|
||||
Plus, Search, Bell, BellOff, AlertCircle, AlertTriangle, Check, CheckCircle,
|
||||
X, XCircle, ChevronRight, ChevronDown, ArrowLeft, ArrowRight, ArrowUp, ArrowDown,
|
||||
User, UserPlus, Users, UserCheck, Archive, RefreshCw, Settings, MessageCircle,
|
||||
Camera, CreditCard, BarChart3, Clock, ClockCheck, FileText, Inbox, Mail,
|
||||
ArrowUpDown, Cog, Database, Download, Edit, Eye, EyeOff, Filter, Flag, Folder,
|
||||
Globe, Home, Info, Key, LayoutDashboard, Layers, Link, List, Lock, LogIn, LogOut,
|
||||
Megaphone, Menu, MoreHorizontal, MoreVertical, Pencil, Phone, Pin, Play, Plus as PlusIcon,
|
||||
Power, Printer, ReceiptText, Repeat, Save, Send, Server, Shield, ShoppingCart,
|
||||
Star, Tag, Trash, Trash2, TrendingUp, TrendingDown, Upload, Wallet, Zap,
|
||||
HelpCircle, // fallback for unmapped
|
||||
// (полный список — финализируется на implementation phase после grep всех 78 имён)
|
||||
} from 'lucide-vue-next';
|
||||
import type { IconSet, IconProps } from 'vuetify';
|
||||
|
||||
const lucideMap: Record<string, Component> = {
|
||||
'mdi-plus': Plus,
|
||||
'mdi-magnify': Search,
|
||||
'mdi-bell-outline': Bell,
|
||||
'mdi-bell-off-outline': BellOff,
|
||||
'mdi-alert-circle': AlertCircle,
|
||||
'mdi-alert-circle-outline': AlertCircle,
|
||||
'mdi-alert-outline': AlertTriangle,
|
||||
'mdi-check': Check,
|
||||
'mdi-check-circle': CheckCircle,
|
||||
'mdi-check-circle-outline': CheckCircle,
|
||||
'mdi-close': X,
|
||||
'mdi-close-circle': XCircle,
|
||||
'mdi-chevron-right': ChevronRight,
|
||||
'mdi-chevron-down': ChevronDown,
|
||||
'mdi-arrow-left': ArrowLeft,
|
||||
'mdi-arrow-right': ArrowRight,
|
||||
'mdi-arrow-up': ArrowUp,
|
||||
'mdi-arrow-down': ArrowDown,
|
||||
'mdi-account-outline': User,
|
||||
'mdi-account-plus-outline': UserPlus,
|
||||
'mdi-account-group-outline': Users,
|
||||
'mdi-account-switch': UserCheck,
|
||||
'mdi-account-arrow-right-outline': UserCheck,
|
||||
// ... ВСЕ 78 entries (финальный список генерируется в plan-phase)
|
||||
};
|
||||
|
||||
const liderraLucideSet: IconSet = {
|
||||
component: (props: IconProps) => {
|
||||
const Icon = lucideMap[String(props.icon)] || HelpCircle;
|
||||
return h(Icon, { size: 20, strokeWidth: 1.75 });
|
||||
},
|
||||
};
|
||||
|
||||
export const vuetify = createVuetify({
|
||||
theme: { defaultTheme: 'liderraForest', themes: { liderraForest } },
|
||||
icons: {
|
||||
defaultSet: 'liderra',
|
||||
sets: { liderra: liderraLucideSet },
|
||||
},
|
||||
defaults: { /* existing — не трогать */ },
|
||||
});
|
||||
```
|
||||
|
||||
### 2.3. Семантическое маппинг — guidelines
|
||||
|
||||
Для 78 имён map'ить по semantic similarity:
|
||||
|
||||
- Прямое соответствие имени (mdi-X → X Lucide): `mdi-plus → Plus`, `mdi-check → Check`, `mdi-bell-outline → Bell`.
|
||||
- `-outline` суффикс → тот же Lucide (Lucide — все stroke-based, outline по умолчанию).
|
||||
- Compound (`mdi-account-plus-outline`) → `UserPlus` (composite Lucide component).
|
||||
- Action-specific (`mdi-cash-plus`) → semantic-closest: `mdi-cash-plus → CirclePlus` или `BadgePlus`. Принцип: понятность пользователю > pixel-точность.
|
||||
- Отсутствует Lucide-эквивалент → fallback `HelpCircle` в IconSet component fn.
|
||||
|
||||
Полный final mapping table 78 строк — produces в implementation plan (отдельный artifact).
|
||||
|
||||
### 2.4. Что меняется
|
||||
|
||||
| Файл | Изменение |
|
||||
|---|---|
|
||||
| `app/package.json` | +1 dep `lucide-vue-next` (`^0.460.0`+ — последняя на 13.05.2026) |
|
||||
| `app/package-lock.json` | автогенерация `npm install` |
|
||||
| `app/resources/js/plugins/vuetify.ts` | +icons config, +78-entry lucideMap, +liderraLucideSet (~80 строк добавлений) |
|
||||
| Реестр CTO-19 ⏸ → ✅ | при явном «закрываем» от заказчика на финальном этапе (Pravila §2.2 / §7.1) |
|
||||
| CLAUDE.md §0 реестр row | bump к v1.83 (registry version после CTO-19 closure) через `/claude-md-management:claude-md-improver` |
|
||||
| Memory `reference_archive.md` + `MEMORY.md` index | sync v1.82 → v1.83 |
|
||||
|
||||
### 2.5. Что НЕ меняется
|
||||
|
||||
- 51 Vue/TS файл с `icon="mdi-*"` — НЕ touched. Все 78 `mdi-*` строки остаются.
|
||||
- ProjectsView.vue clearable workaround (`::after content '✕'`) — можно убрать после Lucide migration (clearable теперь рендерит реальный X icon). **Решение откладывается** — отдельный hygiene-task post-CTO-19.
|
||||
- Другие нормативные документы (Pravila / PSR_v1 / Tooling) — не меняются. R15 motion settings, paired-stack rules — invariant.
|
||||
- Vuetify theme / defaults — invariant.
|
||||
|
||||
## 3. Bundle impact
|
||||
|
||||
**До (CTO-19 broken state):**
|
||||
|
||||
- 0 KB icons in bundle (всё `<i class="mdi-...">` без glyph'ов)
|
||||
- Без font CSS → no visual icons → broken UX
|
||||
|
||||
**После Lucide migration (path α):**
|
||||
|
||||
- `lucide-vue-next` selective imports 78 components — ~25-30 KB gzip (Vite tree-shaking)
|
||||
- Vuetify icons API без изменений в views → no migration cost on consumer side
|
||||
- SVG inline в DOM (Lucide rendering) → optimal a11y (`<svg role="img">`)
|
||||
|
||||
**Альтернатива path (i) — npm i @mdi/font:**
|
||||
|
||||
- +250 KB CSS bundle (`materialdesignicons.css`) — full MDI font (>7000 icons из которых используется 78)
|
||||
- Против бренда (CLAUDE.md §2 «Иконки: Lucide»)
|
||||
- **REJECTED** заказчиком (13.05.2026 ii > i)
|
||||
|
||||
## 4. Testing strategy
|
||||
|
||||
### 4.1. Regression baseline
|
||||
|
||||
- Vitest **88 files / 683 passed / 3 skipped** (фактическое baseline на 13.05.2026 day) — должно остаться без regression. Иконочный refactor — pure plugin-level change, не затрагивает component logic / state / events.
|
||||
- Pest --parallel **742 / 739 passed / 3 skipped** — backend нетронут, baseline неизменна.
|
||||
- Vite build — exit 0; bundle increase ~25-30 KB acceptable.
|
||||
|
||||
### 4.2. Visual smoke (manual через Playwright MCP)
|
||||
|
||||
5 ключевых views:
|
||||
|
||||
| URL | Что проверить (visual) |
|
||||
|---|---|
|
||||
| `/dashboard` | KPI-cards icons, action buttons, notification bell |
|
||||
| `/projects` | search-input magnify, clearable, action menu icons, status pills |
|
||||
| `/settings` | tab icons, save buttons, security cards icons |
|
||||
| `/admin/billing` | navigation drawer icons, table icons, dropdown chevrons |
|
||||
| `/no-such-404` | error illustration if uses icons |
|
||||
|
||||
Acceptance: 0 пустых icon mounts, все glyph'ы видны и semantically correct.
|
||||
|
||||
### 4.3. a11y (axe-core CDN inject)
|
||||
|
||||
После migration на `/dashboard` + `/admin/billing` — re-run axe-core scan. Acceptance: 0 нових violations (Lucide SVG inline должно `role="img"` или `aria-hidden` — проверить Vuetify wrapper).
|
||||
|
||||
### 4.4. Acceptance criteria
|
||||
|
||||
- ✅ `npm i lucide-vue-next` — пакет в `package.json`
|
||||
- ✅ vuetify.ts содержит icons config с 78-entry lucideMap
|
||||
- ✅ Vitest baseline match (683/3 skipped, 0 failed)
|
||||
- ✅ Vite build exit 0
|
||||
- ✅ Visual smoke 5 views — все iconography видна
|
||||
- ✅ 0 новых a11y violations на iconography-related rules
|
||||
- ✅ Реестр CTO-19 ⏸ → ✅ closed (при явном «закрываем»)
|
||||
- ✅ Pre-commit hooks pass (gitleaks + markdownlint + cspell + pint + larastan + pest)
|
||||
- ✅ Pre-push hooks pass (gitleaks-full-history + lychee)
|
||||
|
||||
## 5. Risks + mitigation
|
||||
|
||||
| Риск | Severity | Mitigation |
|
||||
|---|---|---|
|
||||
| Некоторые MDI имена не имеют clean Lucide-эквивалента (например `mdi-cash-plus`, `mdi-clock-check-outline`) | P2 | Best-fit semantic mapping (`CirclePlus` / `Clock`) + fallback `HelpCircle` в IconSet component fn. Полный mapping table — в implementation plan. |
|
||||
| StrokeWidth / size mismatch — Lucide default 24px stroke=2; Vuetify default icon size 24px | P3 | strokeWidth=1.75, size=20 — близко к Vuetify default. Tweakable если visual smoke покажет несовпадение |
|
||||
| Vuetify `prepend-icon` / `append-icon` props ждут string «mdi-...» — наш IconSet может не сработать через alias-path | P2 | Custom IconSet регистрируется на `defaultSet: 'liderra'` — Vuetify routs ВСЕ icon props (включая prepend/append/clearable) через `lucideMap`. Verify в visual smoke |
|
||||
| Bundle size +25-30 KB | P3 | Acceptable (заменяет potential 250 KB MDI font). Tree-shaking подтверждается через `npm run build:analyze` (rollup-plugin-visualizer уже установлен в Sprint 4). |
|
||||
| Vitest stubs `<v-icon>` — поведение IconSet не тестируется unit-tests | P3 | Visual smoke через Playwright MCP — purpose-built для cross-cutting render verification |
|
||||
| Reset / rollback | — | git revert последнего коммита; vuetify.ts вернётся в pre-Lucide state. `npm uninstall lucide-vue-next` cleanup. Без code-changes в views — rollback дешёвый |
|
||||
|
||||
## 6. Closure plan
|
||||
|
||||
### 6.1. CTO-19 ✅ closure
|
||||
|
||||
Pravila §2.2 / §7.1 — закрытие в реестре требует явного «закрываем» от заказчика. Implementation вывод:
|
||||
|
||||
- После visual smoke ОК + tests green → задать вопрос «CTO-19 закрываем как ✅?» через AskUserQuestion.
|
||||
- При «да/закрываем» → реестр §3 CTO-19 ⏸ → ✅ + Сводка §0 counter update (17 ✅ → 18 ✅, 1 ⏸ → 0 ⏸).
|
||||
|
||||
### 6.2. Registry bump v1.82 → v1.83
|
||||
|
||||
- Header version bump + новый changelog entry «v1.83 — CTO-19 ✅ closed via Lucide migration».
|
||||
- Footer line update.
|
||||
- Сводка §0 row CTO: «18 / 17 ✅ / 0 / 1 ⏸ / 0 / 0 / 1 P2 [?]» → «18 / 18 ✅ / 0 / 0 / 0 / 0 / 0».
|
||||
- Итого продуктовых: 87 / 70 ✅ / 5 🟦 / 12 ⏸ → 87 / 71 ✅ / 5 🟦 / 11 ⏸.
|
||||
|
||||
### 6.3. CLAUDE.md + memory sync
|
||||
|
||||
- CLAUDE.md §0 row «Открытые вопросы» bump v1.82 → v1.83 через `/claude-md-management:claude-md-improver` (per CLAUDE.md §5 п.10) или ручной Edit с обоснованием «registry version sync».
|
||||
- Memory `reference_archive.md` description + `MEMORY.md` index line 7 — sync.
|
||||
|
||||
### 6.4. Commit strategy
|
||||
|
||||
**Single atomic commit** — `feat(icons): Lucide migration (CTO-19) via custom Vuetify IconSet`. Содержит:
|
||||
|
||||
- `app/package.json` + `app/package-lock.json`
|
||||
- `app/resources/js/plugins/vuetify.ts`
|
||||
- `docs/Открытые_вопросы_v8_3.md` (registry CTO-19 ✅ + v1.83 + Сводка)
|
||||
- `CLAUDE.md` (§0 registry row + version line bump)
|
||||
|
||||
Memory updates — git-untracked, отдельный шаг.
|
||||
|
||||
## 7. Что НЕ делается в этом дизайне
|
||||
|
||||
- **НЕ удаляется** ProjectsView.vue clearable workaround (`::after content '✕'`). Отдельный hygiene-task post-CTO-19 — может быть выполнен опционально.
|
||||
- **НЕ мигрирует** mdi-*строки на lucide-* (path β explicitly rejected заказчиком).
|
||||
- **НЕ устанавливаются** дополнительные icon packages (`@mdi/font`, `heroicons`, `tabler-icons` etc.).
|
||||
- **НЕ меняется** Vuetify theme / defaults / palette / typography.
|
||||
- **НЕ изменяется** narrative ТЗ v8.5 / schema v8.20 / Pravila / PSR_v1 / Tooling.
|
||||
- **НЕ trigger** новые open Q-items (CTO-19 — единственный affected; новых not opened).
|
||||
|
||||
## 8. Открытые вопросы по дизайну
|
||||
|
||||
Нет — design self-contained. Mapping table 78 entries — генерируется automatically на implementation phase через iterate over `grep -roh "mdi-[a-z][a-z0-9-]*" app/resources/js | sort -u`. Best-fit decisions — Claude's call по semantic similarity с opportunity для пользовательской correction post-implementation.
|
||||
|
||||
---
|
||||
|
||||
**Approved**: 13.05.2026 через AskUserQuestion brainstorming flow (path ii Lucide migration → scope α aliases-only).
|
||||
|
||||
**Next:** invoke `superpowers:writing-plans` для detailed implementation plan.
|
||||
@@ -1,322 +0,0 @@
|
||||
# Portal Full Audit #2 — Design (2026-05-13)
|
||||
|
||||
> **Версия:** 1.0 от 2026-05-13
|
||||
> **Триггер:** заказчик «продолжим аудит экономия 0%» (continuation после CTO-19 closure + plan5→main merge `21262ef`)
|
||||
> **Режим:** экономия 0% — максимальное всеобъемлющее качество, без любых скипов
|
||||
> **HEAD на старте:** `main` `21262ef` (Merge plan5-frontend-projects → main, 120 commits ahead `615db99`)
|
||||
> **Предыдущий аудит:** [../audits/2026-05-12-portal-full-audit-report.md](../audits/2026-05-12-portal-full-audit-report.md) (10/10 Q-items closed)
|
||||
|
||||
---
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Полный 14-phase портальный аудит на main `21262ef` post-merge plan5→main. Сравнение с baseline 12.05.2026, обнаружение drift'а от 120 закоммиченных изменений (Plan 5 frontend Tasks 7-11, Quiet Luxury portal redesign, CTO-19 Lucide migration, Q.DEFER.003/004 closures, audit-fix tail). Закрытие residuals + новая Phase 14 pre-production readiness.
|
||||
|
||||
## 2. Scope-decisions (согласованы с заказчиком)
|
||||
|
||||
| Dimension | Выбрано | Альтернативы рассмотрены |
|
||||
|---|---|---|
|
||||
| Тип audit'а | Новый audit pass от main `21262ef` | (B) доразбор deferred / (C) Pa11y residual / (D) квирк 62 hygiene — отложены |
|
||||
| Глубина | Full 13-phase + Phase 14 pre-prod readiness | (A) full 13 / (B) core 6 / (C) delta-mode — отвергнуты |
|
||||
| Fix-стратегия | Hybrid: P0+P1 → atomic commits на main; P2/P3 → только findings.md | (A) fix-during-audit all / (B) audit-only / (C) per-phase gate — отвергнуты |
|
||||
| Branch policy | main directly, atomic commits, push в конце через `Bash(git push origin main:*)` | feature branch + PR — отвергнут (continuation pattern) |
|
||||
|
||||
## 3. Artifacts (выходные)
|
||||
|
||||
Все в `docs/superpowers/audits/`:
|
||||
|
||||
- **`2026-05-13-portal-full-audit.md`** — план (адаптация 12.05 плана к 2026-05-13 HEAD)
|
||||
- **`2026-05-13-portal-full-audit-findings.md`** — детальные findings per phase, P0/P1/P2/P3 + FIX-NOW/FIX-DEFER/BLOCKED tags, ≥3 гипотезы per non-trivial finding (per economy 0% + Pravila §4.5 systematic-debugging)
|
||||
- **`2026-05-13-portal-full-audit-blocked.md`** — Q-items (Q.HARD/Q.PRODUCT/Q.DEFER/Q.INFO) с «что нужно от заказчика» + «план Б»
|
||||
- **`2026-05-13-portal-full-audit-report.md`** — summary verdict 🟢/🟡/🔴 + метрики до/после + commits list + lessons captured
|
||||
|
||||
**Доп. артефакты:**
|
||||
|
||||
- Screenshots в `audit-screens/2026-05-13/` (24 views Phase 5).
|
||||
- Memory updates: новые квирки в `feedback_environment.md`, baseline updates в `project_state.md` после Phase 9.
|
||||
|
||||
## 4. Phases (14 шт.)
|
||||
|
||||
### Phase 0 — Pre-flight (~5 мин)
|
||||
|
||||
- `git status --short` + `git log --oneline -10` → подтвердить HEAD = `21262ef`, working tree clean.
|
||||
- `git diff HEAD~5 --stat` — что менялось в последних 5 коммитах.
|
||||
- Skeleton 4 audit-файлов в `docs/superpowers/audits/2026-05-13-portal-full-audit-{plan,findings,blocked,report}.md`.
|
||||
- Inventory untracked workdir после CTO-19 (Lucide migration screenshots, mapping changes).
|
||||
- Verify deps installed: `composer install --quiet` + `npm ci --legacy-peer-deps` (per квирк 74).
|
||||
|
||||
### Phase 1 — Static analysis (parallel ×4 subagents, ~20 мин)
|
||||
|
||||
Single message с 4 параллельными `Agent` tool calls (per `superpowers:dispatching-parallel-agents`):
|
||||
|
||||
- **Subagent A (Backend) — `subagent_type: general-purpose`:** `composer pint:test` + `composer stan` + `composer audit --locked`. Return raw exit codes + полный stderr/stdout, не summary.
|
||||
- **Subagent B (Frontend) — `subagent_type: general-purpose`:** `npm run lint:vue` + `npx vue-tsc --noEmit` + `npx prettier --check .` + `npx knip --production`. Raw output.
|
||||
- **Subagent C (Docs) — `subagent_type: general-purpose`:** `npm run lint:md` + `npm run spell` + `npm run links`. Raw output.
|
||||
- **Subagent D (SQL) — `subagent_type: general-purpose`:** `npx squawk lint app/database/migrations/` + `pg_format db/schema.sql > /tmp/schema-fmt.sql && diff db/schema.sql /tmp/schema-fmt.sql | wc -l`. Raw output.
|
||||
|
||||
**Я принимаю все решения по findings**, subagent'ы только собирают данные.
|
||||
|
||||
### Phase 2 — Test suites (sequential, ~10 мин)
|
||||
|
||||
В main session:
|
||||
|
||||
1. `composer test` без `--parallel` (квирк 62 — ожидаемые sequential failures из cumulative state). Полный output.
|
||||
2. `composer test -- --parallel --recreate-databases` (per квирк 73 — для long sessions).
|
||||
3. `cd app && npx vitest run --reporter=verbose` (без coverage — Phase 13 отдельно).
|
||||
4. `cd app && npm run story:build` (Histoire full, не watch).
|
||||
5. `cd app && npm run build` (Vite production).
|
||||
|
||||
### Phase 3 — Schema integrity (Boost MCP, ~5 мин)
|
||||
|
||||
Через `mcp__laravel-boost__database-query` к dev `liderra`:
|
||||
|
||||
```sql
|
||||
SELECT count(*) FROM pg_tables WHERE schemaname='public';
|
||||
SELECT count(*) FROM pg_indexes WHERE schemaname='public';
|
||||
SELECT count(*) FROM pg_policies WHERE schemaname='public';
|
||||
SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON p.pronamespace=n.oid WHERE n.nspname='public';
|
||||
SELECT count(*) FROM pg_trigger WHERE tgisinternal=false;
|
||||
SELECT count(*) FROM pg_inherits; -- partition relationships
|
||||
```
|
||||
|
||||
Diff с CLAUDE.md §0/§2 dev-actual baseline (75/102/289/39/5/19/0). Orphan FK check через `mcp__laravel-boost__database-schema`.
|
||||
|
||||
### Phase 4 — Security (~10 мин)
|
||||
|
||||
**Methodology gap closure (Pravila v1.12 §4.6):**
|
||||
|
||||
1. **ПЕРВЫМ** `ls .github/workflows/` (enumerate CI infra ДО semgrep verdict) — закрывает audit gap от 12.05.
|
||||
2. `./bin/gitleaks.exe detect --no-banner --redact` (full history).
|
||||
3. `composer audit --locked`.
|
||||
4. Production secrets grep:
|
||||
- `grep -rE "AKIA[0-9A-Z]{16}|SK[a-z0-9]{32}" app/ --include="*.php"`
|
||||
- проверка `.env.example` ↔ `app/config/*.php` references на полноту (нет ли missing env keys).
|
||||
|
||||
### Phase 5 — UI smoke (Playwright MCP, ~30 мин)
|
||||
|
||||
Main session (не subagent — MCP context inheritance не работает).
|
||||
|
||||
- DemoSeeder seed: `cd app && php artisan db:seed --class=DemoSeeder`.
|
||||
- Laravel server: `cd app && php artisan serve` (background).
|
||||
- Vite dev server: `cd app && npm run dev` (background).
|
||||
- 24 views per 12.05 table (auth ×7, main ×8, admin ×8, error ×1).
|
||||
- **CTO-19 verification:** Lucide icons rendering correctly на всех 24 views (custom Vuetify `IconSet` в `app/resources/js/plugins/vuetify.ts`, 103-entry mapping).
|
||||
- Login flow: admin@demo.local / password → POST /api/auth/login → /dashboard.
|
||||
- Logout flow: avatar dropdown → POST /api/auth/logout → /login.
|
||||
- Screenshots → `audit-screens/2026-05-13/01-login.png` … `24-error-404.png`.
|
||||
|
||||
### Phase 6 — Cross-doc integrity (~10 мин)
|
||||
|
||||
Версии 7 нормативных файлов (factual vs memory claims):
|
||||
|
||||
| Документ | Path | Expected (memory) |
|
||||
|---|---|---|
|
||||
| CLAUDE.md | `CLAUDE.md` | v1.91 |
|
||||
| Pravila | `docs/Pravila_raboty_Claude_v1_1.md` | v1.12 |
|
||||
| PSR_v1 | `docs/Plugin_stack_rules_v1.md` | v2.0 |
|
||||
| Tooling | `docs/Tooling_v8_3.md` | v1.16 |
|
||||
| Реестр | `docs/Открытые_вопросы_v8_3.md` | v1.83 |
|
||||
| Схема | `db/schema.sql` | v8.20 (per memory project_state) |
|
||||
| README | `docs/README_АРХИВ_v8_5.md` | v8.5 |
|
||||
|
||||
Плюс:
|
||||
|
||||
- `routes/web.php` explicit `Route::view` list completeness check (12.05 finding — `/reset`, `/projects`, `/admin/*` были missing; fixed `b9038bc`).
|
||||
- Vue Router routes inventory (`resources/js/router/index.ts`) vs views inventory (`resources/js/views/**`).
|
||||
- Memory description ↔ файлы факт (forward-stale check как Q.DEFER.001).
|
||||
|
||||
### Phase 7 — Categorize (~5 мин)
|
||||
|
||||
Taxonomy:
|
||||
|
||||
- **Severity:** P0 (блокирует prod / data corruption / security) → P1 (failing test / type error / a11y) → P2 (warning / style / stale doc) → P3 (cosmetic).
|
||||
- **Fix-eligibility:** FIX-NOW (≤30 мин) → FIX-DEFER (>1 час) → BLOCKED (hard-stop из decision-tree).
|
||||
|
||||
Severity rollup таблица per phase + total. Fix-queue ordered list для Phase 8.
|
||||
|
||||
### Phase 8 — Fix loop (hybrid policy)
|
||||
|
||||
- **Только P0+P1** атомарными коммитами на main.
|
||||
- **P2+P3** → только запись в findings.md (без commits).
|
||||
- Self-review §8 после каждых 3 коммитов: `git diff HEAD~3..HEAD --stat`.
|
||||
- После каждого Edit/Write на code → relevant тесты (Pest/Vitest).
|
||||
- Перед каждым commit → `lefthook run pre-commit --all-files`.
|
||||
- Никаких `--no-verify`, `--amend`, `--no-gpg-sign`.
|
||||
|
||||
### Phase 9 — Final regression verification
|
||||
|
||||
Re-run baseline (per economy 0% — full output, не summary):
|
||||
|
||||
- `composer test -- --parallel --recreate-databases` — ожидаемо ≥742 / ≥739 passed / 0 failed / ≤3 skipped.
|
||||
- `cd app && npx vitest run --reporter=verbose` — ожидаемо ≥88 files / ≥683 passed / 0 failed.
|
||||
- `cd app && npm run build` — exit 0, без warnings.
|
||||
- `cd app && npm run story:build` — ожидаемо ≥35 stories / ≥63 variants.
|
||||
- Если регрессия → systematic-debugging (≥3 гипотезы) → fix или rollback.
|
||||
|
||||
### Phase 10 — Live Pa11y / axe-core (~20 мин)
|
||||
|
||||
**Guest URLs (4) через Pa11y CLI:**
|
||||
|
||||
- `npx pa11y --standard WCAG2AA --timeout 30000 --wait 1500 http://127.0.0.1:8000/login`
|
||||
- `…/register`
|
||||
- `…/forgot`
|
||||
- `…/no-such-path-anywhere` (ErrorView 404)
|
||||
|
||||
**Auth-required views (16) через Playwright MCP + axe-core 4.10 CDN inject:**
|
||||
|
||||
Per Q.DEFER.004 false-alarm lesson + Pravila v1.12 §4.6 UI-refactor visual smoke:
|
||||
|
||||
1. `mcp__playwright__browser_navigate` → URL.
|
||||
2. **`await new Promise((r) => setTimeout(r, 500))`** через `browser_evaluate` (HMR race quirk).
|
||||
3. Hard reload (`browser_navigate` again).
|
||||
4. `browser_evaluate` inject axe-core 4.10 CDN script.
|
||||
5. `browser_evaluate` `axe.run()` + parse violations.
|
||||
|
||||
URLs: /dashboard, /deals, /kanban, /projects, /reports, /billing, /settings, /reminders + 8 admin (/admin/tenants, /admin/tenants/1, /admin/billing, /admin/incidents, /admin/system, /admin/pricing-tiers, /admin/supplier-prices, /admin/impersonation).
|
||||
|
||||
**Закрытие Q.DEFER.002 residual** — auth-required coverage gap.
|
||||
|
||||
### Phase 11 — TODO/FIXME sweep (~5 мин)
|
||||
|
||||
```bash
|
||||
# В Grep tool (НЕ Bash):
|
||||
pattern: '\b(TODO|FIXME|XXX|HACK)\b'
|
||||
path: app/
|
||||
output_mode: content
|
||||
-n: true
|
||||
head_limit: 0 # unlimited per economy 0%
|
||||
```
|
||||
|
||||
Категоризация: MVP-defer ⏸ Б-1 / Feature-defer (Plan 6+) / Production-readiness / Test infra / False-positive.
|
||||
|
||||
### Phase 12 — Bundle analyzer (~10 мин)
|
||||
|
||||
1 subagent: `cd app && BUILD_ANALYZE=1 npm run build:analyze` → парсит `storage/bundle-analyze.html` (rollup-plugin-visualizer). Raw top-15 chunks list. Сравнение с 12.05 baseline (VBtn 184 kB / KanbanView 182 kB / app 131 kB).
|
||||
|
||||
### Phase 13 — Vitest coverage (~5 мин)
|
||||
|
||||
1 subagent: `cd app && npx vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=text` через `@vitest/coverage-v8`. Raw coverage tables. Сравнение с 12.05 baseline (Stmts 75% / Branch 75% / Funcs 67% / Lines 77%) + Q.DEFER.003 post-closure (Stmts 78.67% / Branch 76.21% / Funcs 70.56% / Lines 80.89%).
|
||||
|
||||
### Phase 14 — Pre-production readiness (новое, ~20 мин)
|
||||
|
||||
Main session:
|
||||
|
||||
- **14.1 Sentry integration:** `Grep -E "TODO\(production\)|Sentry::captureException" app/`. Status `ProcessWebhookJob.php:375` (12.05 finding) + any new TODOs.
|
||||
- **14.2 DB roles deployment:** Read `db/00_create_roles.sql` + `db/02_grants.sql`, проверка structure (5 ролей `crm_*` + grants matrix), prod parity expectations.
|
||||
- **14.3 Mock-data prod-gate (Q.PRODUCT.002 revisit):** проверка что решение (B) prod-fallback всё ещё актуально; считаем gzip overhead `mockDeals.ts` + `mockAdmin.ts` в production bundle (Phase 12 cross-link).
|
||||
- **14.4 CI workflows audit:** `ls .github/workflows/` + Read каждого workflow. Coverage: SAST (Semgrep `sast.yml` per Q.INFO.001), tests, build, deploy.
|
||||
- **14.5 Environment validation:** diff `app/.env.example` keys vs `Grep "env\(.*['\"](\w+)['\"]" app/config/`. Missing keys → P1.
|
||||
- **14.6 Queue/cron:** `php artisan schedule:list` + `php artisan queue:work --once --tries=1` smoke + verify `partitions:create-months` schedule entry.
|
||||
- **14.7 Backup/log rotation:** Grep `BACKUP|log-rotation|logrotate` в `docs/`, `app/config/logging.php` channels review.
|
||||
- **14.8 Deployment runbook:** `Glob deploy*.md DEPLOY*.md RUNBOOK*.md` — присутствие/отсутствие documented.
|
||||
|
||||
Финал: push на `origin/main`.
|
||||
|
||||
## 5. Subagent dispatch policy (raw output mandate per economy 0%)
|
||||
|
||||
| Phase | Subagent? | Single-message parallel? |
|
||||
|---|---|---|
|
||||
| 1 (Backend/Frontend/Docs/SQL) | ×4 parallel | YES — single message с 4 `Agent` calls |
|
||||
| 5 (UI smoke) | NO — MCP context inheritance broken | n/a |
|
||||
| 10 (Pa11y) | NO — MCP context | n/a |
|
||||
| 11 (TODO sweep) | NO — direct Grep | n/a |
|
||||
| 12 (Bundle analyzer) | YES — 1 | n/a |
|
||||
| 13 (Vitest coverage) | YES — 1 | n/a |
|
||||
| 14 (Pre-prod) | NO — main session | n/a |
|
||||
|
||||
Prompts всем subagent'ам: «верни **raw** exit codes + полный output stderr+stdout, **не summary**. Решения принимаю я».
|
||||
|
||||
## 6. Verification gates
|
||||
|
||||
**После Edit/Write:**
|
||||
|
||||
| Файл-тип | Тест |
|
||||
|---|---|
|
||||
| PHP code | `composer test -- --filter=<closest>` или full Pest `--parallel` |
|
||||
| Vue/TS frontend | `npx vitest run <closest-spec>` или full Vitest |
|
||||
| schema/migration | Pest RLS + model smoke + `mcp__laravel-boost__database-schema` re-check |
|
||||
| routes/web.php | `php artisan route:list` + Pest feature tests |
|
||||
| CLAUDE.md/Pravila/PSR_v1/Tooling | `claude-md-management:claude-md-improver` skill (не direct Edit) + markdownlint + lychee |
|
||||
|
||||
**Перед commit:** `lefthook run pre-commit --all-files`.
|
||||
|
||||
**Pre-push (один раз в конце):** `lefthook run pre-push` (gitleaks-full-history + lychee strict).
|
||||
|
||||
## 7. Error handling и lessons-applied
|
||||
|
||||
Guardrails из 12.05 audit + post-audit (Pravila v1.12 + memory квирки 74-76):
|
||||
|
||||
- **Pravila v1.12 §4.6 audit methodology gap:** Phase 4 SAST → `ls .github/workflows/` FIRST.
|
||||
- **Pravila v1.12 §4.6 UI-refactor visual smoke:** если фикс задеёт UI → Playwright visual smoke + axe-core re-check (CTO-19 lesson — unit tests jsdom insufficient для Vuetify-internal defaults).
|
||||
- **Q.DEFER.004 axe-core HMR race:** `setTimeout 500ms` + hard reload перед `axe.run()`.
|
||||
- **Квирк 62/73 Pest cumulative state:** для full-suite после long session — `--recreate-databases` flag обязателен.
|
||||
- **Pravila v1.12 §4.7 п.4 plans relative paths:** в audit-plan/spec ссылки на `app/` через `../../../app/...` (lychee semantics; прецедент CTO-19 fixup `f6e1e64`).
|
||||
- **Квирк 74:** `npm install` требует `--legacy-peer-deps` (Histoire 1.0.0-beta.1 peerDep).
|
||||
- **Квирк 75:** Vuetify-internal default mdi-* names — grep также по `node_modules/vuetify/lib/iconsets/mdi*`.
|
||||
|
||||
## 8. Decision-tree hard-stops (per Pravila §13 + PSR_v1 R0.6)
|
||||
|
||||
Эти findings автоматически → BLOCKED, не FIX:
|
||||
|
||||
- Schema.sql semantic changes (не cosmetic).
|
||||
- New packages (composer require / npm install кроме devDependencies update — даже это требует согласования).
|
||||
- ТЗ-правки (`docs/CRM_bp-gr_Инструкция_v8_5.md`).
|
||||
- Реестр-вопросы closing (`Б-/CTO-/DO-/Ю-/Диз-/OPEN-`) без явного «закрываем».
|
||||
- Force-push на main / `--no-verify` / hook bypass / `--no-gpg-sign`.
|
||||
- Production DB connection.
|
||||
- ПДн / токены / API-ключи в commits (gitleaks gate).
|
||||
|
||||
## 9. Testing/verification план
|
||||
|
||||
**Baseline (фиксируем в Phase 2 как точку отсчёта):**
|
||||
|
||||
| Метрика | Expected (post-merge `21262ef`) |
|
||||
|---|---|
|
||||
| Pest `--parallel --recreate-databases` | 742 / 739 passed / 0 failed / 3 skipped |
|
||||
| Pest sequential | 742 / 736 passed / 3 failed (квирк 62) / 3 skipped |
|
||||
| Vitest | 88 files / 683 passed / 3 skipped |
|
||||
| Vite build | ~3.5s, 0 warnings |
|
||||
| Histoire build | 35 stories / 63 variants |
|
||||
| ESLint | 0 errors |
|
||||
| vue-tsc | 0 errors |
|
||||
| Prettier --check | 0 issues |
|
||||
| markdownlint | 1 (untracked design.md из прошлой сессии) |
|
||||
| cspell | ≤18 (mixed-script artifacts) |
|
||||
| lychee | 0 broken |
|
||||
| gitleaks (full history) | 0 leaks |
|
||||
| RLS policies | 39 |
|
||||
|
||||
**Regression gate (Phase 9):** все метрики ≥ baseline. Регрессия → rollback или fix перед push.
|
||||
|
||||
## 10. Out of scope
|
||||
|
||||
Явно НЕ делаем в этой сессии:
|
||||
|
||||
- pgFormatter swap schema.sql (Q.HARD.002 — закрыт «не трогать»).
|
||||
- Q.DEFER.003 sub-A api/* — уже closed `95f5f94`.
|
||||
- Pest квирк 62 corrective hygiene (test-DB DROP+CREATE+ICU+migrate) — отдельная задача.
|
||||
- CSS code-splitting KanbanView vuedraggable / NewDealDialog / DealDetailDrawer — Phase 12 P2 defer.
|
||||
- Sentry actual integration (только аудит TODO statuses, не fix).
|
||||
- Б-1 saas-admin SSO middleware (BLOCKED).
|
||||
|
||||
## 11. Success criteria
|
||||
|
||||
- 14 phases completed.
|
||||
- All P0 + P1 findings либо fixed (atomic commit) либо BLOCKED (явно в blocked.md с reason).
|
||||
- Phase 9 regression gate passed: 0 регрессий vs baseline.
|
||||
- Pre-push lefthook clean (gitleaks-full-history + lychee).
|
||||
- 4 audit-docs committed.
|
||||
- Memory updates если найдены новые квирки.
|
||||
- Push на `origin/main` через `Bash(git push origin main:*)`.
|
||||
- Verdict 🟢 / 🟡 / 🔴 в report.md с обоснованием.
|
||||
|
||||
## 12. Известные ограничения этой сессии
|
||||
|
||||
- **Не верифицировал** до старта: production environment parity (Phase 14.4-14.7) — будет в Phase 14.
|
||||
- **Не верифицировал** до старта: actual coverage delta после Q.DEFER.003 closure beyond memory claim — будет в Phase 13.
|
||||
- **Зависит от MCP availability:** Playwright + Boost MCP должны быть запущены (default).
|
||||
- **Зависит от dev servers:** Laravel + Vite background processes для Phase 5/10.
|
||||
|
||||
---
|
||||
|
||||
*Конец design'а. После approval — invoke writing-plans skill для конкретного implementation plan.*
|
||||
@@ -1,231 +0,0 @@
|
||||
# Design Spec: ProjectDetailsDrawer — поле «Регионы»
|
||||
|
||||
**Дата:** 2026-05-14 (вечер)
|
||||
**Триггер:** Запрос заказчика после visual smoke ProjectDetailsDrawer (screenshot 14.05 вечер) — добавить редактирование региона в drawer-форму.
|
||||
**Текущее состояние:** ProjectDetailsDrawer MVP shipped (commits `9d88955..0d7f505`), редактирует name/limit/days. `region_mask` + `region_mode` отправляются в PATCH payload (Task 5 уже их включает), но UI-контрола нет — поля иммутабельны для пользователя.
|
||||
**Связано:** [PDD design spec §7](./2026-05-14-project-details-drawer-design.md) Out-of-plan пункт "Region multi-select autocomplete" — этой spec'ой закрывается.
|
||||
|
||||
---
|
||||
|
||||
## 1. Поведение (UX-контракт)
|
||||
|
||||
| Состояние project | `selectedRegions[]` в UI | `form.region_mask` | `form.region_mode` |
|
||||
|---|---|---|---|
|
||||
| `region_mask=0` (вся РФ по умолчанию) | пустой массив | 0 | `'include'` |
|
||||
| `region_mask=6` (bits 1+2: Адыгея + Башкортостан) | `[1, 2]` → 2 chips | 6 | (legacy: сохраняем `include` или `exclude` как пришло) |
|
||||
| User добавляет регион (например code:3) | `[1, 2, 3]` → 3 chips | encoded: 14 (bits 1+2+3) | `'exclude'` |
|
||||
| User снимает все | `[]` → 0 chips | 0 | `'include'` |
|
||||
|
||||
**Семантика бэкенда (legacy за пределы Task 5):** `region_mode='include'+mask=0` ≡ «принимать всю РФ»; `region_mode='exclude'+mask≠0` ≡ «принимать всю РФ, КРОМЕ перечисленных». Эта конвенция установлена в [NewProjectDialog.vue:141-153](../../../app/resources/js/views/projects/NewProjectDialog.vue#L141) и сохраняется без изменений.
|
||||
|
||||
**Edge case:** project уже сохранён с `region_mode='include'+mask≠0` (legacy/inconsistent данные) — UI decompose'ит mask в codes, показывает chips. Если пользователь нажмёт «сохранить» — watch перепишет mode='exclude'. Это normalization (не deletion) — приемлемо.
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
**Подход A: In-place port** (утверждён 2026-05-14). Альтернативы (composable extraction, defer) отклонены — single use, YAGNI.
|
||||
|
||||
**Изменения:**
|
||||
|
||||
- [app/resources/js/components/projects/ProjectDetailsDrawer.vue](../../../app/resources/js/components/projects/ProjectDetailsDrawer.vue) — добавить:
|
||||
- Import `REGIONS` из `../../constants/regions`.
|
||||
- `const selectedRegions = ref<number[]>([]);`
|
||||
- `const selectableRegions = REGIONS.filter(r => r.code !== 0);` (исключить «Вся РФ» sentinel — fixes latent NewProjectDialog bug).
|
||||
- Helper `maskToCodes(mask: number): number[]` — reverse-decompose bits 1..31.
|
||||
- В `reseedFromProject(p)`: `selectedRegions.value = maskToCodes(p.region_mask ?? 0);`
|
||||
- Watch `selectedRegions` → encode в `form.region_mask` + `form.region_mode`.
|
||||
- Template: `<v-autocomplete v-model="selectedRegions">` блок между «Лимит» и «Дни приёма».
|
||||
- [app/tests/Frontend/ProjectDetailsDrawer.spec.ts](../../../app/tests/Frontend/ProjectDetailsDrawer.spec.ts) — +3 теста.
|
||||
|
||||
**Backend — без изменений.** [UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php) уже validates `region_mask` + `region_mode`. Task 5 onSave уже их шлёт.
|
||||
|
||||
---
|
||||
|
||||
## 3. Компоненты и интерфейсы
|
||||
|
||||
### `ProjectDetailsDrawer.vue` — script additions
|
||||
|
||||
```ts
|
||||
import { REGIONS } from '../../constants/regions';
|
||||
|
||||
const selectedRegions = ref<number[]>([]);
|
||||
const selectableRegions = REGIONS.filter(r => r.code !== 0);
|
||||
|
||||
function maskToCodes(mask: number): number[] {
|
||||
const codes: number[] = [];
|
||||
for (let i = 1; i <= 31; i++) if (mask & (1 << i)) codes.push(i);
|
||||
return codes;
|
||||
}
|
||||
|
||||
// inside reseedFromProject(p), AFTER form.region_mask line:
|
||||
selectedRegions.value = maskToCodes(p.region_mask ?? 0);
|
||||
|
||||
watch(selectedRegions, (codes) => {
|
||||
if (codes.length === 0) {
|
||||
form.region_mask = 0;
|
||||
form.region_mode = 'include';
|
||||
} else {
|
||||
form.region_mask = codes.reduce((acc, c) => (c >= 1 && c <= 31 ? acc | (1 << c) : acc), 0);
|
||||
form.region_mode = 'exclude';
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Template insertion (между Лимит и Дни)
|
||||
|
||||
```vue
|
||||
<div class="pdd-field">
|
||||
<span class="pdd-label">Регионы (пусто = вся РФ)</span>
|
||||
<v-autocomplete
|
||||
v-model="selectedRegions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details
|
||||
data-testid="pdd-regions"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
`<v-autocomplete>` уже доступен через vite-plugin-vuetify auto-import.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data flow
|
||||
|
||||
```
|
||||
Mount drawer / swap project
|
||||
→ reseedFromProject(p)
|
||||
→ form.region_mask = p.region_mask ?? 0
|
||||
→ selectedRegions.value = maskToCodes(form.region_mask)
|
||||
→ autocomplete отображает chips
|
||||
|
||||
User selects/deselects region in autocomplete
|
||||
→ selectedRegions reactive update
|
||||
→ watch fires
|
||||
→ form.region_mask + form.region_mode updated
|
||||
|
||||
Save click
|
||||
→ onSave (existing, Task 5)
|
||||
→ payload.region_mask = form.region_mask
|
||||
→ payload.region_mode = form.region_mode
|
||||
→ axios.patch(/api/projects/{id}, payload)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Error handling
|
||||
|
||||
- `p.region_mask` undefined / null → `?? 0` → empty codes → safe.
|
||||
- `mask` с битом 0 (sentinel «вся РФ» mistakenly set от bugged NewProjectDialog) → `maskToCodes` skips bit 0 (loop `for i=1..31`) → бит игнорируется в UI. Не отображается как chip. Save затем перепишет mask без bit 0.
|
||||
- `codes >31` — REGIONS array ограничен 1..31, поэтому this branch unreachable через UI. Defensive `c >= 1 && c <= 31` guard в watch на случай programmatic mutation. Plan 6: bigint upgrade.
|
||||
- Network error при Save — обрабатывается existing onSave (silently caught, не часть этой spec'и).
|
||||
|
||||
---
|
||||
|
||||
## 6. Тестирование
|
||||
|
||||
### Vitest jsdom — расширить [app/tests/Frontend/ProjectDetailsDrawer.spec.ts](../../../app/tests/Frontend/ProjectDetailsDrawer.spec.ts)
|
||||
|
||||
**Тест 1: `renders region chips when project has non-zero region_mask`**
|
||||
|
||||
```ts
|
||||
it('renders region chips when project has non-zero region_mask', async () => {
|
||||
const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' }; // bits 1+2
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
|
||||
await wrapper.vm.$nextTick();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Адыгея'); // code 1
|
||||
expect(text).toContain('Башкортостан'); // code 2
|
||||
});
|
||||
```
|
||||
|
||||
**Тест 2: `selecting regions encodes mask + sets mode='exclude'`**
|
||||
|
||||
```ts
|
||||
it('selecting regions encodes mask + sets mode=exclude', async () => {
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
|
||||
// sampleProject имеет region_mask=0, mode='include' начально
|
||||
const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' });
|
||||
await autocomplete.vm.$emit('update:model-value', [3, 5]); // 2 regions
|
||||
await wrapper.vm.$nextTick();
|
||||
// Inspect via exposed form ref OR via PATCH payload after save click
|
||||
// Easier path: spy axios + click save, assert payload.region_mask
|
||||
(axios.patch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: sampleProject } });
|
||||
await wrapper.get('[data-testid="pdd-save"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(axios.patch).toHaveBeenCalledWith(
|
||||
'/api/projects/42',
|
||||
expect.objectContaining({ region_mask: 40, region_mode: 'exclude' }), // bits 3+5 = 8+32 = 40
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**Тест 3: `clearing all regions resets mask=0 + mode='include'`**
|
||||
|
||||
```ts
|
||||
it('clearing all regions resets mask=0 + mode=include', async () => {
|
||||
const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' };
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
|
||||
const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' });
|
||||
await autocomplete.vm.$emit('update:model-value', []);
|
||||
await wrapper.vm.$nextTick();
|
||||
(axios.patch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: withRegions } });
|
||||
await wrapper.get('[data-testid="pdd-save"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(axios.patch).toHaveBeenCalledWith(
|
||||
'/api/projects/42',
|
||||
expect.objectContaining({ region_mask: 0, region_mode: 'include' }),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Visual smoke (manual, заказчик)
|
||||
|
||||
1. Открыть /projects → выбрать проект «Доставка еды (СМС)» → drawer открывается.
|
||||
2. В drawer'е увидеть поле «Регионы (пусто = вся РФ)» между Лимитом и Днями.
|
||||
3. Кликнуть в input → dropdown показывает 31 регион (без «Вся РФ»).
|
||||
4. Выбрать Москва + СПб → появляются 2 chips.
|
||||
5. Click Сохранить → toast, PATCH в Network tab содержит `region_mask` non-zero + `region_mode=exclude`.
|
||||
6. Re-open drawer того же проекта → 2 chips сохранены (reverse-decompose работает).
|
||||
7. Click ✕ на chip → chip убирается.
|
||||
8. Кнопка clearable (X справа от input) → все chips удалены, mask=0 + mode=include на save.
|
||||
|
||||
### Регрессии (must-not-break)
|
||||
|
||||
- 14 PDD unit tests + 5 integration → все green.
|
||||
- Vue-tsc / ESLint — 0 errors.
|
||||
- Pre-commit hooks — 0 issues.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
- NewProjectDialog reverse-decompose TODO (line 172) — отдельный sweep.
|
||||
- Composable `useRegionsBitmask` extraction — Plan 6 если будет 3-й consumer.
|
||||
- Bigint mask для region codes >31 (нужен schema change PostgreSQL) — Plan 6.
|
||||
- Кастомный chip-design (currently Vuetify default) — Quiet Luxury polish.
|
||||
- Mobile-адаптация autocomplete (touch keyboard / full-screen modal) — Plan 6.
|
||||
- Server-side validation на duplicate codes — UI gateways через Set semantics в v-autocomplete.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
Нет. UX-вопрос (исключить «Вся РФ» из dropdown) закрыт заказчиком 2026-05-14.
|
||||
|
||||
---
|
||||
|
||||
## 9. Связанные документы
|
||||
|
||||
- [2026-05-14-project-details-drawer-design.md](./2026-05-14-project-details-drawer-design.md) — parent design spec, §7 Out-of-plan «Region multi-select autocomplete» закрывается этой spec'ой.
|
||||
- [NewProjectDialog.vue](../../../app/resources/js/views/projects/NewProjectDialog.vue) — source recipe (lines 78-87 template + 141-153 watch).
|
||||
- [constants/regions.ts](../../../app/resources/js/constants/regions.ts) — REGIONS array (32 entries).
|
||||
- [UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php) — backend validation contract.
|
||||
@@ -1,430 +0,0 @@
|
||||
# Design Spec: Plan 6 — Subject-level regions для outbound supplier API
|
||||
|
||||
**Дата:** 2026-05-14 (вечер)
|
||||
**Триггер:** В сессии #909-debug обнаружено, что текущая колонка `projects.region_mask` — 8-битная маска федеральных округов (CHECK 0..255), а PDD-feature от 2026-05-14 (commits `4f60add` spec + `159ed3e` plan + `f982046` feat) предполагал 32-bit маску под 31 субъект РФ. Конфликт привёл к CHECK violation 500 при создании проекта. Interim A откатил regions UI; этот Plan 6 возвращает поле в правильной модели.
|
||||
**Бизнес-цель (подтверждено заказчиком 2026-05-14):** клиент выбирает гранулярный регион (один из 89 субъектов РФ) → Лидерра хранит → передаёт поставщику crm.bp-gr.ru через outbound API ([SupplierPortalClient::toPayload()](../../../app/app/Services/Supplier/SupplierPortalClient.php#L253-L265)). Поставщик фильтрует исходящие лиды на своей стороне. Внутренний routing (PhonePrefixService, LeadRouter) **не затрагивается** — он остаётся на ФО-уровне.
|
||||
**Связано:**
|
||||
|
||||
- [2026-05-14-pdd-regions-field-design.md](./2026-05-14-pdd-regions-field-design.md) — предыдущая PDD-spec, отзывается этим эпиком (32-bit→89-codes pivot)
|
||||
- [2026-05-10-supplier-integration-design.md](./2026-05-10-supplier-integration-design.md) §4.4 — supplier_projects.current_regions JSONB array (уже generic, поддержит 89)
|
||||
- [PDD plan](../plans/2026-05-14-pdd-regions-field.md) — TDD task list под отозванную модель, заменяется новым writing-plans артефактом
|
||||
|
||||
---
|
||||
|
||||
## 1. Цель и scope
|
||||
|
||||
**Цель (in-scope):**
|
||||
|
||||
- Заменить `projects.region_mask INT` (8 ФО bitmask) на `projects.regions INT[]` (subject-level array, 89 кодов)
|
||||
- UI dropdown в [NewProjectDialog.vue](../../../app/resources/js/views/projects/NewProjectDialog.vue) + [ProjectDetailsDrawer.vue](../../../app/resources/js/components/projects/ProjectDetailsDrawer.vue) с group-by федокруг
|
||||
- Backend validation в [StoreProjectRequest.php](../../../app/app/Http/Requests/StoreProjectRequest.php) + [UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php)
|
||||
- Outbound: [SyncSupplierProjectsJob::adaptProjectsForAllocator()](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L215-L231) копирует `projects.regions[]` напрямую в `supplier_projects.current_regions`
|
||||
- Schema v8.19 → v8.20 с CHANGELOG entry
|
||||
- TDD red-green coverage всех вышеуказанных слоёв
|
||||
|
||||
**НЕ цели (out-of-scope, отдельные эпики):**
|
||||
|
||||
- Удаление колонки `region_mask` и CHECK constraint — Plan 6.5 после deprecation period
|
||||
- Phone-prefix → subject matrix (1500+ DEF codes) — не нужна, поставщик сам определяет регион лида и фильтрует у себя
|
||||
- Рефакторинг [PhonePrefixService.php](../../../app/app/Services/PhonePrefixService.php) — остаётся на ФО-уровне для других use cases
|
||||
- Рефакторинг [LeadRouter.php](../../../app/app/Services/LeadRouter.php#L90) — продолжает читать `region_mask` (legacy), переключение на `regions[]` отложено до Plan 6.5
|
||||
- Изменение supplier API contract — [SupplierProjectDto.php:21](../../../app/app/Services/Supplier/Dto/SupplierProjectDto.php#L21) уже типизировано `array<int, int|string>`, нативно поддерживает 89 кодов
|
||||
- Per-subject pricing / weighted quotas — Plan 7+ (если потребуется)
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектурный подход: Approach 2 — dual-write transition
|
||||
|
||||
Из трёх рассмотренных подходов (Replace / Dual-write / Separate table) выбран **dual-write**:
|
||||
|
||||
| Подход | Pros | Cons | Решение |
|
||||
|---|---|---|---|
|
||||
| 1. Replace `region_mask` → `regions[]` | Один источник истины, чисто | Atomic migration 32 файлов; высокий риск регрессий | ❌ |
|
||||
| **2. Add `regions[]` + deprecate `region_mask`** | **Постепенно, backward-compat, low-risk, легко откатить** | **2 поля в sync transient; cleanup отдельным эпиком** | ✅ |
|
||||
| 3. Separate table `project_regions` | Нормализовано, extensible | Overkill для текущего use case; extra JOIN | ❌ |
|
||||
|
||||
**Переход:**
|
||||
|
||||
1. **Plan 6 (этот эпик):** ADD `regions INT[]` + UI/backend пишут оба поля (`regions=[...]` источник истины + `region_mask=255` legacy default)
|
||||
2. **Plan 6.5 (отдельно, не сейчас):** переключить читатели на `regions[]`, удалить dual-write, DROP COLUMN `region_mask` + `region_mode`, удалить CHECK constraint
|
||||
|
||||
---
|
||||
|
||||
## 3. Schema delta (v8.19 → v8.20)
|
||||
|
||||
### 3.1. Новая колонка `projects.regions INT[]`
|
||||
|
||||
```sql
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN regions INT[] NOT NULL DEFAULT '{}'::INT[];
|
||||
|
||||
CREATE INDEX idx_projects_regions
|
||||
ON projects USING GIN (regions);
|
||||
|
||||
COMMENT ON COLUMN projects.regions IS
|
||||
'Subject-level region filter (1..89 коды субъектов РФ). Пустой массив = вся РФ. Plan 6 (v8.20). См. resources/js/constants/regions.ts.';
|
||||
```
|
||||
|
||||
### 3.2. Deprecation note для `region_mask`
|
||||
|
||||
```sql
|
||||
-- v8.20 (Plan 6): regions[] INT[] заменяет region_mask 8-bit. DEPRECATED для удаления в Plan 6.5.
|
||||
-- Остаётся для backward-compat читателей (PhonePrefixService, LeadRouter) до отдельного эпика.
|
||||
CONSTRAINT chk_projects_region_mask_range
|
||||
CHECK (region_mask BETWEEN 0 AND 255), -- УДАЛЯЕМ в Plan 6.5
|
||||
```
|
||||
|
||||
В [db/schema.sql:811-816](../../../db/schema.sql#L811-L816) — добавить inline-комментарий `-- DEPRECATED Plan 6.5: см. regions INT[] column`.
|
||||
|
||||
### 3.3. CHANGELOG entry
|
||||
|
||||
Добавить в [db/CHANGELOG_schema.md](../../../db/CHANGELOG_schema.md):
|
||||
|
||||
```markdown
|
||||
## v8.20 от 2026-05-14 (Plan 6 — subject-level regions)
|
||||
|
||||
**Изменения:**
|
||||
- `projects` +1 колонка: `regions INT[] NOT NULL DEFAULT '{}'`
|
||||
- `projects` +1 GIN-индекс: `idx_projects_regions`
|
||||
|
||||
**Не изменено (deprecated, удаление в Plan 6.5):**
|
||||
- `projects.region_mask`, `projects.region_mode`, CHECK `chk_projects_region_mask_range`
|
||||
|
||||
**Семантика:**
|
||||
- `regions=[]` → «вся РФ» (паритет с legacy `region_mask=255 + region_mode=include`)
|
||||
- `regions=[77,78]` → проект принимает лиды только из Москвы (77) и СПб (78)
|
||||
|
||||
**Метрики schema baseline:** 63 базовых таблиц / 12 партиций / 117 индексов → **118 индексов** / 39 RLS / 5 функций / 13 триггеров.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. REGIONS constant: расширение 31 → 89
|
||||
|
||||
[app/resources/js/constants/regions.ts](../../../app/resources/js/constants/regions.ts) — текущие 32 entries (включая `code:0 = "Вся РФ"` sentinel) расширяются до 90:
|
||||
|
||||
```ts
|
||||
export interface Region {
|
||||
code: number; // 1..89, sequential по конституционному порядку
|
||||
name: string; // официальное название субъекта
|
||||
federalDistrict: number; // 1..8 (1=Центр, 2=СЗ, 3=Юг, 4=СК, 5=Приволжье, 6=Урал, 7=Сибирь, 8=ДВ)
|
||||
}
|
||||
|
||||
export const REGIONS: Region[] = [
|
||||
{ code: 0, name: 'Вся РФ', federalDistrict: 0 }, // sentinel (UI hint)
|
||||
{ code: 1, name: 'Республика Адыгея', federalDistrict: 3 /* Юг */ },
|
||||
{ code: 2, name: 'Республика Башкортостан', federalDistrict: 5 /* Приволжье */ },
|
||||
// ... (полный список 1..89 по Конституции РФ)
|
||||
{ code: 77, name: 'Москва', federalDistrict: 1 /* Центр */ },
|
||||
{ code: 78, name: 'Санкт-Петербург', federalDistrict: 2 /* СЗ */ },
|
||||
{ code: 89, name: 'Чукотский АО', federalDistrict: 8 /* ДВ */ },
|
||||
];
|
||||
|
||||
export const FEDERAL_DISTRICT_NAMES: Record<number, string> = {
|
||||
1: 'Центральный', 2: 'Северо-Западный', 3: 'Южный', 4: 'Северо-Кавказский',
|
||||
5: 'Приволжский', 6: 'Уральский', 7: 'Сибирский', 8: 'Дальневосточный',
|
||||
};
|
||||
```
|
||||
|
||||
**ASSUMPTION:** sequential 1..89 — точный порядок и список генерится по конституционному порядку (Адыгея..Чукотка). Москва+МО, СПб+ЛО, Севастополь — раздельные субъекты (89 итого).
|
||||
|
||||
**`code:0` sentinel** — остаётся для UI hint «Вся РФ», но в БД не пишется (соответствует пустому массиву `[]`). Должен быть исключён из selectableRegions через `.filter(r => r.code !== 0)`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Backend validation
|
||||
|
||||
### 5.1. StoreProjectRequest (POST /api/projects)
|
||||
|
||||
[app/app/Http/Requests/StoreProjectRequest.php](../../../app/app/Http/Requests/StoreProjectRequest.php) — изменения в `rules()`:
|
||||
|
||||
**Удаляются:**
|
||||
|
||||
```php
|
||||
'region_mask' => ['required', 'integer', 'min:0'],
|
||||
'region_mode' => ['required', Rule::in(['include', 'exclude'])],
|
||||
```
|
||||
|
||||
**Добавляются:**
|
||||
|
||||
```php
|
||||
'regions' => ['present', 'array'],
|
||||
'regions.*' => ['integer', 'between:1,89'],
|
||||
```
|
||||
|
||||
`present` принимает пустой массив `[]`; `between:1,89` — soft validation (без БД-CHECK constraint).
|
||||
|
||||
### 5.2. UpdateProjectRequest (PATCH /api/projects/{id})
|
||||
|
||||
[app/app/Http/Requests/UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php) — аналогично, `sometimes` вместо `present`:
|
||||
|
||||
```php
|
||||
'regions' => ['sometimes', 'array'],
|
||||
'regions.*' => ['integer', 'between:1,89'],
|
||||
```
|
||||
|
||||
### 5.3. ProjectService::create() и update()
|
||||
|
||||
[app/app/Services/Project/ProjectService.php:182-199](../../../app/app/Services/Project/ProjectService.php#L182-L199):
|
||||
|
||||
```php
|
||||
public function create(Tenant $tenant, array $data): Project
|
||||
{
|
||||
$limit = (int) ($tenant->limits['max_projects'] ?? 10);
|
||||
$current = Project::where('tenant_id', $tenant->id)->active()->count();
|
||||
if ($current >= $limit) {
|
||||
throw new HttpResponseException(response()->json([
|
||||
'message' => "Достигнут лимит проектов ({$limit}). Смените тариф.",
|
||||
], 403));
|
||||
}
|
||||
|
||||
$data['tenant_id'] = $tenant->id;
|
||||
$data['is_active'] = true;
|
||||
$data['regions'] = $data['regions'] ?? [];
|
||||
// Plan 6 dual-write: regions[] = источник истины; region_mask/mode legacy для PhonePrefixService.
|
||||
// Cleanup в Plan 6.5 после переключения читателей.
|
||||
$data['region_mask'] = 255;
|
||||
$data['region_mode'] = 'include';
|
||||
|
||||
$project = Project::create($data);
|
||||
SyncSupplierProjectJob::dispatch($project->id);
|
||||
return $project->fresh();
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4. Project model
|
||||
|
||||
[app/app/Models/Project.php](../../../app/app/Models/Project.php) — добавить `regions` в `$fillable` и в `$casts`:
|
||||
|
||||
```php
|
||||
protected $fillable = [
|
||||
/* existing */,
|
||||
'regions',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
/* existing */,
|
||||
'regions' => 'array',
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. UI — NewProjectDialog + ProjectDetailsDrawer
|
||||
|
||||
### 6.1. NewProjectDialog.vue
|
||||
|
||||
[app/resources/js/views/projects/NewProjectDialog.vue](../../../app/resources/js/views/projects/NewProjectDialog.vue):
|
||||
|
||||
**Template (на месте interim A `<v-alert>` placeholder, между «Лимит» и «Дни недели»):**
|
||||
|
||||
```vue
|
||||
<v-autocomplete
|
||||
v-model="form.regions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
label="Регионы (пусто = вся РФ)"
|
||||
multiple chips clearable
|
||||
density="comfortable"
|
||||
data-testid="regions-autocomplete"
|
||||
>
|
||||
<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>
|
||||
```
|
||||
|
||||
**Script:**
|
||||
|
||||
```ts
|
||||
import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../constants/regions';
|
||||
|
||||
const selectableRegions = REGIONS.filter(r => r.code !== 0); // exclude "Вся РФ" sentinel
|
||||
|
||||
const form = reactive({
|
||||
/* existing */,
|
||||
regions: [] as number[], // NEW (vместо region_mask/region_mode в form-state)
|
||||
});
|
||||
|
||||
// region_mode + region_mask убираются полностью из form / payload.
|
||||
// Backend dual-write устанавливает их сам в ProjectService::create.
|
||||
```
|
||||
|
||||
Удалить error banner placeholder (теперь его место занимает v-autocomplete).
|
||||
|
||||
### 6.2. ProjectDetailsDrawer.vue
|
||||
|
||||
[app/resources/js/components/projects/ProjectDetailsDrawer.vue](../../../app/resources/js/components/projects/ProjectDetailsDrawer.vue) — аналогичная вставка autocomplete, refactor 3 existing region-tests на 89-code модель. `maskToCodes()` helper удаляется (больше не нужен).
|
||||
|
||||
### 6.3. Project type в projectsStore
|
||||
|
||||
[app/resources/js/stores/projectsStore.ts](../../../app/resources/js/stores/projectsStore.ts) — добавить `regions?: number[]` в `Project` interface.
|
||||
|
||||
---
|
||||
|
||||
## 7. Outbound mapping — SyncSupplierProjectsJob
|
||||
|
||||
[app/app/Jobs/Supplier/SyncSupplierProjectsJob.php:215-231](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L215-L231):
|
||||
|
||||
**До (ФО-bitmask конверсия):**
|
||||
|
||||
```php
|
||||
$regionMask = (int) $p->region_mask;
|
||||
$obj->regions = $regionMask === 255
|
||||
? []
|
||||
: $this->bitmaskToList($regionMask, 8);
|
||||
```
|
||||
|
||||
**После (direct copy 89-code array):**
|
||||
|
||||
```php
|
||||
// Plan 6: projects.regions[] напрямую копируется в supplier_projects.current_regions.
|
||||
// Empty array = "вся РФ" (паритет с supplier API semantics).
|
||||
$obj->regions = array_values((array) $p->regions);
|
||||
```
|
||||
|
||||
`bitmaskToList()` остаётся для `workdays` (delivery_days_mask, 7 битов). Не удаляется.
|
||||
|
||||
`SupplierQuotaAllocator::unionInts()` [SupplierQuotaAllocator.php:57](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L57) уже работает с массивами — no changes.
|
||||
|
||||
`SupplierPortalClient::toPayload()` [SupplierPortalClient.php:261](../../../app/app/Services/Supplier/SupplierPortalClient.php#L261) уже шлёт `regions: array` — поддержит 89 codes нативно.
|
||||
|
||||
---
|
||||
|
||||
## 8. Data flow
|
||||
|
||||
```
|
||||
User selects regions in <v-autocomplete>
|
||||
→ form.regions = [77, 78]
|
||||
→ POST /api/projects { ..., regions: [77, 78] }
|
||||
→ StoreProjectRequest validates (array, each 1..89)
|
||||
→ ProjectService::create
|
||||
→ $data['regions'] = [77, 78]
|
||||
→ $data['region_mask'] = 255 (legacy dual-write)
|
||||
→ $data['region_mode'] = 'include' (legacy dual-write)
|
||||
→ Project::create → INSERT INTO projects (regions, region_mask, region_mode, ...)
|
||||
→ SyncSupplierProjectJob::dispatch
|
||||
→ Redis queue (job runs async via queue worker)
|
||||
→ adaptProjectsForAllocator: $obj->regions = $p->regions (direct copy)
|
||||
→ SupplierQuotaAllocator → SupplierProjectDto(regions: [77, 78])
|
||||
→ SupplierPortalClient::toPayload → POST к crm.bp-gr.ru { regions: [77, 78] }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Migration & data backfill
|
||||
|
||||
**Existing projects (production data):**
|
||||
|
||||
- При ALTER TABLE с `DEFAULT '{}'::INT[]` все existing rows получают `regions=[]`
|
||||
- `regions=[]` ≡ «вся РФ» — нейтральный default, **не теряет данные** ([потому что region_mask=255 default — тоже «вся РФ»])
|
||||
- Projects с `region_mask ≠ 255` (если есть в production!) — теряют ФО-фильтр. Это **acceptable trade-off**:
|
||||
- DEV — нет prod data
|
||||
- PROD ещё не запущен (Б-1 блокер, ООО не зарегистрирован)
|
||||
- Альтернатива: derive `regions[]` из ФО→subject mapping в migration — overkill, добавляет complexity
|
||||
|
||||
**ASSUMPTION:** на момент Plan 6 нет production-данных с осознанно установленным `region_mask` ≠ 255. Если появятся до Plan 6.5 — отдельная migration с ФО→subjects expansion.
|
||||
|
||||
---
|
||||
|
||||
## 10. Testing strategy (TDD red-green)
|
||||
|
||||
### 10.1. Backend (Pest)
|
||||
|
||||
**[tests/Feature/Plan5/Projects/ProjectsStoreTest.php](../../../app/tests/Feature/Plan5/Projects/ProjectsStoreTest.php) — 3 новых теста:**
|
||||
|
||||
1. `it('creates project with regions array')` — POST `{regions: [77, 78]}` → assert `project.regions === [77, 78]`, response 201
|
||||
2. `it('dual-writes region_mask=255 for backward-compat')` — POST с regions → assert DB row `region_mask=255 AND region_mode='include'`
|
||||
3. `it('rejects regions with code out of 1..89 range')` — POST `{regions: [90]}` → 422 with error on `regions.0`
|
||||
|
||||
**[tests/Feature/Plan5/Projects/ProjectsUpdateTest.php](../../../app/tests/Feature/Plan5/Projects/ProjectsUpdateTest.php) — 2 новых теста:**
|
||||
|
||||
1. `it('updates regions array via PATCH')` — PATCH `{regions: [77]}` → assert DB row `regions=[77]`
|
||||
2. `it('preserves regions when PATCH omits the field (sometimes rule)')` — PATCH без `regions` → DB regions unchanged
|
||||
|
||||
**[tests/Feature/Supplier/SyncSupplierProjectsJobTest.php](../../../app/tests/Feature/Supplier/SyncSupplierProjectsJobTest.php) — 2 новых теста:**
|
||||
|
||||
1. `it('passes regions directly to allocator without bitmask conversion')` — project с `regions=[77,78]` → assert `SupplierProjectDto::regions === [77,78]`
|
||||
2. `it('passes empty array to allocator when project has regions=[]')` — project с `regions=[]` → assert DTO regions `===[]`
|
||||
|
||||
### 10.2. Frontend (Vitest)
|
||||
|
||||
**[tests/Frontend/NewProjectDialog.spec.ts](../../../app/tests/Frontend/NewProjectDialog.spec.ts) — 2 новых теста:**
|
||||
|
||||
1. `it('shows regions autocomplete with 89 options grouped by federal district')` — mount → assert v-autocomplete `:items.length === 89` (без sentinel)
|
||||
2. `it('sends regions array in POST payload')` — fill regions [77, 78] → click СОЗДАТЬ → assert `apiClient.post` called with `regions: [77, 78]`
|
||||
|
||||
**[tests/Frontend/ProjectDetailsDrawer.spec.ts](../../../app/tests/Frontend/ProjectDetailsDrawer.spec.ts) — refactor 3 existing region tests:**
|
||||
|
||||
- `renders region chips when project has non-zero region_mask` → переписать на `renders region chips for project.regions = [1, 2]`
|
||||
- `selecting regions encodes mask + sets mode='exclude'` → переписать на `selecting regions adds to regions array`
|
||||
- `clearing all regions resets mask=0 + mode='include'` → переписать на `clearing all regions sets regions=[]`
|
||||
|
||||
### 10.3. Regression must-stay-green
|
||||
|
||||
- Pest --parallel **742 / 739 passed / 3 skipped / 0 failed** (current baseline)
|
||||
- Vitest **92 / 758 passed / 3 skipped / 0 failed**
|
||||
- vue-tsc 0 errors
|
||||
- ESLint 0 errors, Prettier clean
|
||||
- Migration smoke: `php artisan migrate:fresh` + Pest на RlsSmokeTest.php + TenantModelsTest.php
|
||||
|
||||
### 10.4. Visual smoke (manual, заказчик)
|
||||
|
||||
1. /projects → нажать СОЗДАТЬ → dialog open
|
||||
2. Поле «Регионы» видно между «Лимит» и «Дни недели»
|
||||
3. Click → dropdown показывает 89 субъектов с подзаголовком ФО
|
||||
4. Поиск «Москва» → отфильтровывается «Москва» (code 77)
|
||||
5. Выбрать «Москва» + «СПб» → 2 chips
|
||||
6. Click СОЗДАТЬ → 201, project появляется в списке
|
||||
7. Открыть карточку этого проекта (PDD) → видны 2 chips
|
||||
8. PATCH → удалить chip, добавить другой → сохранить → reload PDD → актуальное состояние
|
||||
|
||||
---
|
||||
|
||||
## 11. Error handling
|
||||
|
||||
- `regions=null` в payload (legacy client) → backend `?? []` → пустой массив, no error
|
||||
- `regions=[90, 100]` → 422 с error на `regions.0` и `regions.1`
|
||||
- `regions=['abc']` → 422 (integer rule fails)
|
||||
- БД INSERT с `regions = NULL` → ERROR (NOT NULL constraint); защита — `present` rule в StoreProjectRequest + `?? []` в ProjectService
|
||||
- Outbound к crm.bp-gr.ru с regions=[77,78] и supplier API недоступен → existing retry logic [SyncSupplierProjectsJob.php](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php) (3 retries, 15/60/300s backoff)
|
||||
|
||||
---
|
||||
|
||||
## 12. Open questions / Assumptions требующие подтверждения
|
||||
|
||||
Помечены **ASSUMPTION** в spec'е — твоё ревью либо подтверждает, либо переписывает:
|
||||
|
||||
1. **Список 89 субъектов** — generate sequential 1..89 по конституционному порядку. **ASSUMPTION:** ОК; если нужен другой порядок (ОКАТО / ISO 3166-2:RU) — укажи.
|
||||
2. **Москва/Москва обл./СПб/ЛО/Севастополь** — раздельные субъекты, **итого 89**. **ASSUMPTION:** ОК.
|
||||
3. **Existing projects с `region_mask ≠ 255`** — теряют ФО-фильтр (получают `regions=[]` = «вся РФ»). **ASSUMPTION:** на момент Plan 6 нет таких prod-данных (Б-1 не закрыт). Если есть в dev — допустимо потерять.
|
||||
4. **Удаление колонки `region_mask`** — отложено до Plan 6.5. **ASSUMPTION:** dual-write не блокирует параллельные эпики (LeadRouter / PhonePrefixService продолжают работать с `region_mask`).
|
||||
5. **Группировка в dropdown** — `v-list-item subtitle = federal_district_name` (плоский список с subtitle). Альтернатива — `<v-list-subheader>` группы. **ASSUMPTION:** subtitle проще и работает с search filter.
|
||||
|
||||
---
|
||||
|
||||
## 13. Out of scope (Plan 6.5+)
|
||||
|
||||
- Drop COLUMN `region_mask`, `region_mode`, CHECK `chk_projects_region_mask_range`
|
||||
- Refactor [LeadRouter.php:90](../../../app/app/Services/LeadRouter.php#L90) на `regions[]`
|
||||
- Refactor / удалить [PhonePrefixService.php](../../../app/app/Services/PhonePrefixService.php) (если он не нужен по другим reasons)
|
||||
- Per-subject pricing / weighted quotas
|
||||
- Mobile-адаптация autocomplete (touch keyboard / full-screen modal)
|
||||
- Bulk-edit regions через [BulkActionsBar](../../../app/resources/js/components/projects/BulkActionsBar.vue)
|
||||
- Region-based reports / analytics
|
||||
|
||||
---
|
||||
|
||||
## 14. Связанные документы и cross-refs
|
||||
|
||||
- [2026-05-14-pdd-regions-field-design.md](./2026-05-14-pdd-regions-field-design.md) — отзывается этим эпиком (Section §7 «Bigint upgrade — Plan 6» ссылается сюда)
|
||||
- [2026-05-10-supplier-integration-design.md](./2026-05-10-supplier-integration-design.md) — supplier_projects schema + DTO
|
||||
- [docs/CRM_bp-gr_Инструкция_v8_5.md](../../CRM_bp-gr_Инструкция_v8_5.md) §5.5 — webhook / supplier-integration spec
|
||||
- [db/schema.sql:811-816](../../../db/schema.sql#L811-L816) — current `region_mask` definition (DEPRECATED после Plan 6)
|
||||
- [db/CHANGELOG_schema.md](../../../db/CHANGELOG_schema.md) — schema versioning history
|
||||
- [Открытые_вопросы](../../Открытые_вопросы_v8_3.md) — новый ticket Биз-XX «Plan 6 — subject-level regions» нужен после approval этой spec'и
|
||||
@@ -1,324 +0,0 @@
|
||||
# Design Spec: ProjectDetailsDrawer (push-mode справа)
|
||||
|
||||
**Дата:** 2026-05-14
|
||||
**Триггер:** Запрос заказчика — при выборе **одного** проекта на странице `/projects` показывать справа боковую панель с настройками и кнопкой «Сохранить»; BulkActionsBar (#869) должен появляться только при **2+ выбранных**.
|
||||
**Текущее поведение (baseline):** [app/resources/js/views/ProjectsView.vue:78](../../../app/resources/js/views/ProjectsView.vue#L78) — `<BulkActionsBar v-if="store.selectedIds.size > 0" />` — bulk-bar показывается при ≥1 selected, никакого drawer'а нет.
|
||||
**Mockup:** [`2026-05-14-project-details-drawer-mockup.html`](./2026-05-14-project-details-drawer-mockup.html) (статический, 3 состояния через toggle).
|
||||
|
||||
---
|
||||
|
||||
## 1. Поведение (UX-контракт)
|
||||
|
||||
| `store.selectedIds.size` | Что показывается | Что скрыто |
|
||||
|---|---|---|
|
||||
| `0` | Грид во всю ширину | Drawer ✕, BulkActionsBar ✕ |
|
||||
| `1` | Грид сдвинут влево (`padding-right: 480px`) + drawer открыт справа | BulkActionsBar ✕ |
|
||||
| `≥ 2` | Грид во всю ширину + BulkActionsBar внизу (как сейчас) | Drawer ✕ |
|
||||
|
||||
**Переходы:**
|
||||
|
||||
- Клик на чекбокс ProjectCard (1-й выбранный) → drawer въезжает справа за 240мс CSS-transition, грид сдвигается влево.
|
||||
- Клик на чекбокс 2-го проекта → drawer мгновенно уезжает обратно (transform: translateX(100%)) + грид возвращается, BulkActionsBar появляется снизу.
|
||||
- Снятие выбора до 0 → всё уходит (drawer и bulk-bar).
|
||||
- Закрытие drawer (X, «Отмена», ESC, клик на чекбокс выбранного проекта) → `store.clearSelection()` + drawer закрывается + грид возвращается.
|
||||
|
||||
**Edge case:** если `singleSelectedProject` исчезает из `store.items` (после fetch — проект удалён/архивирован/выпал из фильтра), drawer закрывается автоматически через computed.
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
**Подход A: custom `<aside>` с CSS push.** (Утверждён 2026-05-14.) Альтернативы (Vuetify teleport / promote-to-AppLayout) отклонены — Vuetify push-mode требует drawer'а как sibling of `v-main`, что усложняет boundary; teleport-в-internals хрупок.
|
||||
|
||||
**Новые файлы:**
|
||||
|
||||
- `app/resources/js/components/projects/ProjectDetailsDrawer.vue` — компонент панели.
|
||||
- `app/tests/Frontend/ProjectDetailsDrawer.spec.ts` — юнит-тесты (jsdom).
|
||||
|
||||
**Изменения существующих:**
|
||||
|
||||
- [app/resources/js/views/ProjectsView.vue](../../../app/resources/js/views/ProjectsView.vue) — условие BulkActionsBar `> 0` → `>= 2`; добавить `<ProjectDetailsDrawer />`; добавить computed `singleSelectedProject`; CSS-класс `.has-drawer` на `.projects-view` root.
|
||||
- [app/resources/js/components/projects/BulkActionsBar.story.vue](../../../app/resources/js/components/projects/BulkActionsBar.story.vue) — без изменений (BulkActionsBar сам не меняется).
|
||||
|
||||
**Backend — без изменений.** Используем существующие endpoints:
|
||||
|
||||
- `PATCH /api/projects/{id}` через [UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php) — поля `name`, `daily_limit_target`, `region_mask`+`region_mode`, `delivery_days_mask`, `sms_senders`, `sms_keyword`.
|
||||
- `PATCH /api/projects/{id}/toggle-active` — для кнопки «Приостановить»/«Возобновить».
|
||||
- `DELETE /api/projects/{id}` — для кнопки «Удалить» (soft-archive).
|
||||
|
||||
**Pinia store ([projectsStore.ts](../../../app/resources/js/stores/projectsStore.ts)):** уже имеет `update(id, payload)`, `toggleActive(project)`, `archive(id)`, `clearSelection()`, `selectedIds: Set<number>`. Не требует изменений.
|
||||
|
||||
---
|
||||
|
||||
## 3. Компоненты и интерфейсы
|
||||
|
||||
### `ProjectDetailsDrawer.vue`
|
||||
|
||||
```ts
|
||||
defineProps<{
|
||||
project: Project | null; // null = drawer закрыт (v-if cleanup внутри)
|
||||
}>()
|
||||
defineEmits<{
|
||||
close: []; // X / Отмена / ESC
|
||||
saved: []; // после успешного PATCH
|
||||
}>()
|
||||
```
|
||||
|
||||
**ESC handler (на life-cycle компонента):**
|
||||
|
||||
```ts
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && props.project) emit('close');
|
||||
}
|
||||
onMounted(() => document.addEventListener('keydown', onKey));
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
|
||||
```
|
||||
|
||||
**Локальный state (reactive form):**
|
||||
|
||||
```ts
|
||||
const form = reactive({
|
||||
name: '',
|
||||
daily_limit_target: 50,
|
||||
region_mask: 0,
|
||||
region_mode: 'include' as 'include' | 'exclude',
|
||||
delivery_days_mask: 127,
|
||||
sms_senders: [] as string[],
|
||||
sms_keyword: '',
|
||||
});
|
||||
const saving = ref(false);
|
||||
const errors = reactive<Record<string, string[]>>({});
|
||||
```
|
||||
|
||||
`watch(() => props.project?.id, reseed)` — пересаживает form при смене проекта.
|
||||
|
||||
**Поля (порядок сверху вниз):**
|
||||
|
||||
1. **Header (read-only):** `name + signal_type + signal_identifier/sms_senders` (как метка).
|
||||
2. **Название** (`v-text-field`) — `form.name`, 1-255.
|
||||
3. **Лимит лидов в день** (`v-text-field type="number"`) — `form.daily_limit_target`, 1-10000.
|
||||
4. **Регионы** (`v-autocomplete multiple chips`) — биндинг к `selectedRegions[]` → `region_mask` + `region_mode` (логика как в [NewProjectDialog.vue:141-153](../../../app/resources/js/views/projects/NewProjectDialog.vue#L141-L153)).
|
||||
5. **Дни недели приёма** (`v-btn-toggle multiple`) — 7 кнопок Пн-Вс + пресеты «Будни» / «Все дни».
|
||||
6. **SMS-поля** (только если `project.signal_type === 'sms'`): `sms_senders` (v-combobox), `sms_keyword` (v-text-field).
|
||||
|
||||
**Footer (две группы):**
|
||||
|
||||
- **Слева (destructive):**
|
||||
- `⏸ Приостановить` (`btn-warning`) — лейбл переключается на `▶ Возобновить` если `!project.is_active`. Применяется немедленно через `store.toggleActive(project)`. Без confirm. Form-state не сбрасывается.
|
||||
- `🗄 Удалить` (`btn-error`) — `window.confirm('Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).')` → `store.archive(project.id)` → `emit('close')`.
|
||||
- **Справа (primary form actions):**
|
||||
- `Отмена` (`btn-text`) — `emit('close')` без сохранения.
|
||||
- `Сохранить` (`btn-primary`, `:loading="saving"`) — PATCH /api/projects/{id} с form-полями. На 422 → проставить `errors`. На успех → toast «Сохранено», `emit('saved')` (родитель делает `store.fetch()`). Drawer остаётся открытым (заказчик может продолжить редактирование).
|
||||
|
||||
### Изменения `ProjectsView.vue`
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// добавить:
|
||||
const singleSelectedProject = computed<Project | null>(() => {
|
||||
if (store.selectedIds.size !== 1) return null;
|
||||
const [id] = store.selectedIds;
|
||||
return store.items.find(p => p.id === id) ?? null;
|
||||
});
|
||||
|
||||
function onDrawerClose() {
|
||||
store.clearSelection();
|
||||
}
|
||||
function onDrawerSaved() {
|
||||
store.fetch();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
|
||||
<!-- ... существующий header / filters / toolbar / grid ... -->
|
||||
|
||||
<!-- условие меняется: > 0 → >= 2 -->
|
||||
<BulkActionsBar v-if="store.selectedIds.size >= 2" />
|
||||
|
||||
<!-- новый mount: -->
|
||||
<ProjectDetailsDrawer
|
||||
:project="singleSelectedProject"
|
||||
@close="onDrawerClose"
|
||||
@saved="onDrawerSaved"
|
||||
/>
|
||||
|
||||
<NewProjectDialog v-model="createOpen" mode="create" @saved="store.fetch()" />
|
||||
<EditProjectDialog v-model="editOpen" :project="editing" @saved="store.fetch()" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.projects-view { transition: padding-right 240ms cubic-bezier(0.16, 1, 0.3, 1); }
|
||||
.projects-view.has-drawer { padding-right: 480px; }
|
||||
</style>
|
||||
```
|
||||
|
||||
### `ProjectDetailsDrawer.vue` CSS-skeleton
|
||||
|
||||
```css
|
||||
.project-details-drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0;
|
||||
width: 480px;
|
||||
background: var(--liderra-surface, #ffffff);
|
||||
border-left: 1px solid var(--liderra-line, #e6e2d6);
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.06);
|
||||
transform: translateX(100%);
|
||||
transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
display: flex; flex-direction: column;
|
||||
z-index: 5;
|
||||
}
|
||||
.project-details-drawer.open { transform: translateX(0); }
|
||||
```
|
||||
|
||||
Внутренний `v-if="project"` — рендер контента только когда проект есть; transform отрабатывает на пустом контейнере для close-анимации.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data flow
|
||||
|
||||
```
|
||||
ProjectCard.checkbox → toggle-select emit
|
||||
→ store.toggleSelect(id) — обновляет store.selectedIds
|
||||
→ reactive: ProjectsView.singleSelectedProject computed
|
||||
→ ProjectDetailsDrawer :project={...} получает обновление
|
||||
→ watch(() => project?.id) → reseed form
|
||||
→ CSS .has-drawer / .open классы меняются → 240мс transition
|
||||
|
||||
ProjectDetailsDrawer.Сохранить:
|
||||
→ axios.patch(/api/projects/{id}, form) [через store.update или прямой axios]
|
||||
→ 200: emit('saved') → ProjectsView.onDrawerSaved → store.fetch() → grid refreshes
|
||||
→ 422: errors reactive проставляется, drawer не закрывается
|
||||
|
||||
ProjectDetailsDrawer.Приостановить:
|
||||
→ store.toggleActive(project) → axios.patch(/api/projects/{id}/toggle-active)
|
||||
→ store.fetch() — project.is_active обновлён → label кнопки меняется
|
||||
|
||||
ProjectDetailsDrawer.Удалить:
|
||||
→ window.confirm
|
||||
→ store.archive(project.id) → axios.delete(/api/projects/{id})
|
||||
→ emit('close') → store.clearSelection() → drawer уходит
|
||||
|
||||
ProjectDetailsDrawer.X / Отмена / ESC:
|
||||
→ emit('close') → store.clearSelection()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Error handling
|
||||
|
||||
- **422 validation:** `e.response.data.errors` → reactive `errors` объект, проставляется через `:error-messages="errors.field"` на v-text-field-ах. Drawer не закрывается, можно править и повторно сохранить.
|
||||
- **Network error:** snackbar «Не удалось сохранить — попробуйте позже» (warning). Drawer остаётся открытым.
|
||||
- **Toggle-active 4xx:** silent no-op (label не меняется — пользователь увидит что состояние не сменилось). Можно добавить toast в Plan 6 если потребуется.
|
||||
- **Archive 4xx:** `window.alert('Не удалось удалить — попробуйте позже')`. Drawer не закрывается.
|
||||
- **Form dirty + Cancel:** **отбрасывается без подтверждения** (MVP). Если пользователь жалуется на потерю — добавим confirm в Plan 6.
|
||||
|
||||
---
|
||||
|
||||
## 6. Тестирование
|
||||
|
||||
### Vitest (jsdom) — новый файл `app/tests/Frontend/ProjectDetailsDrawer.spec.ts`
|
||||
|
||||
Тест-кейсы:
|
||||
|
||||
1. **Renders nothing when project=null** — drawer DOM присутствует (для transition), но контент пуст / `.open` отсутствует.
|
||||
2. **Renders project fields when project provided** — title, name input pre-filled, daily_limit pre-filled, days-mask pre-filled.
|
||||
3. **Reseeds form on project.id change** — props.project меняется → form.name перезагружается.
|
||||
4. **Emits 'close' on X click**.
|
||||
5. **Emits 'close' on Cancel click**.
|
||||
6. **Emits 'close' on ESC keydown**.
|
||||
7. **Save: PATCH /api/projects/{id} с form-полями + emits 'saved' on 200**.
|
||||
8. **Save: 422 → errors reactive проставляется, no emit 'saved', drawer DOM сохраняется**.
|
||||
9. **Pause button: вызывает store.toggleActive(project) + меняет label при `is_active=false`**.
|
||||
10. **Delete button: confirm=true → store.archive(id) + emit 'close'; confirm=false → no-op**.
|
||||
11. **is_active=true → label «Приостановить»; is_active=false → label «Возобновить»**.
|
||||
|
||||
### Vitest integration — extend [app/tests/Frontend/ProjectsView.spec.ts](../../../app/tests/Frontend/ProjectsView.spec.ts) (файл уже есть)
|
||||
|
||||
1. **0 selected → no drawer, no bulk-bar**.
|
||||
2. **1 selected → drawer открыт, bulk-bar скрыт, .has-drawer class на view**.
|
||||
3. **2 selected → drawer закрыт, bulk-bar показан, no .has-drawer class**.
|
||||
4. **Drawer close → store.clearSelection() вызывается → bulk-bar и drawer оба скрыты**.
|
||||
5. **singleSelectedProject = null когда проект пропадает из items после fetch**.
|
||||
|
||||
### Pest (backend) — не требуется
|
||||
|
||||
Backend endpoints не меняются. Существующие тесты `tests/Feature/Api/ProjectControllerTest.php` покрывают PATCH/DELETE/toggle-active.
|
||||
|
||||
### Visual smoke — Playwright MCP / browser ([Pravila §4.6](../../../docs/Pravila_raboty_Claude_v1_1.md))
|
||||
|
||||
Запустить `npm run dev`, открыть /projects:
|
||||
|
||||
- Click 1 проект → проверить визуально что drawer въезжает, грид сдвигается.
|
||||
- Click 2-й проект → drawer уезжает, bulk-bar появляется.
|
||||
- ESC / X / Cancel → drawer закрывается, выбор снимается.
|
||||
- Сохранить → toast «Сохранено», карточка обновляется в гриде.
|
||||
|
||||
### Регрессии (must-not-break)
|
||||
|
||||
- Pre-existing BulkActionsBar tests (`tests/Frontend/BulkActionsBar.spec.ts` если есть) → не должны падать.
|
||||
- Pa11y на /projects authenticated route → не должно быть новых violations (color contrast на новых elementah уже выровнен под Forest palette).
|
||||
- Vue-tsc / ESLint — 0 errors.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope (Plan 6+)
|
||||
|
||||
**Design-out-of-scope (deliberately deferred при brainstorm 2026-05-14):**
|
||||
|
||||
- Confirm dialog при close с dirty form (отбрасываем без подтверждения на MVP).
|
||||
- Optimistic update form-полей в grid после Сохранить (сейчас полный fetch — приемлемая UX-задержка <200мс).
|
||||
- Drawer на DealsView, KanbanView (только Projects сейчас).
|
||||
- Mobile-адаптация (480px drawer + грид = на узких viewport'ах overlap). При <768px viewport — fallback на modal-edit (текущий «Редактировать» в card-menu).
|
||||
- Keyboard navigation между полями drawer'а (Tab уже работает, но без shortcut'ов).
|
||||
- Multi-edit для 2+ selected с общими полями (это уже частично делает BulkActionsBar — но не предмет этой задачи).
|
||||
|
||||
**Polish-debt накопленный за Tasks 1-9 (reviewer-flagged, user defer 2026-05-14):**
|
||||
|
||||
- **Token drift (4 hardcoded hex → CSS vars):**
|
||||
- `#0f6e56` × 4 в [ProjectDetailsDrawer.vue](../../../app/resources/js/components/projects/ProjectDetailsDrawer.vue) `.pdd-day.active` / `.pdd-btn-primary` → `var(--liderra-teal)` ([tokens.css:7](../../../app/resources/css/tokens.css)). Task 2 I1.
|
||||
- `#f59e0b` (Tailwind amber) в `.pdd-btn-warning` → `var(--liderra-saffron, #D9A441)`. NB: значение поменяется (saffron другой оттенок, brand-consistent). Task 6 Important.
|
||||
- `#dc2626` × 2 в `.pdd-btn-error` + `.pdd-error` → `var(--liderra-error, #B83A3A)`. Значение поменяется. Task 7 M1.
|
||||
- `480px` × 2 (ProjectsView CSS + ProjectDetailsDrawer CSS) → `var(--liderra-drawer-width: 480px)` в tokens.css. Task 8 M1.
|
||||
|
||||
- **UX gaps:**
|
||||
- **Snackbar для network errors (spec §5)** не реализован: `onSave`/`onPause`/`onDelete` silently swallow non-422 errors. Spec прямо это требует. Task 5 Important.
|
||||
- **Drawer a11y:** `<aside>` lacks `role="dialog"` / `aria-modal="true"` / `aria-labelledby="pdd-title-id"` / focus-trap при open / focus return при close. Task 8 M4.
|
||||
- **Lucide icons вместо emoji** ✕ / ⏸ / ▶ / 🗄 — CTO-19 mandate. Использовать `lucide-vue-next` components (X / Pause / Play / Archive). Task 2 M3 + Tasks 6/7 cross-task pattern.
|
||||
|
||||
- **Test hardening:**
|
||||
- `data-testid="pdd-error-limit"` symmetry (name-error has testid, limit-error does not). Task 5 M.
|
||||
- `vi.clearAllMocks()` в `beforeEach` для defensive isolation. Task 5 M.
|
||||
- `v-if="errors.name?.length"` вместо `v-if="errors.name"` (empty-array безопасность). Task 5 M.
|
||||
- Comment fix at [ProjectsView.spec.ts:232-233](../../../app/tests/Frontend/ProjectsView.spec.ts) — misleading description. Task 9 M.
|
||||
- `data-testid="pdd-delete"` confirm-args assertion (lock the prompt text). Task 7 M2.
|
||||
- `:disabled="saving"` на Delete во избежание double-action гонок. Task 7 M4.
|
||||
|
||||
**Cross-cutting debt (не только PDD):**
|
||||
|
||||
- **Silent error handling** в `void store.fetch()`, `store.toggleActive()`, `store.archive()` calls — same pattern на DealDetailDrawer + ProjectsView. App-wide toast/error-bus story.
|
||||
- **Sentry breadcrumbs** для actions (pending Б-1 instance deployment per Tooling #34).
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
Нет. Все UX-вопросы закрыты заказчиком 2026-05-14:
|
||||
|
||||
1. Mutual exclusion drawer ↔ bulk-bar: **да** (drawer при 1, bulk-bar при 2+).
|
||||
2. Drawer mode: **push, не overlay** — грид сдвигается, drawer уезжает обратно при close.
|
||||
3. Close = unselect: **да**.
|
||||
4. Modal «Редактировать» в card-menu: **оставить как secondary path**.
|
||||
5. Footer drawer'а: **Приостановить + Удалить (слева) + Отмена + Сохранить (справа)**.
|
||||
|
||||
---
|
||||
|
||||
## 9. Связанные документы
|
||||
|
||||
- [2026-05-14-project-details-drawer-mockup.html](./2026-05-14-project-details-drawer-mockup.html) — интерактивный mockup (3 состояния через toggle), утверждён заказчиком 2026-05-14.
|
||||
- [BRANDBOOK_v2.md](../../../liderra_v8_handoff/docs/BRANDBOOK_v2.md) — цвета (Teal `#0F6E56`, ivory `#F6F3EC`, line `--liderra-line`).
|
||||
- [DealDetailDrawer.vue](../../../app/resources/js/components/deals/DealDetailDrawer.vue) — prior art для `v-navigation-drawer` (overlay-вариант; здесь — push-вариант).
|
||||
- [NewProjectDialog.vue](../../../app/resources/js/views/projects/NewProjectDialog.vue) — источник полей формы (название/лимит/регионы/дни).
|
||||
- [BulkActionsBar.vue](../../../app/resources/js/components/projects/BulkActionsBar.vue) — компонент, условие отображения которого меняется (`> 0` → `>= 2`).
|
||||
- [CLAUDE.md §3.3](../../../CLAUDE.md) #19 Superpowers + #30 Frontend Design (paired stack для UI-разработки).
|
||||
- [Pravila_raboty_Claude_v1_1.md §4.6](../../../docs/Pravila_raboty_Claude_v1_1.md) — visual smoke для UI-refactor.
|
||||
@@ -1,322 +0,0 @@
|
||||
# Portal-wide Audit & Proposals — Лидерра
|
||||
|
||||
**Дата:** 2026-05-15
|
||||
**Автор:** Claude (через `superpowers:brainstorming` → собственная синтез)
|
||||
**Заказчик:** Дмитрий (kpd9363@gmail.com)
|
||||
**Статус:** approved для составления планов; реализация sprint-by-sprint по приоритету
|
||||
**Связанные источники:** [docs/Открытые_вопросы_v8_3.md v1.83](../../Открытые_вопросы_v8_3.md), [docs/CRM_bp-gr_Инструкция_v8_5.md §6](../../CRM_bp-gr_Инструкция_v8_5.md), [CLAUDE.md v1.93](../../../CLAUDE.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Привести портал Лидерра к состоянию «всё, что обещает UI, работает» (полная функциональность для пользователя), включая опциональный модуль миграции исторических данных из crm.bp-gr.ru (§6 ТЗ), и убрать dev-артефакты перед production. Запрос заказчика 2026-05-15 night:
|
||||
|
||||
> «Проверь портал с точки зрения пользователя. Чтобы работали все кнопки, закладки, списки, справочники и т.д. Посмотри на содержание портала, убери или добавь того чего не хватает для полноценной работы портала и всех функций и задач, включая миграцию проектов и сделок. Сформулируй все предложения в табличной форме и дай на утверждение мне перед реализацией. Экономия 0%».
|
||||
|
||||
## 2. Метод аудита
|
||||
|
||||
| Шаг | Инструмент | Результат |
|
||||
|---|---|---|
|
||||
| Static-audit views/handlers/lookups | 4 параллельных `Explore`-агента (auth / app-user / admin / shared+stores+api) | 100+ raw findings с file:line |
|
||||
| Карта маршрутов | `app/resources/js/router/index.ts` + `app/routes/web.php` | 26 SPA + 23 API endpoint'а |
|
||||
| Sidebar coverage | `AppSidebar.vue` + `AdminLayout.vue` navItems | Расхождения с router'ом |
|
||||
| ТЗ-coverage | ТЗ v8.5 §6 (CSV-импорт) + cross-ref schema.sql v8.20 | §6 спецификация vs текущая реализация (0%) |
|
||||
| Реестр открытых вопросов | v1.83 (87 продуктовых / 71 ✅ / 11 ⏸) | Какие ⏸ внешние блокеры, какие можно закрыть в портале |
|
||||
| Browser smoke | Playwright MCP — `/login` + console + network | Подтверждено: Vuetify работает, Lucide рендерит, `/api/auth/me` 401 для unauth (curl-500 был артефакт без Sanctum headers), DemoSeeder credentials расходятся с БД |
|
||||
|
||||
**Что НЕ проверялось этим аудитом:**
|
||||
|
||||
- Mobile-вид (всё спроектировано в desktop-first; mobile pass — отдельный спринт после готовности баз)
|
||||
- Лендинг (⏸ Б-1, отдельный track)
|
||||
- Real load-testing (Pre-prod track)
|
||||
- Pen-test / SAST расширенный (Phase 3 трек Roadmap)
|
||||
|
||||
## 3. Sprint Schedule (по приоритету)
|
||||
|
||||
```
|
||||
Sprint 1 (P0) → Sprint 2 (P1 wave 1) → Sprint 3 (P1 wave 2 + Backend gaps)
|
||||
Sprint 4 (P1 миграция §6) → Sprint 5 (P2 UX-debt) → Sprint 6 (P3 polish + Cleanup)
|
||||
```
|
||||
|
||||
### Sprint 1 — P0 BLOCKED-UX (5 эпиков, ~1.5-2 дня)
|
||||
|
||||
| ID | Title | Effort |
|
||||
|---|---|---|
|
||||
| C2 | FilterChip popovers DealsView (Проект/Менеджер) — реализовать списки из `/api/projects` + `/api/managers` | M |
|
||||
| C4 | KanbanView DnD persist через `PATCH /api/deals/{id}` | S |
|
||||
| C5 | BulkActionsBar (projects) — `window.alert` → `v-snackbar` | XS |
|
||||
| G1 | AdminPricingTiersView — error handling submit/delete | XS |
|
||||
| G2 | AdminSupplierPricesView — error handling save | XS |
|
||||
|
||||
### Sprint 2 — P1 wave 1: UI-функционал (Settings, Billing, Auth) (~5 эпиков, ~5-7 дней)
|
||||
|
||||
| ID | Title | Effort |
|
||||
|---|---|---|
|
||||
| A2 | RecoveryCodesView continue button — redirect | XS |
|
||||
| A3 | RecoveryCodesView — подвязка на real API | S |
|
||||
| A7 | `/legal/offer` + `/legal/privacy` routes + stub blade | S |
|
||||
| D1+D2+D3+D4+D5+J5+J6 | Settings ProfileTab + ApiTab — все 6 кнопок + backend endpoints (`PATCH /me`, `POST /api-keys/regenerate`, `PUT /tenants/me/webhook-settings`, `POST /webhooks/test`, copy/regenerate logic) | L |
|
||||
| E1 | BillingView Пополнить баланс — TopupDialog + `POST /api/billing/topup` (stub без реальной оплаты на MVP) | L |
|
||||
| E3 | BillingView Overview tab — подвязка BalanceCard/TransactionsTable/InvoicesTable на real API | L |
|
||||
|
||||
### Sprint 3 — P1 wave 2: Admin + Dashboard + Reports + Reminders (~6 эпиков, ~4-6 дней)
|
||||
|
||||
| ID | Title | Effort |
|
||||
|---|---|---|
|
||||
| B1 | ❌ **won't-do** (2026-05-16) — ~~AppSidebar: добавить «Напоминания» в «Работа»~~. Конфликт с решением заказчика «sidebar cleanup» (commit `5c8ad27`): пункт убран намеренно. Реализован и откатан (revert `e0a3fb8`). | XS |
|
||||
| B4 | AdminLayout sidebar: добавить /admin/pricing-tiers + /admin/supplier-prices | XS |
|
||||
| B5 | AdminLayout impersonation-banner | M |
|
||||
| C1+J3 | Dashboard real API + `GET /api/dashboard/summary` | M |
|
||||
| C8+F3 | RemindersView openDeal + bell deep-link на `/deals?openId=` | S |
|
||||
| D6+D7 | SettingsView placeholder-tabs — реализация или скрытие (decide per-tab) | M-L per tab |
|
||||
| F1 | Reports Provider'ы — managers/sources/billing summary | M |
|
||||
| F2 | Reports скачать-файл endpoint `/api/reports/jobs/{id}/file` + signed URL 24ч | S |
|
||||
| G4 | AdminBillingView row-actions (suspend/refund/edit-tariff + backend endpoints) | L |
|
||||
| G5 | AdminIncidentsView detail-view `/admin/incidents/:id` | M |
|
||||
| G6 | AdminIncidentsView РКН-notify action + endpoint | S |
|
||||
| J1 | CTO-18 — auth+tenant middleware на /api/deals (требует Б-1 для prod) | M |
|
||||
| J2 | /api/admin/* — auth:saas-admin middleware (требует Б-1 + DO-4) | M |
|
||||
|
||||
### Sprint 4 — 🆕 Миграция исторических данных §6 (9 sub-tasks = 1 эпик, ~3-5 дней)
|
||||
|
||||
| ID | Title | Effort |
|
||||
|---|---|---|
|
||||
| H1 | Schema delta: `import_unknown_statuses` table + RLS | S |
|
||||
| H2 | Schema enrichment `import_log` (+5 колонок) | XS |
|
||||
| H3 | Backend сервисы: `CsvLeadsParser` + `StatusRuToSlugMapper` + `HistoricalImportService` | XL |
|
||||
| H4 | Backend job + controller: `ImportLeadsJob` + `ImportController` + route + email | L |
|
||||
| H5 | Frontend ImportView (dropzone + progress + результат-таблица) | L |
|
||||
| H6 | Unknown statuses wizard — sub-dialog маппинга | M |
|
||||
| H7 | Bulk-импорт проектов (18-колоночный CSV из crm.bp-gr.ru) | L |
|
||||
| H8 | UI вход — tab в Settings + кнопка в ProjectsView page-head | XS (decision) |
|
||||
| H9 | Мини-инструкция «Как перенести данные из crm.bp-gr.ru» | S |
|
||||
|
||||
**Schema delta:** v8.20 → v8.21 (+1 таблица + 5 колонок в import_log)
|
||||
|
||||
### Sprint 5 — P2 UX-debt (~19 эпиков, ~4-5 дней)
|
||||
|
||||
| ID | Title | Effort |
|
||||
|---|---|---|
|
||||
| A1 | Yandex SSO кнопка — `:disabled` + tooltip | XS |
|
||||
| A4 | ResetPassword confirmation `:error-messages` | XS |
|
||||
| A5 | ForgotPassword fallback message logic fix | XS |
|
||||
| A6 | TwoFactor реальный countdown | S |
|
||||
| A8 | DemoSeeder re-seed скрипт + dev README | XS |
|
||||
| B2 | Sidebar count «Сделки» из store + `/api/deals?count_only=1` (J4) | S |
|
||||
| B3 | ⌘K — minimal command palette ИЛИ удалить плашку | M (decide) |
|
||||
| C3 | DealsView Export button consolidation | XS |
|
||||
| C6 | NewDealDialog degradation alert при mock-fallback | XS |
|
||||
| C7 | DealDetailDrawer degradation alert | XS |
|
||||
| C9 | ProjectsView Plan 6 (regions subject-level) — review + merge после GitHub 403 unblock | S |
|
||||
| E2 | BalanceCard «Автопополнение» + «Сменить тариф» — disable+tooltip ИЛИ реализовать | XS / L |
|
||||
| E4 | BillingView pending alert — real data | S |
|
||||
| G3 | AdminPricingTiers + SupplierPrices — переписать на TypeScript | M |
|
||||
| G7 | AdminPricingTiers effective_from date-picker | S |
|
||||
| G10 | AdminBilling `confirm()` → `v-dialog` | XS |
|
||||
| I1 | DevIndexBadge + DevIndexOverlay cleanup (если решено убрать перед production) | S |
|
||||
| I3 | mocks → `tests/__mocks__/` или DEV-only feature-flag | S |
|
||||
| I4 | ImpersonationDialog devPlainCode — gate за DEV | XS |
|
||||
|
||||
### Sprint 6 — P3 polish + Cleanup tail (~8 эпиков, ~2-3 дня)
|
||||
|
||||
| ID | Title | Effort |
|
||||
|---|---|---|
|
||||
| A9 | A11y aria-labels на eye-icons | XS |
|
||||
| B6 | AdminLayout DEV-only auth-gap banner | XS |
|
||||
| F4 | usePolling → constants/polling.ts | XS |
|
||||
| F5 | `new_device_login` через session-fingerprint (требует инфра) | XL |
|
||||
| G8 | ImpersonationDialog two-person approval (требует Б-1 SSO) | M |
|
||||
| G9 | AdminSystem aria-label на edit-кнопки | XS |
|
||||
| I2 | dev-indices.json gitignore-решение | XS |
|
||||
| I5 | ProjectsView clearable workaround removal (CTO-19 closure tail) | XS |
|
||||
|
||||
## 4. Таблица предложений — полный список 70 эпиков
|
||||
|
||||
> Полная таблица с обоснованиями — см. оригинальный sync-message от 2026-05-15 в conversation. Ниже компактная версия с ссылками.
|
||||
|
||||
### A. Аутентификация и onboarding (9 эпиков)
|
||||
|
||||
| ID | Объект | Сейчас | Предложение | Приоритет | Effort |
|
||||
|---|---|---|---|---|---|
|
||||
| A1 | `LoginView.vue:107` — Yandex 360 SSO | dead stub (по плану — ⏸ Б-1) | `:disabled` + tooltip «после Б-1» | 🟡 P2 | XS |
|
||||
| A2 | `RecoveryCodesView.vue:73` | TODO no-op | redirect на /login или /dashboard | 🟠 P1 | XS |
|
||||
| A3 | `RecoveryCodesView.vue:15-24` | Статический mock | API integration | 🟠 P1 | S |
|
||||
| A4 | `ResetPasswordView.vue:110-118` | Нет `:error-messages` | Add error display | 🟡 P2 | XS |
|
||||
| A5 | `ForgotPasswordView.vue:36-38` | Fallback недостижим | Безусловный fallback | 🟡 P2 | XS |
|
||||
| A6 | `TwoFactorView.vue:129` | Hardcoded «02:34» | Реальный countdown | 🟡 P2 | S |
|
||||
| A7 | `AuthLayout.vue:47-48` | `/legal/*` → 404 | Stub-routes + blade | 🟠 P1 | S |
|
||||
| A8 | DemoSeeder dev | 422 при логине | Re-seed скрипт + README | 🟡 P2 | XS |
|
||||
| A9 | Eye-icons toggle | Нет aria-label | Добавить | 🟢 P3 | XS |
|
||||
|
||||
### B. Sidebar / Layout / Navigation (6 эпиков)
|
||||
|
||||
| ID | Объект | Сейчас | Предложение | Приоритет | Effort |
|
||||
|---|---|---|---|---|---|
|
||||
| B1 | AppSidebar — Напоминания | Отсутствует в nav (намеренно, `5c8ad27`) | ❌ **won't-do** (2026-05-16): re-add конфликтует с решением заказчика «sidebar cleanup»; пункт остаётся убранным | 🟠 P1 | XS |
|
||||
| B2 | AppSidebar Сделки count=247 | Hardcoded | computed из store | 🟡 P2 | S |
|
||||
| B3 | ⌘K stub в sidebar+topbar | Без handler | Command palette или удалить | 🟡 P2 | M |
|
||||
| B4 | AdminLayout navItems | Нет /admin/pricing-tiers и /admin/supplier-prices | Добавить | 🟠 P1 | XS |
|
||||
| B5 | Impersonation banner | Нет глобального индикатора | Полоса в AdminLayout | 🟠 P1 | M |
|
||||
| B6 | AdminLayout auth-gap | Не виден в UI | DEV-only banner | 🟢 P3 | XS |
|
||||
|
||||
### C. App-views Dashboard/Deals/Kanban/Projects (9 эпиков)
|
||||
|
||||
| ID | Объект | Сейчас | Предложение | Приоритет | Effort |
|
||||
|---|---|---|---|---|---|
|
||||
| C1 | Dashboard KPI mock | TODO | `GET /api/dashboard/summary` | 🟠 P1 | M |
|
||||
| C2 | DealsView FilterChip popovers | console.log only | Popover'ы со списками | 🔥 P0 | M |
|
||||
| C3 | DealsView page-head Export | Заглушка | Подвязать или удалить | 🟡 P2 | XS |
|
||||
| C4 | KanbanView DnD persist | TODO | `PATCH /api/deals/{id}` | 🔥 P0 | S |
|
||||
| C5 | BulkActionsBar window.alert | Блокирует UI | `v-snackbar` | 🔥 P0 | XS |
|
||||
| C6 | NewDealDialog mock fallback | Silent | Degradation alert | 🟡 P2 | XS |
|
||||
| C7 | DealDetailDrawer MOCK_EVENTS | Silent | Degradation alert | 🟡 P2 | XS |
|
||||
| C8 | RemindersView openDeal | Пусто | `/deals?openId=` deep-link | 🟠 P1 | S |
|
||||
| C9 | ProjectsView Plan 6 regions | Локально в worktree | Review + merge после GitHub unblock | 🟠 P1 | S |
|
||||
|
||||
### D. Settings (7 эпиков)
|
||||
|
||||
| ID | Объект | Сейчас | Предложение | Приоритет | Effort |
|
||||
|---|---|---|---|---|---|
|
||||
| D1 | ProfileTab Save | Без handler | `PATCH /api/auth/me` (J6) | 🟠 P1 | M |
|
||||
| D2 | ApiTab Copy | Без handler | clipboard.writeText + toast | 🟠 P1 | XS |
|
||||
| D3 | ApiTab Regenerate | Без handler | `POST /api/api-keys/regenerate` (J5) | 🟠 P1 | M |
|
||||
| D4 | ApiTab Save Webhook | Без handler | `PUT /api/tenants/me/webhook-settings` (J5) | 🟠 P1 | S |
|
||||
| D5 | ApiTab Test Webhook | Без handler | `POST /api/webhooks/test` (J5) | 🟠 P1 | S |
|
||||
| D6 | PlaceholderTab × 4 | «В разработке» | Реализовать или скрыть | 🟠 P1 | L per tab |
|
||||
| D7 | SettingsView left-rail | 8 tab'ов, 4 заглушки | Hide-if-not-implemented + добавить «Импорт» (H8) | 🟡 P2 | XS |
|
||||
|
||||
### E. Billing (4 эпика)
|
||||
|
||||
| ID | Объект | Сейчас | Предложение | Приоритет | Effort |
|
||||
|---|---|---|---|---|---|
|
||||
| E1 | BillingView Пополнить | Без handler | TopupDialog + `POST /api/billing/topup` stub | 🟠 P1 | L |
|
||||
| E2 | BalanceCard 3 кнопки | Без handlers | Disable+tooltip или реализация | 🟡 P2 | XS/L |
|
||||
| E3 | BillingView Overview API | MOCK | Real API | 🟠 P1 | L |
|
||||
| E4 | BillingView pending alert | Hardcoded | Real data | 🟡 P2 | S |
|
||||
|
||||
### F. Reports / Reminders / Notifications (5 эпиков)
|
||||
|
||||
| ID | Объект | Сейчас | Предложение | Приоритет | Effort |
|
||||
|---|---|---|---|---|---|
|
||||
| F1 | Reports Provider'ы | 1 из 4 | Реализовать 3 оставшихся | 🟠 P1 | M |
|
||||
| F2 | Reports Скачать | Endpoint нет | `/api/reports/jobs/{id}/file` + signed URL | 🟠 P1 | S |
|
||||
| F3 | bell deep-link | `/deals` без id | `/deals?openId=` (C8) | 🟡 P2 | XS |
|
||||
| F4 | usePolling 30s hardcoded | Разбросано | `constants/polling.ts` | 🟢 P3 | XS |
|
||||
| F5 | new_device_login | Заглушка | Session-fingerprint (Post-MVP) | 🟢 P3 | XL |
|
||||
|
||||
### G. Admin SaaS (10 эпиков)
|
||||
|
||||
| ID | Объект | Сейчас | Предложение | Приоритет | Effort |
|
||||
|---|---|---|---|---|---|
|
||||
| G1 | PricingTiersView submit/delete | Silent failures | try/catch + alert + toast | 🔥 P0 | XS |
|
||||
| G2 | SupplierPricesView save | Silent failures | try/catch + alert + toast | 🔥 P0 | XS |
|
||||
| G3 | PricingTiers + SupplierPrices — plain JS | Нет типизации | TS rewrite + `api/admin.ts` | 🟡 P2 | M |
|
||||
| G4 | AdminBillingView row-actions | Нет actions | Dropdown 3 actions + backend | 🟠 P1 | L |
|
||||
| G5 | AdminIncidentsView drill-down | Нет detail-view | `/admin/incidents/:id` | 🟠 P1 | M |
|
||||
| G6 | AdminIncidentsView РКН-notify | Badge без action | Button + endpoint | 🟠 P1 | S |
|
||||
| G7 | PricingTiersView effective_from | Hardcoded next month | Date-picker | 🟡 P2 | S |
|
||||
| G8 | ImpersonationDialog two-person | TODO (CTO-15) | Реализация после Б-1 SSO | 🟢 P3 | M |
|
||||
| G9 | AdminSystem aria-label | Нет на edit-кнопках | Добавить | 🟢 P3 | XS |
|
||||
| G10 | AdminBilling `confirm()` | Браузерный | `v-dialog` confirm | 🟡 P2 | XS |
|
||||
|
||||
### H. Миграция §6 — 🆕 НОВЫЙ ЭПИК (9 sub-tasks)
|
||||
|
||||
| ID | Объект | Сейчас | Предложение | Приоритет | Effort |
|
||||
|---|---|---|---|---|---|
|
||||
| H1 | Schema delta | `import_unknown_statuses` отсутствует | Migration + RLS | 🆕 P1 | S |
|
||||
| H2 | import_log enrichment | Минимальный | +5 колонок (entity_type, source_system, mapping_config, unknown_statuses_count, dry_run) | 🆕 P1 | XS |
|
||||
| H3 | Backend services | Нет | `CsvLeadsParser` + `StatusRuToSlugMapper` + `HistoricalImportService` (advisory-lock per §6.5, balance НЕ списывается, transaction type `historical_import`) | 🆕 P1 | XL |
|
||||
| H4 | Backend job + controller | Нет | `ImportLeadsJob` + `ImportController` + route `/api/imports` + email-уведомление | 🆕 P1 | L |
|
||||
| H5 | Frontend ImportView | Нет | Dropzone + progress + результат-таблица | 🆕 P1 | L |
|
||||
| H6 | Unknown statuses wizard | Нет | Sub-dialog маппинга | 🆕 P1 | M |
|
||||
| H7 | Bulk-импорт проектов | Нет | Аналогичный flow для CSV проектов | 🆕 P1 | L |
|
||||
| H8 | UI вход — где | Нет | Tab в Settings + кнопка в ProjectsView | 🆕 P1 | XS |
|
||||
| H9 | Мини-инструкция | Нет | Markdown «Как перенести данные» | 🆕 P2 | S |
|
||||
|
||||
**ТЗ референс:** §6.1 (когда нужен) / §6.2 (формат файла) / §6.3 (маппинг полей) / §6.4 (русские названия → slug) / §6.5 (идемпотентность через `webhook_dedup_keys`) / §6.6 (workflow в UI) / §6.7 (таблица `import_log`).
|
||||
|
||||
### I. Dev-артефакты для production hygiene (5 эпиков)
|
||||
|
||||
| ID | Объект | Сейчас | Предложение | Приоритет | Effort |
|
||||
|---|---|---|---|---|---|
|
||||
| I1 | DevIndexBadge + DevIndexOverlay | Temporary feature | Удалить перед production | 🧹 P2 | S |
|
||||
| I2 | dev-indices.json | Modified in git status | .gitignore decision | 🧹 P3 | XS |
|
||||
| I3 | composables/mock*.ts | Production fallbacks | `tests/__mocks__/` или DEV-flag | 🧹 P2 | S |
|
||||
| I4 | devPlainCode banner | Visible в UI | DEV-only gate | 🧹 P2 | XS |
|
||||
| I5 | ProjectsView clearable `::after` | Workaround | Удалить (CTO-19 closure tail) | 🧹 P3 | XS |
|
||||
|
||||
### J. Backend / Infra gaps (6 эпиков)
|
||||
|
||||
| ID | Объект | Сейчас | Предложение | Приоритет | Effort |
|
||||
|---|---|---|---|---|---|
|
||||
| J1 | CTO-18 middleware на /api/deals | Реестр-open | auth:sanctum+tenant перед prod | 🟠 P1 | M |
|
||||
| J2 | /api/admin/* middleware | Per Б-1 ⏸ | auth:saas-admin после Б-1+DO-4 | 🟠 P1 | M |
|
||||
| J3 | GET /api/dashboard/summary | Нет | Создать для C1 | 🟠 P1 | M |
|
||||
| J4 | GET /api/deals?count_only=1 | Нет | Создать для B2 | 🟡 P2 | XS |
|
||||
| J5 | API-keys/Webhooks endpoints | Нет | 3 новых endpoint'а для D2-D5 | 🟠 P1 | M |
|
||||
| J6 | PATCH /api/auth/me | Только notification-preferences | Расширить для full profile (D1) | 🟠 P1 | S |
|
||||
|
||||
### K. Out-of-scope (намеренно НЕ меняем)
|
||||
|
||||
| ID | Объект | Причина |
|
||||
|---|---|---|
|
||||
| K1 | Lucide иконки | CTO-19 ✅ closed |
|
||||
| K2 | region_mask legacy | Plan 6.5 cleanup |
|
||||
| K3 | Реестровые ⏸ внешние блокеры | Б-1 / юр.редактура / DO-2/DO-4 / Биз-25..31 |
|
||||
| K4 | MagicLink 24ч | Биз-13 closed |
|
||||
| K5 | Histoire stories | Dev-tool |
|
||||
|
||||
## 5. Сводка
|
||||
|
||||
| Категория | Кол-во | 🔥 P0 | 🟠 P1 | 🟡 P2 | 🟢 P3 | 🆕 NEW | 🧹 CLEAN |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| A. Auth | 9 | 0 | 3 | 5 | 1 | 0 | 0 |
|
||||
| B. Sidebar/Layout | 6 | 0 | 3 | 2 | 1 | 0 | 0 |
|
||||
| C. App-views | 9 | 3 | 4 | 2 | 0 | 0 | 0 |
|
||||
| D. Settings | 7 | 0 | 6 | 1 | 0 | 0 | 0 |
|
||||
| E. Billing | 4 | 0 | 2 | 2 | 0 | 0 | 0 |
|
||||
| F. Reports/etc | 5 | 0 | 2 | 1 | 2 | 0 | 0 |
|
||||
| G. Admin | 10 | 2 | 4 | 3 | 1 | 0 | 0 |
|
||||
| H. Миграция §6 | 9 | 0 | 0 | 1 | 0 | 8 | 0 |
|
||||
| I. Cleanup | 5 | 0 | 0 | 3 | 2 | 0 | 5 |
|
||||
| J. Backend gaps | 6 | 0 | 5 | 1 | 0 | 0 | 0 |
|
||||
| **Итого** | **70** | **5** | **29** | **21** | **7** | **8** | **5** |
|
||||
|
||||
**Realistic эпиков после группировки:** ~30-35.
|
||||
**Грубая оценка solo-разработчика:** 6-9 недель (Sprint 1 ~2 дня, Sprint 2 ~7 дней, Sprint 3 ~6 дней, Sprint 4 ~5 дней, Sprint 5 ~4 дня, Sprint 6 ~3 дня, + buffer на code-review/testing/regression).
|
||||
|
||||
## 6. Risks & dependencies
|
||||
|
||||
| Risk | Митigation |
|
||||
|---|---|
|
||||
| GitHub push BLOCKED 403 «verify email» (Plan 6 + сегодняшние коммиты) | До закрытия — работа локально + worktree. Б-1 (ООО) не требуется для этого, нужен GitHub-account fix |
|
||||
| Б-1 (реквизиты ООО) блокирует J1 (полный production переход), J2 (saas-admin SSO), E1 (реальная ЮKassa), Диз-3/DO-2/DO-4 | Все эпики на MVP идут со stub-аутентификацией / placeholder-данными. Готовим код, но production-deploy ждёт Б-1 |
|
||||
| Биз-28 (CSV-схема поставщика) — BLOCKED credentials | Не блокирует H1-H9 (миграция работает с CSV ТЗ-формата §6.2 от пользователя, не от поставщика) |
|
||||
| Schema delta v8.20→v8.21 в Sprint 4 | Migrate:fresh на dev в начале спринта; PHPStan baseline regen; обновить CLAUDE.md §0/§2 после merge |
|
||||
| Sprint 5 (P2 UX) — большой объём | Можно дробить на 2 sub-sprint'а если устаёт фокус |
|
||||
| Mock-data fallback removal (I3) | Verify нет production-paths использующих mocks перед удалением — может потребовать feature-flag вместо удаления |
|
||||
|
||||
## 7. Acceptance criteria для каждого Sprint'а
|
||||
|
||||
- **Sprint 1 (P0):** Все 5 P0-эпиков закрыты + regression Pest --parallel + Vitest зелёные + browser smoke по каждому fix'у.
|
||||
- **Sprint 2 (P1 wave 1):** Settings (Profile/Api) + Billing (Topup) полностью функциональны от UI до DB. Auth legal-pages не выдают 404.
|
||||
- **Sprint 3 (P1 wave 2):** Dashboard живой. Admin row-actions + impersonation banner + incidents drill-down + reports backend complete. Sidebar (B1, B4) исправлен.
|
||||
- **Sprint 4 (миграция §6):** Тенант может загрузить CSV из crm.bp-gr.ru, увидеть прогресс, замапить unknown statuses, получить email-результат. Аналогично для проектов.
|
||||
- **Sprint 5 (P2):** ⌘K либо работает, либо удалён. Все toast'ы консистентны. Demo-seeder работает на чистом dev'е. Dev-артефакты удалены/гейтнуты.
|
||||
- **Sprint 6 (P3):** A11y aria-labels везде. Polling вынесен. Cleanup tail.
|
||||
|
||||
## 8. Approval log
|
||||
|
||||
- 2026-05-15 night — Дмитрий: «Полный portal-wide + браузерный smoke (Recommended)» (scope)
|
||||
- 2026-05-15 night — Дмитрий: «Да, всё в работу — спринты по приоритету P0→P1→P2→P3+Новые (Recommended)» (composition + scheduling)
|
||||
- 2026-05-16 — Дмитрий: B1 «Напоминания в сайдбар» → ❌ **won't-do** — re-add конфликтует с прежним решением заказчика «sidebar cleanup» (commit `5c8ad27`, тест `AppLayout.spec.ts` фиксирует «убраны по требованию заказчика»). Sprint 3A реализовал B4 + B5; B1 реализован и откатан (revert `e0a3fb8`).
|
||||
|
||||
## 9. Next steps
|
||||
|
||||
После approval этого spec'а:
|
||||
|
||||
1. `git add` + `git commit` этого spec-файла.
|
||||
2. Запустить `superpowers:writing-plans` для **Sprint 1** (5 P0-эпиков). Когда Sprint 1 закрыт — Sprint 2 и т.д.
|
||||
3. Между спринтами — verification-before-completion regression sweep (Pest --parallel + Vitest + vue-tsc + lefthook).
|
||||
4. Обновление CLAUDE.md / реестра / memory после каждого закрытого спринта (через `claude-md-management:revise-claude-md`).
|
||||
@@ -1,149 +0,0 @@
|
||||
# Design: переделка миграции проектов + распределения лидов
|
||||
|
||||
**Дата:** 2026-05-20 · **Тип:** FEATURE epic · **Статус:** дизайн на ревью заказчика.
|
||||
**Discovery-brief:** [docs/discovery/2026-05-20-project-migration-redesign-brief.md](../../discovery/2026-05-20-project-migration-redesign-brief.md) (требования R1–R7 + алгоритм заказа/распределения).
|
||||
|
||||
## 1. Контекст
|
||||
|
||||
Два связанных изменения вокруг жизненного цикла «проект Лидерры → проект(ы) у поставщика crm.bp-gr.ru → входящие лиды»:
|
||||
|
||||
1. **Экспорт проекта** сейчас отложенный и неполный: онлайн ставится только каркас (лимит 0, регионы пусто), параметры дописывает ночной крон. Поле `regions` на портал уходит **пустым** (gap зафиксирован в [recon §2](../../discovery/2026-05-19-rt-project-form-locators.md)).
|
||||
2. **Распределение лидов** не имеет потолка получателей — один номер может уйти многим клиентам (владелец номера «сходит с ума»).
|
||||
|
||||
Гранулярность региона — **субъект РФ (коды 1..89)**, уже реализована в ЛК (Plan 6): [resources/js/constants/regions.ts](../../../app/resources/js/constants/regions.ts), [NewProjectDialog.vue:88-109](../../../app/resources/js/views/projects/NewProjectDialog.vue#L88-L109). `projects.regions INT[]` — коды субъектов; legacy `region_mask`/`region_mode` (8 ФО) — DEPRECATED, использовался только phone-фильтром.
|
||||
|
||||
## 2. Закрытые решения (brainstorming 2026-05-20)
|
||||
|
||||
| # | Под-вопрос | Решение |
|
||||
|---|---|---|
|
||||
| 1 | Scope тумблера режима экспорта | **Глобально (SaaS)** — `system_settings`, единое поведение для всех тенантов. |
|
||||
| 2 | Онлайн-режим при недоступном портале | **Ярус-3 очередь** — переиспользовать существующий `FailoverProjectChannel` (ярус1 AJAX → ярус2 форма → ярус3 ручная очередь). Пользователь не блокируется. |
|
||||
| 3 | «Вся РФ» / взрыв проектов | Создание per-субъект **оставить как есть**; от взрыва — **ручной экран очистки** в админке. Регион в ЛК — **обязателен** (валидация). |
|
||||
| 4 | Имя проекта на портал | **Строго = источник** (`signal_identifier`: домен/телефон/отправитель); тег = регион; отдельного человекочитаемого поля **нет**. |
|
||||
| 5 | Ключ конкуренции (источник+регион+день) | Сопоставляется **структурно** — через `supplier_project` (= источник × субъект). Phone-фильтр `region_mask` в `LeadRouter` **убрать** (доверяем региону проекта; для мобильных он всё равно no-op). |
|
||||
| P1 | «Вся РФ» при «требовать регион» | Явная опция «Вся РФ» в ЛК → **один пул** (1 save × 3 платформы = 3 проекта, native `regions=[]`, `tag="РФ"`). При выборе «Вся РФ» — **предупреждение «вы выбрали всю Россию» с обязательным подтверждением**. Конкретные субъекты → per-субъект save. |
|
||||
| P2 | Структура | **Один связный spec** (этот файл); фазы — в плане. |
|
||||
|
||||
## 3. Архитектура изменений (обзор)
|
||||
|
||||
Ядро — переход от модели «1 Лидерра-проект ↔ 3 supplier_projects (по слоту на платформу)» к **«1 Лидерра-проект ↔ N supplier_projects (субъект × платформа), M:N»**. supplier_project становится ключом конкуренции `(источник × субъект × платформа)`; вокруг него крутятся заказ, распределение и очистка.
|
||||
|
||||
```
|
||||
ЛК (выбор субъектов / «Вся РФ» + подтверждение)
|
||||
↓ create/edit
|
||||
ProjectService → (режим: онлайн|пакетный)
|
||||
↓
|
||||
SyncSupplierProjectJob (онлайн, сразу, полные параметры) SyncSupplierProjectsJob (пакетный крон 18:00)
|
||||
↓ per (источник × субъект): один save с флагами B1+B2+B3 (R5), портал делит лимит (R6)
|
||||
supplier_projects (subject_code) ←M:N→ projects (pivot)
|
||||
↓ заказ = max(наиб, ceil(Σ/3)) per supplier_project
|
||||
crm.bp-gr.ru
|
||||
↓ webhook лид (raw_payload[tag] = субъект)
|
||||
RouteSupplierLeadJob → LeadRouter (по supplier_project, без phone-фильтра)
|
||||
↓ cap=3 рандом из недобравших
|
||||
deals (region tag из payload) + lead_charges
|
||||
```
|
||||
|
||||
## 4. Компоненты
|
||||
|
||||
### 4.1. Режимы экспорта (R1/R2/R3)
|
||||
|
||||
- **Глобальный тумблер** `system_settings['supplier_export_mode']` ∈ `{online, batch}`, default `batch` (прод-безопасно). Тип `string`. Сид-строка, без миграции схемы.
|
||||
- **Пакетный** (`batch`) — текущий ночной `SyncSupplierProjectsJob` ([app/app/Jobs/Supplier/SyncSupplierProjectsJob.php:59](../../../app/app/Jobs/Supplier/SyncSupplierProjectsJob.php#L59)). **Крон уже `dailyAt('18:00')`** ([console.php:52-54](../../../app/routes/console.php#L52)) — R2 фактически выполнен в failover-spec 19.05.2026; формулировка brief «20:30 → 18:00» устарела. В эпике R2 = только закрепить тестом (`SupplierScheduleTest` уже это проверяет).
|
||||
- **Онлайн** (`online`) — при create/edit проекта в ЛК `ProjectService` сразу диспатчит полный sync (лимит/дни/регионы/субъекты), не каркас. При недоступном портале — стандартная эскалация `FailoverProjectChannel` в ярус-3 очередь (Q2). Пользователь ЛК не ждёт результата.
|
||||
- Резолвер режима — единая точка (напр. `SupplierExportMode::current()`), читается и `ProjectService`, и админкой.
|
||||
|
||||
### 4.2. Модель данных
|
||||
|
||||
- **`supplier_projects`** ([schema.sql:902](../../../db/schema.sql#L902)):
|
||||
- +`subject_code SMALLINT NULL` — субъект РФ (1..89); `NULL` = пул «Вся РФ».
|
||||
- UNIQUE index `(platform, unique_key)` → `(platform, unique_key, subject_code)` (NULL-субъект уникален отдельно; под `NULLS NOT DISTINCT` Postgres 15+ — пул «Вся РФ» один на источник×платформу).
|
||||
- `current_regions` (JSONB) хранит native-`regions`, переданные порталу: `[subject_code]` для per-субъект, `[]` для пула.
|
||||
- **Pivot `project_supplier_links`** (новая таблица, заменяет 3 FK-слота):
|
||||
- `project_id BIGINT`, `supplier_project_id BIGINT`, `platform VARCHAR(4)`, `subject_code SMALLINT NULL`, `created_at`.
|
||||
- PK/UNIQUE `(project_id, supplier_project_id)`; FK на обе стороны (`ON DELETE CASCADE` со стороны project, `ON DELETE CASCADE` со стороны supplier_project).
|
||||
- Колонки `projects.supplier_b{1,2,3}_project_id` ([schema.sql:808-810](../../../db/schema.sql#L808-L810)) — **DEPRECATED**, миграция данных в pivot (см. §5), затем удаление в follow-up.
|
||||
- **`deals`** ([schema.sql:1573](../../../db/schema.sql#L1573)): регион-тег из `raw_payload['tag']` (имя субъекта) → код субъекта (через словарь `regions.ts`/PHP-зеркало) → пишем в **новую колонку `deals.subject_code SMALLINT NULL`** (не путать с `region_code` ISO-3166, который phone-derived и ненадёжен для мобильных). Источник истины региона сделки = тег поставщика, не телефон.
|
||||
- **`system_settings`** ([schema.sql:566](../../../db/schema.sql#L566)) — сид `supplier_export_mode`.
|
||||
|
||||
### 4.3. Маппинг save (R5/R6/R7, Q4, P1)
|
||||
|
||||
- **R5** — один `save` с тремя флагами `srcrt+srcbl+srcmt=true` → портал создаёт 3 rt-проекта (B1/B2/B3). **Решение заказчика: вариант а** — ответ `rt-project-save` отдаёт `id` только последнего (recon §находка 2), поэтому после save **дочитать `listProjects`** ([SupplierPortalClient.php:61](../../../app/app/Services/Supplier/SupplierPortalClient.php#L61)) и найти 3 свежесозданных по `name`+`tag` → забрать все 3 `external_id`. Fallback (б) «3 раздельных save» — только если smoke варианта а провалится (см. §8 R-SAVE). Текущий код шлёт по одному флагу — переписать.
|
||||
- **R6** — убрать наш сплит `SupplierQuotaAllocator::distributeForPlatform` ([SupplierQuotaAllocator.php:73](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L73)); слать **один лимит** на (источник×субъект), портал делит на B1/B2/B3 поровну (verified: лимит 15 → по 5).
|
||||
- **R7** — `tag` = имя субъекта (не `_lidpotok`, [SupplierPortalClient.php:431](../../../app/app/Services/Supplier/SupplierPortalClient.php#L431)); native `regions=[subject_code]`; per-субъект отдельный save. **«Вся РФ» (P1)** → один save, `tag="РФ"`, `regions=[]`.
|
||||
- **Q4** — `name` = `signal_identifier` (как сейчас, [SyncSupplierProjectJob.php:159](../../../app/app/Jobs/SyncSupplierProjectJob.php#L159)); строго источник.
|
||||
- `unique_key` — расширить субъектом: `{signal_identifier}` (как сейчас) + `subject_code` уходит в отдельную колонку (§4.2), уникальность — составным индексом.
|
||||
|
||||
### 4.4. Регион → deals (R7)
|
||||
|
||||
В `RouteSupplierLeadJob` при создании Deal: взять `raw_payload['tag']` → резолв в `subject_code` (PHP-словарь субъектов, зеркало `regions.ts`) → записать `deals.subject_code`. При теге `"РФ"`/неизвестном — `NULL`. Доступно для фильтров/аналитики сделок.
|
||||
|
||||
### 4.5. Формула заказа (алгоритм brief)
|
||||
|
||||
Per `supplier_project` (= источник×субъект), для eligible-сегодня клиентов (по pivot, workday-маска, активность):
|
||||
|
||||
```
|
||||
Σ = сумма daily_limit_target клиентов
|
||||
наиб = max(daily_limit_target)
|
||||
заказ = max( наиб , ceil(Σ / 3) )
|
||||
```
|
||||
|
||||
- `ceil(Σ/3)` — ёмкость шаринга (лид продаётся ≤3 раз); `наиб` — крупнейший клиент должен добрать.
|
||||
- Заказ — потолок запроса; платим за фактически поступившие. Один лимит на (источник×субъект), портал делит (R6).
|
||||
- Переписать `SupplierQuotaAllocator::allocate` ([:38](../../../app/app/Services/Supplier/SupplierQuotaAllocator.php#L38)) — pure-функция, агрегирует по pivot вместо суммы по платформам; `distributeForPlatform` удалить.
|
||||
- Примеры (verified в brief): `[5,5,10,20]→20`; `15×5+10 (16 кл.)→29`; `3×15→15`; `3×15+30→30`; `4×10→14`.
|
||||
|
||||
### 4.6. Распределение лида (cap=3, brief)
|
||||
|
||||
- `LeadRouter::matchEligibleProjects` ([LeadRouter.php:46](../../../app/app/Services/LeadRouter.php#L46)) — кандидаты по **pivot** (вместо `match($platform)` по FK-колонке), фильтры `is_active`, workday, `delivered_today < лимит`, баланс. **Убрать phone-фильтр** `phoneMatchesRegions` ([:87-93](../../../app/app/Services/LeadRouter.php#L87-L93)) — Q5.
|
||||
- `RouteSupplierLeadJob` ([RouteSupplierLeadJob.php:115](../../../app/app/Jobs/RouteSupplierLeadJob.php#L115)) — из eligible (остаток лимита > 0) выбрать **3 случайных** (cap=3), создать Deal каждому. Группировка выкинута. Дедуп по `source_crm_id`/`duplicate_of_id` — сохраняется.
|
||||
- Детерминизм тестов: рандом через инъектируемый сидируемый источник (Randomizer/closure).
|
||||
|
||||
### 4.7. Админка
|
||||
|
||||
- **Тумблер режима экспорта** (онлайн/пакетный) — в существующем разделе supplier-integration (`AdminSupplierIntegrationView.vue` + контроллер): GET текущий режим, POST смена → пишет `system_settings`.
|
||||
- **Экран «Проекты у поставщика»** (новый), строка на `supplier_project`:
|
||||
- источник (`unique_key`) · платформа · тег-субъект (`subject_code`→имя)
|
||||
- **кто заказывал** — тенанты/клиенты с активными `projects`, связанными через pivot
|
||||
- **дата последней поставки** — `max(supplier_leads.created_at)` по проекту (или по deals)
|
||||
- чекбоксы + bulk «Удалить выбранные» → `SupplierPortalClient::deleteProject(externalId)` ([SupplierPortalClient.php:97](../../../app/app/Services/Supplier/SupplierPortalClient.php#L97)) + снять локальную запись (CASCADE по pivot). Удаление — **ручное** (Q3).
|
||||
- Уважать окно портала (22:00–00:00 правки запрещены) — при попытке в окне показать ошибку/деферить.
|
||||
- **NB:** уже существует авто-джоб `CleanupInactiveSupplierProjectsJob` ([console.php:55](../../../app/routes/console.php#L55), daily 02:00) — TTL-очистка по `supplier_projects.inactive_since` (180 дней). Ручной экран — **дополнение** (немедленная очистка orphan по решению админа), не замена; авто-TTL остаётся.
|
||||
|
||||
### 4.8. ЛК (Q3b, P1)
|
||||
|
||||
- **Валидация**: нельзя сохранить проект без выбора региона (минимум один субъект **или** «Вся РФ»). Backend `ProjectController` store/update + фронт.
|
||||
- **Опция «Вся РФ»**: вернуть в autocomplete (sentinel `code:0`, сейчас отфильтрован [NewProjectDialog.vue:154](../../../app/resources/js/views/projects/NewProjectDialog.vue#L154)). Выбор «Вся РФ» — взаимоисключающий с конкретными субъектами.
|
||||
- **Предупреждение (P1)**: при выборе «Вся РФ» — модал/диалог «Вы выбрали всю Россию — проект будет получать лиды по всем регионам. Подтвердить?» с обязательным подтверждением до сохранения.
|
||||
|
||||
## 5. Данные и миграция
|
||||
|
||||
1. Добавить `supplier_projects.subject_code`, новый UNIQUE-индекс, pivot `project_supplier_links`, `deals.subject_code`, сид `system_settings.supplier_export_mode`. Запись в `db/CHANGELOG_schema.md` ([правило §4.2](../../Pravila_raboty_Claude_v1_1.md)).
|
||||
2. **Бэкофилл pivot** из существующих `projects.supplier_b{1,2,3}_project_id` (для каждого ненулевого слота → строка pivot, `subject_code=NULL` для legacy записей).
|
||||
3. RLS/роли: pivot и новые колонки — под существующую модель (`crm_supplier_worker` BYPASSRLS для sharing-flow читает pivot; tenant-проекты — под RLS). `rls-reviewer` на миграции.
|
||||
4. Удаление `supplier_b{1,2,3}_project_id` — **follow-up** после переключения всех читателей (LeadRouter, sync-jobs, admin) на pivot. В этом эпике колонки остаются (двойная запись) до зелёной регрессии — снижает риск.
|
||||
|
||||
## 6. Тестирование (TDD)
|
||||
|
||||
- **Pure**: `SupplierQuotaAllocator::allocate` — 5 verified-примеров заказа + edge (1 клиент, недобор, sms-без-keyword).
|
||||
- **LeadRouter/RouteSupplierLeadJob**: cap=3 (4+ eligible → ровно 3), <3 eligible → все, лимит-потолок (недобравшие), сидируемый рандом, дедуп.
|
||||
- **Sync**: онлайн полный sync; пакетный 18:00; per-субъект save (tag, native regions); «Вся РФ» пул (1 save, regions=[], tag РФ); R5 multi-flag; R6 нет сплита; failover ярус-3.
|
||||
- **region→deals**: тег субъекта → `deals.subject_code`; «РФ»/неизвестный → NULL.
|
||||
- **Админка**: список (кто заказывал, дата последней поставки), bulk-delete, тумблер режима; окно портала.
|
||||
- **ЛК (Vitest)**: валидация «регион обязателен»; «Вся РФ» предупреждение+подтверждение; взаимоисключение.
|
||||
- **Регрессия**: полный Supplier+Integration+Webhook suite зелёный; миграция `migrate:fresh` + бэкофилл.
|
||||
|
||||
## 7. Вне scope / будущее
|
||||
|
||||
- Per-tenant режим экспорта (сейчас глобальный, Q1).
|
||||
- Удаление DEPRECATED `supplier_b{1,2,3}_project_id` и `region_mask`/`region_mode` (follow-up после переключения читателей).
|
||||
- Автоматическая очистка проектов (сейчас только ручная, Q3).
|
||||
- Расширение `PhonePrefixService` до полного справочника субъектов (регион теперь приходит тегом — потребность снижается).
|
||||
|
||||
## 8. Риски и известные ограничения
|
||||
|
||||
- **R-SAVE (med, решение принято — вариант а):** ответ `rt-project-save` возвращает `id` последнего из 3 проектов (recon §находка 2). Подход: после мульти-save дочитать `listProjects` и сопоставить 3 свежих по `name`+`tag`. **Task 1 плана = живой smoke**, что список реально отдаёт все 3 свежесозданных; fallback (б) «3 раздельных save» — только если smoke провалится. До зелёного smoke прод-путь не переписывать.
|
||||
- **R-PIVOT (high):** замена 3-FK на M:N трогает LeadRouter, sync-jobs, admin, тесты — фазировать; двойная запись (колонки + pivot) до зелёной регрессии.
|
||||
- **R-WINDOW:** правки/удаления у поставщика запрещены 22:00–00:00 МСК — sync и cleanup уважают окно (`WindowDeferredException` уже есть).
|
||||
- **R-RANDOM:** cap=3 рандом должен быть детерминируемым в тестах (инъекция источника случайности).
|
||||
- **Не верифицировано:** реальный JSON-ответ multi-flag save (R-SAVE); поведение портала при `tag` с кириллицей-субъектом на больших объёмах.
|
||||
@@ -1,149 +0,0 @@
|
||||
# Удаление проектов вместо архива + дедуп источника + человеческие ошибки
|
||||
|
||||
**Дата:** 2026-05-21
|
||||
**Статус:** утверждён (заказчик «ок делай»)
|
||||
**Ветка исполнения:** worktree от `feat/project-migration-redesign` (origin/main)
|
||||
|
||||
## Контекст и проблема
|
||||
|
||||
Заказчик при создании проекта получил сырой `SQLSTATE[23505] Unique violation ...
|
||||
projects_tenant_id_name_key` в UI. Разбор вскрыл 4 связанных задачи:
|
||||
|
||||
1. **Архивация бессмысленна** — если клиенту проект не нужен, хранить его незачем.
|
||||
Сейчас «удаление» (`DELETE /api/projects/{id}`) = мягкая архивация
|
||||
(`ProjectService::archive` → `archived_at`, `is_active=false`). Нужно настоящее удаление.
|
||||
2. **Шеринг между клиентами** — два разных клиента могут завести проекты с одинаковыми
|
||||
параметрами/источником. Это корректно (модель шеринга: один донор раздаётся ≤3 клиентам
|
||||
через `LeadRouter`). Менять не нужно — подтвердить.
|
||||
3. **Дедуп источника внутри клиента отсутствует** — один клиент может завести 2 проекта на
|
||||
один источник (номер/домен/SMS-отправитель). Нужен запрет.
|
||||
4. **Утечка SQL в UI** — `Project::create()` бьётся о DB-констрейнт без перехвата → сырой
|
||||
`SQLSTATE` рендерится пользователю. Нужны человеческие сообщения.
|
||||
|
||||
## Ключевые факты кодовой базы (разведка)
|
||||
|
||||
- `deals.project_id` — **без FK** (таблица партиционирована, partition-wise FK не
|
||||
поддерживается). Hard-delete проекта НЕ каскадит и НЕ блокируется сделками → они «повиснут».
|
||||
Поэтому удаление проекта со сделками опасно → блокируем (см. Решение 1).
|
||||
- `ON DELETE CASCADE` на `projects(id)` имеют только служебные таблицы:
|
||||
`project_supplier_links`, `supplier_manual_sync_queue`, `project_suppliers`,
|
||||
`project_user_assignments`, `project_limit_adjustments`. Их каскад при удалении — норма.
|
||||
- Уникальность `projects` на DB-уровне: `projects_tenant_id_name_key` = `(tenant_id, name)`.
|
||||
Дедупа по источнику на DB-уровне нет.
|
||||
- Источник проекта: `signal_identifier` (call=телефон `7\d{10}`, site=домен) либо
|
||||
`sms_senders[]` (+ `sms_keyword`) для sms.
|
||||
- Шеринг донора: `project_supplier_links` (M:N projects↔supplier_projects) связывает
|
||||
Лидерра-проекты РАЗНЫХ тенантов с одним `supplier_project` (донором). Outbound-синк
|
||||
(`SyncSupplierProjectsJob`) считает агрегатный лимит по всем клиентам источника
|
||||
(`computeOrder = max(max, ceil(Σ/3))`, cap=3).
|
||||
- Архив-ссылки (под снос): backend — `ProjectController` (destroy/index/bulk),
|
||||
`ProjectService` (archive/bulk/update/resolveBulkScope), `Project` (scopeActive/scopeArchived,
|
||||
fillable+cast `archived_at`), `ProjectResource`, `DashboardController`,
|
||||
`SyncSupplierProjectsJob`, `BulkProjectActionRequest`. Frontend — `BulkActionsBar`,
|
||||
`ProjectCard`, `ProjectDetailsDrawer`, `ProjectsView`, `projectsStore`.
|
||||
|
||||
## Решения (утверждены заказчиком)
|
||||
|
||||
### Решение 1 — удаление вместо архива, с защитой по сделкам
|
||||
|
||||
- `DELETE /api/projects/{id}` → **hard delete** через новый `ProjectService::delete()`.
|
||||
- **Guard:** если по проекту есть хоть одна `deals` (любой статус, включая `deleted_at`-soft)
|
||||
→ удаление блокируется HTTP 422 (`{errors:{...}}`/`message`, формат фронта) с сообщением:
|
||||
*«Нельзя удалить проект: по нему есть сделки. Остановите приём (пауза), чтобы скрыть из
|
||||
работы».* Пустой проект (0 сделок) → удаляется насовсем.
|
||||
- Архивация убирается **полностью**: код-пути `archive`, scope `archived`, фильтр «Архивные»,
|
||||
bulk-action `archive`. Колонка `archived_at` дропается миграцией (schema bump). Пауза
|
||||
(`is_active`) сохраняется — это отдельный механизм.
|
||||
- Bulk: action `archive` → `delete` (с тем же guard'ом per-project; проекты со сделками
|
||||
попадают в `skipped` с причиной, не роняют весь батч).
|
||||
|
||||
### Решение 2 — удаление у поставщика с учётом шеринга
|
||||
|
||||
При удалении Лидерра-проекта P (тенант T) по источнику X (один или несколько доноров
|
||||
B1/B2/B3 через `project_supplier_links`):
|
||||
|
||||
1. Удаляем P локально (его `project_supplier_links` уходят каскадом).
|
||||
2. Для каждого затронутого `supplier_project` S (донора источника X):
|
||||
- Считаем оставшиеся `project_supplier_links` на S (проекты ДРУГИХ тенантов).
|
||||
- **Остались** → донор нужен другим клиентам → **пере-синк** S (агрегатный лимит/регионы/дни
|
||||
без T) через outbound-синк. У поставщика проект НЕ удаляем.
|
||||
- **Не осталось** (T был последним) → у поставщика **удаляем** донора
|
||||
(`SupplierPortalClient::deleteProject(external_id)`) + удаляем локальную запись
|
||||
`supplier_projects` S.
|
||||
3. Внешние вызовы к поставщику — через job (resilience, retry), не inline в HTTP-запросе.
|
||||
|
||||
Граничные сценарии:
|
||||
|
||||
- P не привязан ни к одному донору (например, синк ещё не прошёл) → шаг 2 пропускается.
|
||||
- Несколько доноров (B1/B2/B3) у одного источника → шаг 2 для каждого независимо.
|
||||
- Падение удаления у поставщика → job ретраит; локальное удаление P уже выполнено
|
||||
(eventual consistency; «висячий» донор у поставщика подметёт следующий синк/ретрай).
|
||||
|
||||
### Решение 3 — дедуп источника внутри клиента
|
||||
|
||||
- При создании и при изменении источника: внутри `tenant_id` источник должен быть уникален
|
||||
среди проектов клиента.
|
||||
- «Источник» (source key):
|
||||
- call/site → `signal_identifier`;
|
||||
- sms → нормализованный `sms_senders` (сортировка+lower) + `sms_keyword`.
|
||||
- Enforcement: app-level проверка в `ProjectService::create()`/`update()` → 422 с сообщением
|
||||
*«У вас уже есть проект с этим источником: "<название>"»*. После сноса архива «существующие
|
||||
проекты» = все проекты клиента (soft-deleted проектов нет — мы их hard-delete'им).
|
||||
- DB-уровень: partial unique index как защита-эшелон (опционально, в той же миграции);
|
||||
его нарушение перехватывается общим обработчиком (Решение 4) и не утекает.
|
||||
|
||||
### Решение 4 — человеческие ошибки вместо SQL
|
||||
|
||||
- App-level pre-checks (до DB): уникальность `name` в рамках клиента + уникальность источника
|
||||
(Решение 3) → 422 `{errors: {field: [msg]}}` (формат, который уже понимает фронт).
|
||||
- Глобальный перехват `Illuminate\Database\QueryException` в `bootstrap/app.php`
|
||||
(`withExceptions`): в лог — полный текст; пользователю — generic
|
||||
*«Не удалось сохранить. Проверьте данные или попробуйте ещё раз»* (HTTP 422 для JSON-запросов).
|
||||
Никакой `SQLSTATE` в UI.
|
||||
- Фронт `NewProjectDialog`/`projectsStore` — убедиться, что 422 `errors`/`message`
|
||||
показываются как есть (уже умеет; правок минимум).
|
||||
|
||||
## Затрагиваемые компоненты
|
||||
|
||||
**Backend**
|
||||
|
||||
- `ProjectService`: +`delete()` (guard сделок + оркестрация шеринга), −`archive()`,
|
||||
bulk `archive`→`delete`, чистка `archived_at`/`archived` из update/resolveBulkScope,
|
||||
+дедуп источника в create/update.
|
||||
- `ProjectController`: `destroy()` → `delete()`, index `status=archived` убрать, bulk doc.
|
||||
- `BulkProjectActionRequest`: `archive`→`delete`, status `archived` убрать.
|
||||
- `Project` (модель): −scopeArchived, scopeActive упростить/убрать, −`archived_at` fillable+cast.
|
||||
- `ProjectResource`: −`archived_at`.
|
||||
- `DashboardController`, `SyncSupplierProjectsJob`: убрать `whereNull('archived_at')`.
|
||||
- Новый job `DeleteSupplierProjectJob` (или расширение существующего) — удаление донора у
|
||||
поставщика, когда источник остался без потребителей.
|
||||
- Миграция: `DROP COLUMN projects.archived_at` (+ опц. partial unique index источника) →
|
||||
schema bump v8.27 + `db/CHANGELOG_schema.md`.
|
||||
- `bootstrap/app.php`: глобальный handler `QueryException`.
|
||||
|
||||
**Frontend**
|
||||
|
||||
- `BulkActionsBar`: «Архивировать»→«Удалить» (+подтверждение/иконка).
|
||||
- `ProjectCard`, `ProjectDetailsDrawer`: «Архивировать»→«Удалить».
|
||||
- `ProjectsView`: `@archive`→`@delete`, убрать фильтр «Архивные».
|
||||
- `projectsStore`: `archive()`→`delete()`, bulk `archive`→`delete`, тип `archived_at` убрать.
|
||||
|
||||
## Тестирование (TDD)
|
||||
|
||||
- Backend (Pest): delete пустого проекта → 204 + строки нет; delete со сделками → 409/422,
|
||||
проект жив; шеринг — delete последнего потребителя → донор удалён у поставщика (mock client);
|
||||
delete при оставшихся потребителях → донор НЕ удалён, пере-синк; дедуп источника create/update
|
||||
→ 422; имя-дубль → 422 (не SQL); глобальный QueryException handler → generic message.
|
||||
- Frontend (Vitest): кнопки «Удалить» вместо «Архивировать»; нет фильтра «Архивные»;
|
||||
store.delete дёргает DELETE; ошибка сервера показывается человеческим текстом.
|
||||
- Live («проверь на практике» по каждой задаче, затем чистка тестовых данных):
|
||||
1) удаление пустого проекта + блок на проекте со сделками;
|
||||
2) два тенанта с одинаковым источником — создание проходит;
|
||||
3) попытка дубля источника у одного тенанта — отказ с понятным текстом;
|
||||
4) создание дубля имени — человеческое сообщение, не SQL.
|
||||
|
||||
## Вне scope (YAGNI)
|
||||
|
||||
- Restore/корзина удалённых проектов (удаление окончательное по решению заказчика).
|
||||
- Массовая авто-чистка уже-архивированных проектов dev-БД (разовая ручная операция).
|
||||
- `StatusPill 'archived'` mapping не трогаем (используется и для статусов сделок).
|
||||
Reference in New Issue
Block a user