From cbc0ab9e0eec4f03cbfa994c90cf8e638471c64d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 15 Jun 2026 09:23:18 +0300 Subject: [PATCH] chore: prune Liderra-specs-plans over-copied into claude-brain --- .../plans/2026-05-09-sprint1-hygiene-plan.md | 1296 ----- .../2026-05-10-sprint4-audit-tail-plan.md | 768 --- ...2026-05-10-sprint5-preprod-tooling-plan.md | 563 -- ...2026-05-10-sprint6-phase-a-reports-plan.md | 1212 ----- .../2026-05-10-supplier-foundation-plan.md | 2515 --------- .../2026-05-10-supplier-plan26-cleanup.md | 808 --- ...026-05-10-supplier-webhook-routing-plan.md | 2637 ---------- ...2026-05-11-plan4-billing-csv-admin-plan.md | 4634 ----------------- .../plans/2026-05-11-supplier-sync-plan3.md | 3188 ------------ .../2026-05-13-test-quality-preprod-sprint.md | 976 ---- .../plans/2026-05-15-sprint1-p0-fixes.md | 1739 ------- .../plans/2026-05-15-sprint2a-auth.md | 424 -- .../plans/2026-05-15-sprint2b-settings.md | 2027 ------- .../plans/2026-05-15-sprint2c-billing.md | 2674 ---------- .../2026-05-16-sprint3a-layout-navigation.md | 542 -- ...2026-05-16-sprint3b-dashboard-deeplinks.md | 823 --- .../plans/2026-05-16-sprint3c-reports.md | 1364 ----- .../2026-05-16-sprint3d-admin-actions.md | 1128 ---- .../2026-05-16-sprint3e-settings-tabs.md | 240 - .../2026-05-16-sprint3f-api-middleware.md | 355 -- .../2026-05-16-sprint4-historical-import.md | 3413 ------------ .../plans/2026-05-16-sprint5a-auth-polish.md | 567 -- .../plans/2026-05-17-deals-page-redesign.md | 2831 ---------- .../plans/2026-05-17-sprint5b-layout-views.md | 1060 ---- .../2026-05-17-sprint5c-billing-admin.md | 700 --- ...26-05-17-sprint5d-cleanup-mock-fallback.md | 480 -- .../plans/2026-05-17-sprint6-p3-polish.md | 387 -- ...18-deals-drawer-and-project-source-edit.md | 885 ---- ...26-05-18-supplier-csv-reconcile-channel.md | 1553 ------ .../2026-05-19-supplier-migration-followup.md | 1089 ---- ...05-19-supplier-project-channel-failover.md | 2746 ---------- ...ject-migration-redesign-plan-4-admin-lk.md | 465 -- ...-05-22-supplier-projects-import-lkomega.md | 1396 ----- .../2026-05-23-admin-tenant-balance-edit.md | 912 ---- ...5-23-billing-v2-spec-a-balance-rub-plan.md | 2361 --------- ...05-23-billing-v2-spec-b-duplicates-plan.md | 807 --- ...6-05-24-billing-v2-spec-c-preflight-vtb.md | 2669 ---------- ...026-05-24-legacy-direct-webhook-removal.md | 1173 ----- ...05-25-supplier-webhook-phase-1-json-422.md | 355 -- ...26-05-25-supplier-webhook-phase-2-dedup.md | 475 -- ...upplier-webhook-phase-3-direct-platform.md | 899 ---- .../2026-05-26-slepok-routing-protection.md | 2334 --------- .../2026-05-29-lead-region-resolution.md | 641 --- ...ier-webhook-fast-fail-and-stuck-cleanup.md | 427 -- .../2026-05-09-sprint1-hygiene-design.md | 347 -- ...2026-05-09-sprint2-modernization-design.md | 80 - ...26-05-10-sprint6-postmvp-backend-design.md | 460 -- .../2026-05-10-supplier-integration-design.md | 295 -- .../2026-05-11-plan3-supplier-sync-design.md | 618 --- ...26-05-11-plan4-billing-csv-admin-design.md | 736 --- .../2026-05-17-deals-page-redesign-design.md | 132 - ...8-supplier-csv-reconcile-channel-design.md | 217 - ...upplier-project-channel-failover-design.md | 223 - ...supplier-projects-import-lkomega-design.md | 147 - ...-05-23-admin-tenant-balance-edit-design.md | 98 - ...23-billing-v2-spec-a-balance-rub-design.md | 633 --- ...-23-billing-v2-spec-b-duplicates-design.md | 160 - ...-billing-v2-spec-c-preflight-vtb-design.md | 820 --- ...24-legacy-direct-webhook-removal-design.md | 204 - ...-25-supplier-webhook-reliability-design.md | 291 -- ...-05-26-slepok-routing-protection-design.md | 1106 ---- ...026-05-29-lead-region-resolution-design.md | 1069 ---- 62 files changed, 68174 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-09-sprint1-hygiene-plan.md delete mode 100644 docs/superpowers/plans/2026-05-10-sprint4-audit-tail-plan.md delete mode 100644 docs/superpowers/plans/2026-05-10-sprint5-preprod-tooling-plan.md delete mode 100644 docs/superpowers/plans/2026-05-10-sprint6-phase-a-reports-plan.md delete mode 100644 docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md delete mode 100644 docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md delete mode 100644 docs/superpowers/plans/2026-05-10-supplier-webhook-routing-plan.md delete mode 100644 docs/superpowers/plans/2026-05-11-plan4-billing-csv-admin-plan.md delete mode 100644 docs/superpowers/plans/2026-05-11-supplier-sync-plan3.md delete mode 100644 docs/superpowers/plans/2026-05-13-test-quality-preprod-sprint.md delete mode 100644 docs/superpowers/plans/2026-05-15-sprint1-p0-fixes.md delete mode 100644 docs/superpowers/plans/2026-05-15-sprint2a-auth.md delete mode 100644 docs/superpowers/plans/2026-05-15-sprint2b-settings.md delete mode 100644 docs/superpowers/plans/2026-05-15-sprint2c-billing.md delete mode 100644 docs/superpowers/plans/2026-05-16-sprint3a-layout-navigation.md delete mode 100644 docs/superpowers/plans/2026-05-16-sprint3b-dashboard-deeplinks.md delete mode 100644 docs/superpowers/plans/2026-05-16-sprint3c-reports.md delete mode 100644 docs/superpowers/plans/2026-05-16-sprint3d-admin-actions.md delete mode 100644 docs/superpowers/plans/2026-05-16-sprint3e-settings-tabs.md delete mode 100644 docs/superpowers/plans/2026-05-16-sprint3f-api-middleware.md delete mode 100644 docs/superpowers/plans/2026-05-16-sprint4-historical-import.md delete mode 100644 docs/superpowers/plans/2026-05-16-sprint5a-auth-polish.md delete mode 100644 docs/superpowers/plans/2026-05-17-deals-page-redesign.md delete mode 100644 docs/superpowers/plans/2026-05-17-sprint5b-layout-views.md delete mode 100644 docs/superpowers/plans/2026-05-17-sprint5c-billing-admin.md delete mode 100644 docs/superpowers/plans/2026-05-17-sprint5d-cleanup-mock-fallback.md delete mode 100644 docs/superpowers/plans/2026-05-17-sprint6-p3-polish.md delete mode 100644 docs/superpowers/plans/2026-05-18-deals-drawer-and-project-source-edit.md delete mode 100644 docs/superpowers/plans/2026-05-18-supplier-csv-reconcile-channel.md delete mode 100644 docs/superpowers/plans/2026-05-19-supplier-migration-followup.md delete mode 100644 docs/superpowers/plans/2026-05-19-supplier-project-channel-failover.md delete mode 100644 docs/superpowers/plans/2026-05-20-project-migration-redesign-plan-4-admin-lk.md delete mode 100644 docs/superpowers/plans/2026-05-22-supplier-projects-import-lkomega.md delete mode 100644 docs/superpowers/plans/2026-05-23-admin-tenant-balance-edit.md delete mode 100644 docs/superpowers/plans/2026-05-23-billing-v2-spec-a-balance-rub-plan.md delete mode 100644 docs/superpowers/plans/2026-05-23-billing-v2-spec-b-duplicates-plan.md delete mode 100644 docs/superpowers/plans/2026-05-24-billing-v2-spec-c-preflight-vtb.md delete mode 100644 docs/superpowers/plans/2026-05-24-legacy-direct-webhook-removal.md delete mode 100644 docs/superpowers/plans/2026-05-25-supplier-webhook-phase-1-json-422.md delete mode 100644 docs/superpowers/plans/2026-05-25-supplier-webhook-phase-2-dedup.md delete mode 100644 docs/superpowers/plans/2026-05-25-supplier-webhook-phase-3-direct-platform.md delete mode 100644 docs/superpowers/plans/2026-05-26-slepok-routing-protection.md delete mode 100644 docs/superpowers/plans/2026-05-29-lead-region-resolution.md delete mode 100644 docs/superpowers/plans/2026-05-29-supplier-webhook-fast-fail-and-stuck-cleanup.md delete mode 100644 docs/superpowers/specs/2026-05-09-sprint1-hygiene-design.md delete mode 100644 docs/superpowers/specs/2026-05-09-sprint2-modernization-design.md delete mode 100644 docs/superpowers/specs/2026-05-10-sprint6-postmvp-backend-design.md delete mode 100644 docs/superpowers/specs/2026-05-10-supplier-integration-design.md delete mode 100644 docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md delete mode 100644 docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md delete mode 100644 docs/superpowers/specs/2026-05-17-deals-page-redesign-design.md delete mode 100644 docs/superpowers/specs/2026-05-18-supplier-csv-reconcile-channel-design.md delete mode 100644 docs/superpowers/specs/2026-05-19-supplier-project-channel-failover-design.md delete mode 100644 docs/superpowers/specs/2026-05-22-supplier-projects-import-lkomega-design.md delete mode 100644 docs/superpowers/specs/2026-05-23-admin-tenant-balance-edit-design.md delete mode 100644 docs/superpowers/specs/2026-05-23-billing-v2-spec-a-balance-rub-design.md delete mode 100644 docs/superpowers/specs/2026-05-23-billing-v2-spec-b-duplicates-design.md delete mode 100644 docs/superpowers/specs/2026-05-24-billing-v2-spec-c-preflight-vtb-design.md delete mode 100644 docs/superpowers/specs/2026-05-24-legacy-direct-webhook-removal-design.md delete mode 100644 docs/superpowers/specs/2026-05-25-supplier-webhook-reliability-design.md delete mode 100644 docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md delete mode 100644 docs/superpowers/specs/2026-05-29-lead-region-resolution-design.md diff --git a/docs/superpowers/plans/2026-05-09-sprint1-hygiene-plan.md b/docs/superpowers/plans/2026-05-09-sprint1-hygiene-plan.md deleted file mode 100644 index 58ce6f2..0000000 --- a/docs/superpowers/plans/2026-05-09-sprint1-hygiene-plan.md +++ /dev/null @@ -1,1296 +0,0 @@ -# Plan: Спринт 1 «Hygiene» — исправление дефектов аудита 2026-05-09 - -> **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:** Закрыть 22 фикса аудита (3 P0 + 10 P1 + 3 P2 + 6 low-risk O-\*) + 3 записи в реестр, разбитых на 6 атомарных коммитов по доменам. - -**Architecture:** 6 фаз / 6 коммитов. Каждая фаза — изолированный scope, заканчивается verification (соответствующие линтеры/тесты) и коммитом. Зависимости: B и D зависят от A (метрики schema + CHANGELOG ссылка). Phase D правки CLAUDE.md/Pravila/Tooling — обязательно через `claude-md-management:claude-md-improver` skill (CLAUDE.md §5 п.10). - -**Tech Stack:** PostgreSQL 16, Laravel 13.7, PHP 8.3, Pest 4, Larastan, Vue 3 + Vuetify 3, ESLint flat-config, Pinia, Axios, lefthook, GitHub Actions, npm scripts, lychee, cspell, markdownlint, pa11y. Платформа: Windows Server 2022, PowerShell + Bash. - -**Spec:** [docs/superpowers/specs/2026-05-09-sprint1-hygiene-design.md](../specs/2026-05-09-sprint1-hygiene-design.md) v1.0, commit `08bbe4d`. - ---- - -## File Structure - -| Файл | Действие | Что меняется | -|---|---|---| -| `db/schema.sql` | Modify | RLS на impersonation_tokens (после строки 540), 2 индекса (после 1521 и 1537), bump версии в шапке v8.10→v8.11, обновить метрики | -| `db/CHANGELOG_schema.md` | Modify | Новая запись «v8.11 от 09.05.2026» в начале (reverse chronological) | -| `app/routes/web.php` | Modify | Добавить `'tenant'` к существующим auth:sanctum group'ам (строки 44, 52, 63) | -| `app/app/Http/Requests/Auth/Concerns/HasPasswordRules.php` | Create | Trait c rules + messages для password | -| `app/app/Http/Requests/Auth/LoginRequest.php` | Modify | Подключить trait, заменить inline rules | -| `app/app/Http/Requests/Auth/RegisterRequest.php` | Modify | То же | -| `app/tests/Feature/AdminIncidentsIndexTest.php` | Modify | bcrypt('test') → bcrypt('test1234') | -| `package.json` | Modify | format:sql:check Windows-совместимый путь | -| `.gitignore` | Modify | Добавить `db/.schema-formatted.tmp.sql` | -| `.lychee.toml` | Modify | exclude root-relative ссылок web/v8 | -| `pa11y.config.json` | Modify | Пути к liderra_v8_handoff/concepts/v8_*.html | -| `app/composer.json` | Modify | Скрипт `audit-offline` | -| `app/eslint.config.js` | Modify | Правило `no-restricted-imports` против vuetify/components | -| `.github/workflows/dependency-check.yml` | Create | Еженедельный npm outdated workflow | -| `README.md` | Modify | Versions sync (Tooling v1.10, Pravila v1.6, schema v8.11) | -| `CLAUDE.md` | Modify (через claude-md-management) | Histoire 21/43, F-K ссылка | -| `docs/Pravila_raboty_Claude_v1_1.md` | Modify (через claude-md-management) | §13.9 cross-ref version | -| `docs/Tooling_v8_3.md` | Modify (через claude-md-management) | stylelint-config-standard ^40.0.0 в §4.2 п.22 | -| `cspell-words.txt` | Verify | `ребрендинга` уже добавлено в self-review аудита, не дублировать | -| `liderra_v8_handoff/docs/BRANDBOOK_v2.md` | Modify | Таблица 14 status-slug mapping | -| `liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md` | Modify | Axe-claim documentation + font-display strategy | -| `docs/Открытые_вопросы_v8_3.md` | Modify | Новый CTO для P1-10, cross-link для P1-11, новый OPEN для P1-09 | - ---- - -## Phase A. DB — schema.sql v8.10 → v8.11 - -### Task A1: Добавить RLS на impersonation_tokens - -**Files:** - -- Modify: `db/schema.sql:540` (после `idx_imp_tokens_admin`, до forward FK ALTER) - -- [ ] **Step A1.1: Прочитать существующий блок RLS политик в schema.sql, чтобы скопировать стиль** - -```bash -sed -n '2390,2410p' db/schema.sql -``` - -Expected: видим существующие `CREATE POLICY tenant_isolation ON ... USING (tenant_id = current_setting('app.current_tenant_id')::bigint);` - -- [ ] **Step A1.2: Добавить ALTER TABLE + CREATE POLICY для impersonation_tokens** - -В `db/schema.sql` после строки 540 (`CREATE INDEX idx_imp_tokens_admin ...`), перед `-- Forward FK на impersonation_tokens`, вставить: - -```sql - --- v8.11 (audit P0-02): RLS-isolation между тенантами для одноразовых impersonation-токенов -ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITY; -CREATE POLICY tenant_isolation ON impersonation_tokens - USING (tenant_id = current_setting('app.current_tenant_id')::bigint); - -``` - -- [ ] **Step A1.3: Verify добавление** - -```bash -grep -n "ENABLE ROW LEVEL SECURITY" db/schema.sql | wc -l -grep -cE "^CREATE POLICY" db/schema.sql -``` - -Expected: ENABLE RLS = **40** (было 39, +1), CREATE POLICY = **38** (было 37, +1). - -### Task A2: Добавить index на failed_webhook_jobs.webhook_log_id - -**Files:** - -- Modify: `db/schema.sql:1521` (после `idx_failed_webhook_unresolved`) - -- [ ] **Step A2.1: Добавить индекс** - -После строки 1521 (`CREATE INDEX idx_failed_webhook_unresolved ON failed_webhook_jobs(failed_at DESC) WHERE resolved_at IS NULL;`) вставить: - -```sql -CREATE INDEX idx_failed_webhook_jobs_log ON failed_webhook_jobs(webhook_log_id); -- v8.11 (audit O-perf-02) -``` - -### Task A3: Добавить index на rejected_deals_log.webhook_log_id - -**Files:** - -- Modify: `db/schema.sql:1537` (после `idx_rejected_tenant_created`) - -- [ ] **Step A3.1: Добавить индекс** - -После строки 1537 (`CREATE INDEX idx_rejected_tenant_created ON rejected_deals_log(tenant_id, created_at DESC);`) вставить: - -```sql -CREATE INDEX idx_rejected_deals_log_webhook ON rejected_deals_log(webhook_log_id); -- v8.11 (audit O-perf-03) -``` - -### Task A4: Bump версии и метрик в шапке schema.sql - -**Files:** - -- Modify: `db/schema.sql:1-30` (шапка) - -- [ ] **Step A4.1: Найти шапку и обновить** - -```bash -sed -n '1,30p' db/schema.sql -``` - -Найти строку с версией `v8.10` и метриками. Изменить: - -- `v8.10` → `v8.11` -- Дату на `09.05.2026` (если уже не она) -- Метрики: `37 RLS` → `38 RLS`, `95 индексов` → `97 индексов` - -### Task A5: Запись в CHANGELOG_schema.md - -**Files:** - -- Modify: `db/CHANGELOG_schema.md` (добавить новую запись в самом верху, после введения) - -- [ ] **Step A5.1: Найти место для вставки** - -```bash -head -20 db/CHANGELOG_schema.md -``` - -Expected: видим intro + первую существующую запись (например, `## v8.10 от ...`). - -- [ ] **Step A5.2: Вставить новую запись «v8.11 от 09.05.2026» перед v8.10** - -Шаблон: - -```markdown -## v8.11 от 09.05.2026 — hygiene-фиксы аудита - -**Источник:** [docs/audit_2026-05-09.md](../docs/audit_2026-05-09.md) (commit `b6ae8dd`). - -### Изменения - -1. **P0-02:** Добавлены `ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITY` и `CREATE POLICY tenant_isolation ON impersonation_tokens` (схема ~ строка 540). -2. **O-perf-02:** Добавлен индекс `idx_failed_webhook_jobs_log` на `failed_webhook_jobs(webhook_log_id)` (строка 1521). -3. **O-perf-03:** Добавлен индекс `idx_rejected_deals_log_webhook` на `rejected_deals_log(webhook_log_id)` (строка 1537). - -### Метрики (после v8.11) - -- 56 базовых таблиц + 12 партиций (без изменений, 68 CREATE TABLE) -- **97 индексов** (было 95, +2 = idx_failed_webhook_jobs_log, idx_rejected_deals_log_webhook) -- **38 RLS-политик** (было 37, +1 = tenant_isolation на impersonation_tokens) -- 5 функций, 13 триггеров (без изменений) - -### Применение - -```bash -cd app && php artisan migrate:fresh -``` - -Для существующего tenant'а с данными миграция через `ALTER TABLE` + `CREATE POLICY` + `CREATE INDEX CONCURRENTLY` (3 раздельных DDL без блокировок). - -``` - -### Task A6: Verification схемы - -- [ ] **Step A6.1: squawk smoke** - -```bash -npm run lint:sql -``` - -Expected: `Found 0 issues in 1 file 🎉`. Если не 0 — разобраться, не коммитить. - -- [ ] **Step A6.2: Метрики** - -```bash -echo "ENABLE RLS: $(grep -cE 'ENABLE ROW LEVEL SECURITY' db/schema.sql)" -echo "CREATE POLICY: $(grep -cE '^CREATE POLICY' db/schema.sql)" -echo "CREATE INDEX (incl UNIQUE): $(grep -cE '^CREATE\s+(UNIQUE\s+)?INDEX' db/schema.sql)" -``` - -Expected: `ENABLE RLS: 40`, `CREATE POLICY: 38`, `CREATE INDEX: 97`. - -Note: ENABLE RLS = 40 vs POLICY = 38 — gap 2 остаётся (одна повторно выявленная косвенно — отдельная задача, не Sprint 1). - -### Task A7: Commit Phase A - -- [ ] **Step A7.1: Stage и commit** - -```bash -git add db/schema.sql db/CHANGELOG_schema.md -git commit -m "$(cat <<'EOF' -fix(db): RLS на impersonation_tokens + 2 missing FK indices (audit P0-02 + O-perf-02/03) - -schema.sql v8.10 → v8.11. Закрытие аудита 2026-05-09 (b6ae8dd): -- P0-02: ALTER TABLE impersonation_tokens ENABLE ROW LEVEL SECURITY - + CREATE POLICY tenant_isolation. Закрывает RLS-gap (39 vs 37 → 40 vs 38). -- O-perf-02: CREATE INDEX idx_failed_webhook_jobs_log на webhook_log_id. -- O-perf-03: CREATE INDEX idx_rejected_deals_log_webhook на webhook_log_id. - -Метрики v8.11: 56 базовых + 12 партиций / 97 индексов / 38 RLS / 5 функций / 13 триггеров. -CHANGELOG_schema.md обновлён. -squawk: 0 issues. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -Expected: pre-commit hook PASS (gitleaks, markdownlint на CHANGELOG, cspell, squawk). - ---- - -## Phase B. Backend - -### Task B1: Применить middleware 'tenant' к существующим auth:sanctum-группам - -**Files:** - -- Modify: `app/routes/web.php:44`, `:52`, `:63` - -**Note:** alias `'tenant'` **уже зарегистрирован** в `app/bootstrap/app.php:17`. Регистрировать заново не нужно. Задача — применить middleware к группам. - -- [ ] **Step B1.1: Прочитать текущее состояние routes/web.php** - -```bash -sed -n '44p;52p;63p' app/routes/web.php -``` - -Expected: - -```text -Route::middleware('auth:sanctum')->prefix('/api/notifications')->group(function () { -Route::middleware('auth:sanctum')->prefix('/api/reminders')->group(function () { -Route::middleware('auth:sanctum')->prefix('/api/reports/jobs')->group(function () { -``` - -- [ ] **Step B1.2: Заменить middleware('auth:sanctum') на middleware(['auth:sanctum', 'tenant'])** - -Через Edit tool, для каждой из 3 строк (44, 52, 63): - -```text -Route::middleware('auth:sanctum')->prefix(...) -``` - -→ - -```text -Route::middleware(['auth:sanctum', 'tenant'])->prefix(...) -``` - -**НЕ трогаем** `/api/deals` (P1-10) и `/api/admin` (P1-11) — они в реестр, не правки в Sprint 1. - -- [ ] **Step B1.3: Verify изменений** - -```bash -grep -n "middleware(\['auth:sanctum', 'tenant'\])" app/routes/web.php | wc -l -``` - -Expected: `3`. - -### Task B2: Создать HasPasswordRules trait - -**Files:** - -- Create: `app/app/Http/Requests/Auth/Concerns/HasPasswordRules.php` - -- [ ] **Step B2.1: Создать каталог Concerns** - -```bash -mkdir -p app/app/Http/Requests/Auth/Concerns -``` - -- [ ] **Step B2.2: Записать trait** - -`app/app/Http/Requests/Auth/Concerns/HasPasswordRules.php`: - -```php - - */ - protected function passwordRules(): array - { - return ['required', 'string', 'min:8', 'max:255']; - } - - /** - * Сообщения об ошибках для password. - * - * @return array - */ - protected function passwordMessages(): array - { - return [ - 'password.required' => 'Укажите пароль.', - 'password.min' => 'Пароль должен быть не короче 8 символов.', - ]; - } -} -``` - -### Task B3: Подключить trait в LoginRequest - -**Files:** - -- Modify: `app/app/Http/Requests/Auth/LoginRequest.php` - -- [ ] **Step B3.1: Добавить use trait + заменить rules['password'] и сообщения** - -Через Edit tool заменить тело класса. Старое: - -```php -class LoginRequest extends FormRequest -{ - /** @return array */ - public function rules(): array - { - return [ - 'email' => ['required', 'string', 'email', 'max:255'], - 'password' => ['required', 'string', 'min:8', 'max:255'], - 'remember' => ['sometimes', 'boolean'], - ]; - } - - /** @return array */ - public function messages(): array - { - return [ - 'email.required' => 'Укажите email.', - 'email.email' => 'Email указан некорректно.', - 'password.required' => 'Укажите пароль.', - 'password.min' => 'Пароль должен быть не короче 8 символов.', - ]; - } -} -``` - -Новое: - -```php -class LoginRequest extends FormRequest -{ - use \App\Http\Requests\Auth\Concerns\HasPasswordRules; - - /** @return array */ - public function rules(): array - { - return [ - 'email' => ['required', 'string', 'email', 'max:255'], - 'password' => $this->passwordRules(), - 'remember' => ['sometimes', 'boolean'], - ]; - } - - /** @return array */ - public function messages(): array - { - return array_merge($this->passwordMessages(), [ - 'email.required' => 'Укажите email.', - 'email.email' => 'Email указан некорректно.', - ]); - } -} -``` - -### Task B4: Подключить trait в RegisterRequest - -**Files:** - -- Modify: `app/app/Http/Requests/Auth/RegisterRequest.php` - -- [ ] **Step B4.1: Аналогично LoginRequest** - -Старое: - -```php -class RegisterRequest extends FormRequest -{ - /** @return array */ - public function rules(): array - { - return [ - 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], - 'password' => ['required', 'string', 'min:8', 'max:255'], - 'accept_offer' => ['required', 'accepted'], - 'accept_pdn' => ['required', 'accepted'], - ]; - } - - /** @return array */ - public function messages(): array - { - return [ - 'email.required' => 'Укажите email.', - 'email.email' => 'Email указан некорректно.', - 'email.unique' => 'Аккаунт с таким email уже существует.', - 'password.min' => 'Пароль должен быть не короче 8 символов.', - 'accept_offer.accepted' => 'Необходимо принять оферту.', - 'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.', - ]; - } -``` - -Новое: - -```php -class RegisterRequest extends FormRequest -{ - use \App\Http\Requests\Auth\Concerns\HasPasswordRules; - - /** @return array */ - public function rules(): array - { - return [ - 'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')], - 'password' => $this->passwordRules(), - 'accept_offer' => ['required', 'accepted'], - 'accept_pdn' => ['required', 'accepted'], - ]; - } - - /** @return array */ - public function messages(): array - { - return array_merge($this->passwordMessages(), [ - 'email.required' => 'Укажите email.', - 'email.email' => 'Email указан некорректно.', - 'email.unique' => 'Аккаунт с таким email уже существует.', - 'accept_offer.accepted' => 'Необходимо принять оферту.', - 'accept_pdn.accepted' => 'Необходимо согласие на обработку персональных данных.', - ]); - } -``` - -### Task B5: Фикс test password ≥8 - -**Files:** - -- Modify: `app/tests/Feature/AdminIncidentsIndexTest.php` - -- [ ] **Step B5.1: Найти и заменить bcrypt('test')** - -```bash -grep -n "bcrypt('test')" app/tests/Feature/AdminIncidentsIndexTest.php -``` - -Через Edit заменить `bcrypt('test')` → `bcrypt('test1234')`. - -### Task B6: Verification Phase B - -- [ ] **Step B6.1: Pest полный прогон** - -```bash -cd app && composer test -``` - -Expected: `Tests: 416 passed (1388 assertions)` или больше. **Если меньше тестов проходит — STOP**, разобраться (вероятная причина: `tenant`-middleware блокирует тесты на /api/notifications/reminders/reports без `SET LOCAL app.current_tenant_id`). - -**Расширенная проверка:** если тесты падают из-за middleware — добавить в test setup (например, в `tests/TestCase.php`) фикстуру `DB::statement("SET LOCAL app.current_tenant_id = " . $this->tenant->id);` для аутентифицированных тестов. - -- [ ] **Step B6.2: Larastan** - -```bash -cd app && composer stan -``` - -Expected: `0 errors`. - -- [ ] **Step B6.3: Pint format check** - -```bash -cd app && composer pint:test -``` - -Expected: PASSED. Если что-то не отформатировано — `composer pint` для авто-fix. - -### Task B7: Commit Phase B - -- [ ] **Step B7.1: Stage и commit** - -```bash -git add app/routes/web.php \ - app/app/Http/Requests/Auth/Concerns/HasPasswordRules.php \ - app/app/Http/Requests/Auth/LoginRequest.php \ - app/app/Http/Requests/Auth/RegisterRequest.php \ - app/tests/Feature/AdminIncidentsIndexTest.php -git commit -m "$(cat <<'EOF' -fix(backend): tenant middleware на auth-routes + HasPasswordRules trait + test password (audit P0-01 + O-refactor-03 + P2-01) - -Закрытие аудита 2026-05-09 (b6ae8dd): -- P0-01: применён 'tenant' middleware (alias уже в bootstrap/app.php:17) к 3 auth:sanctum-группам: - /api/notifications, /api/reminders, /api/reports/jobs (web.php:44/52/63). - /api/deals и /api/admin/* остаются без auth (P1-10/Б-1) — в реестр Спринта 1 Phase F. -- O-refactor-03: HasPasswordRules trait извлекает rules + messages, подключён в Login/Register. -- P2-01: bcrypt('test') → bcrypt('test1234') в AdminIncidentsIndexTest. - -Pest: 416/416 PASS. -Larastan: 0 errors. -Pint: clean. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Phase C. Configs - -### Task C1: format:sql:check Windows fix - -**Files:** - -- Modify: `package.json:13` -- Modify: `.gitignore` - -- [ ] **Step C1.1: Заменить Unix `/tmp/` путь** - -В `package.json` строка `"format:sql:check"`: - -Старое: - -```json -"format:sql:check": "perl -I bin/pgFormatter/lib bin/pgFormatter/pg_format db/schema.sql > /tmp/schema-formatted.sql && diff -q db/schema.sql /tmp/schema-formatted.sql || echo \"pgFormatter would reformat — run npm run format:sql to apply\"" -``` - -Новое: - -```json -"format:sql:check": "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 || echo \"pgFormatter would reformat — run npm run format:sql to apply\"" -``` - -- [ ] **Step C1.2: Добавить tmp-файл в .gitignore** - -В `.gitignore` добавить (если ещё нет): - -```text -db/.schema-formatted.tmp.sql -``` - -- [ ] **Step C1.3: Verify работа на этой Windows-машине** - -```bash -npm run format:sql:check -``` - -Expected: больше нет `The system cannot find the path specified`. Вместо этого либо успех, либо «pgFormatter would reformat» с реальной diff'ой. - -### Task C2: .lychee.toml exclude web/v8 - -**Files:** - -- Modify: `.lychee.toml` (раздел `exclude`) - -- [ ] **Step C2.1: Прочитать текущий exclude** - -```bash -grep -A 30 "^exclude = \[" .lychee.toml | head -35 -``` - -- [ ] **Step C2.2: Добавить regex для root-relative ссылок web/v8** - -В блок `exclude` добавить (перед закрывающей `]`): - -```toml - # web/v8/*.html — статические концепты, root-relative ссылки на будущие маршруты Vue - "^/(login|register|legal|dashboard|deals|admin|reports|reminders|billing|impersonation|notifications)(/|$|\\?)", -``` - -- [ ] **Step C2.3: Verify уменьшения числа errors** - -```bash -npm run links 2>&1 | grep "Errors" -``` - -Expected: число errors **меньше 19** (близко к 0; могут остаться внешние URL, не связанные с web/v8). - -### Task C3: pa11y.config.json правильные пути - -**Files:** - -- Modify: `pa11y.config.json` - -- [ ] **Step C3.1: Прочитать текущие urls** - -```bash -cat pa11y.config.json -``` - -- [ ] **Step C3.2: Проверить какие концепты реально существуют** - -```bash -ls liderra_v8_handoff/concepts/v8_*.html 2>/dev/null | head -5 -``` - -- [ ] **Step C3.3: Обновить urls** - -В `pa11y.config.json` заменить пути типа `web/01-login.html` на актуальные `liderra_v8_handoff/concepts/v8_login.html` (имя файла подкорректировать под фактическую structure из шага C3.2). Если есть ровно 13 концептов — всех 13 включить. Если в config'е сейчас только 3 url'а (sample) — оставить 3 sample, но из реальных путей. - -### Task C4: composer.json audit-offline скрипт - -**Files:** - -- Modify: `app/composer.json` (раздел `scripts`) - -- [ ] **Step C4.1: Добавить скрипт** - -В `app/composer.json` `scripts`-объект (после существующего `stan` или рядом): - -```json -"audit-offline": "@composer audit --locked", -``` - -Note: флага `--no-network` нет в современных Composer 2.x; `--locked` использует только локальный composer.lock без обращения к packagist API. - -- [ ] **Step C4.2: Verify** - -```bash -cd app && composer audit-offline -``` - -Expected: вывод без curl-таймаутов; либо «No security vulnerability advisories found», либо реальный список уязвимостей. - -### Task C5: ESLint no-restricted-imports vuetify rule - -**Files:** - -- Modify: `app/eslint.config.js` - -- [ ] **Step C5.1: Прочитать текущий конфиг** - -```bash -cat app/eslint.config.js -``` - -- [ ] **Step C5.2: Расширить блок `rules`** - -Найти в `eslint.config.js` секцию: - -```js -{ - rules: { - 'vue/multi-word-component-names': 'off', // допускаем AppShell.vue - }, -}, -``` - -Заменить на: - -```js -{ - rules: { - 'vue/multi-word-component-names': 'off', // допускаем AppShell.vue - 'no-restricted-imports': ['error', { - paths: [{ - name: 'vuetify/components', - message: 'Используй auto-import через vite-plugin-vuetify (vite.config.ts). Явные импорты дублируют auto-injected.', - }], - }], - }, -}, -``` - -- [ ] **Step C5.3: Verify rule срабатывает** - -```bash -cd app && npm run lint:vue -``` - -Expected: 0 errors (никаких явных импортов из vuetify/components в актуальном коде нет — D5-агент в аудите подтвердил). - -### Task C6: GitHub Actions weekly dependency-check workflow - -**Files:** - -- Create: `.github/workflows/dependency-check.yml` - -- [ ] **Step C6.1: Создать каталог если нет** - -```bash -mkdir -p .github/workflows -ls .github/workflows/ -``` - -- [ ] **Step C6.2: Записать workflow** - -`.github/workflows/dependency-check.yml`: - -```yaml -name: Dependency Check - -on: - schedule: - - cron: '0 9 * * 1' # каждый понедельник 09:00 UTC - workflow_dispatch: - -permissions: - contents: read - issues: write - -jobs: - npm-outdated: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - - - name: Install root deps - run: npm install --ignore-scripts - - - name: Check outdated (root) - id: root_outdated - run: | - npm outdated --json > root-outdated.json || true - if [ "$(cat root-outdated.json)" != "{}" ]; then - echo "has_updates=true" >> "$GITHUB_OUTPUT" - fi - - - name: Install app deps - working-directory: app - run: npm install --ignore-scripts - - - name: Check outdated (app) - id: app_outdated - working-directory: app - run: | - npm outdated --json > app-outdated.json || true - if [ "$(cat app-outdated.json)" != "{}" ]; then - echo "has_updates=true" >> "$GITHUB_OUTPUT" - fi - - - name: Open issue if outdated - if: steps.root_outdated.outputs.has_updates == 'true' || steps.app_outdated.outputs.has_updates == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - { - echo "## Weekly outdated dependencies — $(date -u +"%Y-%m-%d")" - echo - echo "### Root (package.json)" - echo '```json' - cat root-outdated.json - echo '```' - echo - echo "### App (app/package.json)" - echo '```json' - cat app/app-outdated.json - echo '```' - } > issue-body.md - gh issue create --title "Weekly outdated check $(date -u +"%Y-%m-%d")" --body-file issue-body.md --label dependencies -``` - -### Task C7: Verification Phase C - -- [ ] **Step C7.1: Полный docs check** - -```bash -npm run check:docs -``` - -Expected: markdownlint 0 errors, cspell 0 issues, lychee 0 errors (web/v8 теперь исключены), pa11y — может быть 0/N если сервер dev не запущен; не блокирует. - -- [ ] **Step C7.2: ESLint smoke** - -```bash -cd app && npm run lint:vue -``` - -Expected: 0 errors. - -- [ ] **Step C7.3: format:sql:check на Windows** - -```bash -npm run format:sql:check -``` - -Expected: либо «pgFormatter would reformat» с реальным diff'ом, либо успех. **НЕ должно быть** «The system cannot find the path specified». - -### Task C8: Commit Phase C - -- [ ] **Step C8.1: Stage и commit** - -```bash -git add package.json .gitignore .lychee.toml pa11y.config.json \ - app/composer.json app/eslint.config.js \ - .github/workflows/dependency-check.yml -git commit -m "$(cat <<'EOF' -fix(configs): Windows-fix format:sql:check + lychee/pa11y/composer/ESLint hygiene + npm-outdated CI (audit P0-03 + 5 P1/O) - -Закрытие аудита 2026-05-09 (b6ae8dd): -- P0-03: format:sql:check заменён /tmp/ на db/.schema-formatted.tmp.sql (Windows-совместимо). -- P1-02: .lychee.toml exclude root-relative для web/v8/*.html (19 false-positive errors → 0). -- P1-12: pa11y.config.json пути обновлены на liderra_v8_handoff/concepts/v8_*.html. -- P1-07: composer audit-offline скрипт (composer audit --locked) для офлайн-режима. -- O-refactor-05: ESLint no-restricted-imports запрещает явный import из 'vuetify/components'. -- O-stack-08: .github/workflows/dependency-check.yml — еженедельный (Mon 09:00 UTC) npm outdated с авто-issue. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Phase D. Docs (narrative) - -**ВАЖНО:** все правки `CLAUDE.md`, `Pravila`, `Tooling` — обязательно через `claude-md-management:claude-md-improver` skill (CLAUDE.md §5 п.10). README.md и cspell-words.txt — обычные правки. - -### Task D1: README.md versions sync - -**Files:** - -- Modify: `README.md` (раздел «Источник истины» / «Документы», ~строки 83-90) - -- [ ] **Step D1.1: Прочитать существующий блок** - -```bash -sed -n '75,100p' README.md -``` - -- [ ] **Step D1.2: Обновить через Edit tool** - -Найти упоминания и заменить: - -- `Tooling v1.0` → `Tooling Прил. Н v1.10` -- `Pravila v1.2` → `Pravila v1.6` -- `schema v8.5` → `schema v8.11` -- `54 таблиц / 91 индекс / 34 RLS` → `56 таблиц / 12 партиций / 97 индексов / 38 RLS / 5 функций / 13 триггеров` - -Точные имена / форматировка зависят от текущего состояния README.md (которое прочитано на шаге D1.1). - -- [ ] **Step D1.3: Verify** - -```bash -grep -E "v1\.10|v1\.6|v8\.11|56 таблиц|97 индекс|38 RLS" README.md | head -5 -``` - -Expected: ≥3 строки matched. - -### Task D2: Invoke claude-md-management:claude-md-improver для CLAUDE.md/Pravila/Tooling - -**Files (правки):** - -- Modify: `CLAUDE.md` -- Modify: `docs/Pravila_raboty_Claude_v1_1.md` -- Modify: `docs/Tooling_v8_3.md` - -- [ ] **Step D2.1: Активировать skill `claude-md-management:claude-md-improver`** - -Вызов skill через Skill tool. На входе skill'у — следующие задачи (передать в args одним блоком): - -```text -Targeted updates по итогам аудита 2026-05-09 (commit b6ae8dd, см. docs/audit_2026-05-09.md): - -1. CLAUDE.md (P1-03): обновить метрику Histoire в §0 строке про «Открытые вопросы» и в §3.3 #24: - - было: «21 story / 28 variants» - - стало: «21 story / 43 variants» - -2. CLAUDE.md (P2-03): в шапке версии 1.81, рядом с упоминанием «6 трений второго порядка F–K», добавить ссылку на детали: - - было: «6 трений второго порядка F–K» - - стало: «6 трений второго порядка F–K (детали в [Plugin_stack_rules_v1.md История версий](docs/Plugin_stack_rules_v1.md#история-версий))» - -3. CLAUDE.md (post P0-02): обновить §2 строку про метрики PG schema: - - было: «56 таблиц + 12 партиций, 95 индексов, 37 RLS-политик, 4 роли БД, 13 триггеров, 5 функций — schema v8.10» - - стало: «56 таблиц + 12 партиций, 97 индексов, 38 RLS-политик, 4 роли БД, 13 триггеров, 5 функций — schema v8.11» - Также обновить §0 ссылку на db/schema.sql: v8.10 → v8.11. - -4. Pravila §13.9 (P1-06): добавить версию (v1.3) в ссылку на Plugin_stack_rules_v1: - - было: «Нарушение Правила 10 [Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) (введено в PSR v1.2)» - - стало: «Нарушение Правила 10 [Plugin_stack_rules_v1.md](Plugin_stack_rules_v1.md) (v1.3)» - Точная локация — найти grep'ом по «Plugin_stack_rules_v1.md» в Pravila_raboty_Claude_v1_1.md. - -5. Tooling §4.2 п.22 (P1-08): дополнить пакет stylelint-config-standard версией: - - было (Прил. Н §4.2 п.22): «Stylelint v17.x» (приблизительно) - - стало: добавить отдельную строку про `stylelint-config-standard ^40.0.0` - Точная локация — Прил. Н, раздел про CSS/Stylelint. - -Все 5 правок — targeted updates, без структурных изменений. После правок — bump CLAUDE.md версии 1.81 → 1.82, Pravila v1.6 → v1.7, Tooling Прил. Н v1.10 → v1.11. История версий в каждом файле — обновить. - -Не делать ничего за пределами этих 5 пунктов. Не добавлять новых section'ов. Не менять формат. -``` - -- [ ] **Step D2.2: Дождаться завершения skill'а и verify правок** - -```bash -git status --short -``` - -Expected: 3 modified files (CLAUDE.md, Pravila, Tooling). Если skill также правит другие — STOP, разобраться. - -- [ ] **Step D2.3: Spot-check отдельные правки** - -```bash -grep -n "21 story / 43 variants" CLAUDE.md -grep -n "v1\.3" docs/Pravila_raboty_Claude_v1_1.md | head -5 -grep -n "stylelint-config-standard" docs/Tooling_v8_3.md -grep -n "v8.11" CLAUDE.md -grep -n "97 индекс" CLAUDE.md -grep -n "38 RLS" CLAUDE.md -``` - -Expected: каждая команда даёт ≥1 результат. - -### Task D3: cspell-words.txt verify - -**Files:** - -- Verify only: `cspell-words.txt` - -- [ ] **Step D3.1: Проверить, что `ребрендинга` уже в словаре** - -```bash -grep "^ребрендинга$" cspell-words.txt -``` - -Expected: 1 строка matched. Если нет — добавить: - -```bash -echo "ребрендинга" >> cspell-words.txt -``` - -(P2-02 был добавлен во время self-review аудита 09.05.2026, должен быть в commit `b6ae8dd`.) - -### Task D4: Verification Phase D - -- [ ] **Step D4.1: Полный docs check** - -```bash -npm run check:docs -``` - -Expected: markdownlint 0, cspell 0, lychee 0 (после Phase C exclude применён), pa11y — informative. - -### Task D5: Commit Phase D - -- [ ] **Step D5.1: Stage и commit** - -```bash -git add README.md CLAUDE.md docs/Pravila_raboty_Claude_v1_1.md docs/Tooling_v8_3.md cspell-words.txt -git commit -m "$(cat <<'EOF' -docs(narrative): sync versions + Histoire 21/43 + cross-refs (audit P1-01/03/06/08 + P2-02/03) - -Закрытие аудита 2026-05-09 (b6ae8dd) для narrative-блока: -- P1-01: README.md обновлён (Tooling v1.10, Pravila v1.6, schema v8.11 / 56 / 97 / 38). -- P1-03: CLAUDE.md Histoire 21/28 → 21/43 (§0 + §3.3). -- P1-06: Pravila §13.9 cross-ref на Plugin_stack_rules_v1 теперь с (v1.3). -- P1-08: Tooling Прил. Н дополнен stylelint-config-standard ^40.0.0. -- P2-02: cspell-words.txt уже содержит «ребрендинга» (verified, добавлено в self-review аудита). -- P2-03: CLAUDE.md F-K линка на Plugin_stack_rules_v1#история-версий. -- post-A: метрики schema в CLAUDE.md обновлены до v8.11 (97 индексов, 38 RLS). - -Все правки CLAUDE.md/Pravila/Tooling — через claude-md-management:claude-md-improver -(CLAUDE.md §5 п.10). README.md — обычная правка. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Phase E. Docs (handoff) - -**Pre-check:** проверить, что `liderra_v8_handoff/` входит в наш git (а не submodule/external). - -```bash -git ls-files liderra_v8_handoff/docs/BRANDBOOK_v2.md -``` - -Если результат пустой (файл не tracked в текущем repo) — Phase E **пропустить целиком**, отметить в финальном отчёте «требует push в handoff-репо отдельно». Если файл tracked — продолжать. - -### Task E1: BRANDBOOK_v2 — таблица 14 status-slug mapping (P1-05) - -**Files:** - -- Modify: `liderra_v8_handoff/docs/BRANDBOOK_v2.md` (после §3.6 — 14 OKLCH-статусов) - -- [ ] **Step E1.1: Извлечь slug'и из schema.sql** - -```bash -sed -n '2170,2200p' db/schema.sql -``` - -Expected: видим `INSERT INTO lead_statuses ... ('new', ...), ('viewed', ...), ...` с 14 строками. - -- [ ] **Step E1.2: Извлечь имена/цвета из BRANDBOOK_v2** - -```bash -sed -n '160,200p' liderra_v8_handoff/docs/BRANDBOOK_v2.md -``` - -- [ ] **Step E1.3: Сопоставить slug → русское имя → OKLCH (по hue-порядку или по логическому)** - -Стандартное mapping (вероятное): - -| slug | русское имя (BRANDBOOK) | OKLCH-cell | -|---|---|---| -| `new` | Новая | (из BRANDBOOK) | -| `viewed` | Просмотрена | (из BRANDBOOK) | -| `worked` | В работе | (из BRANDBOOK) | -| `base` | База | (из BRANDBOOK) | -| `missed` | Не дозвонились | (из BRANDBOOK) | -| `negotiations` | Переговоры | (из BRANDBOOK) | -| `waiting_payment` | Ждёт оплату | (из BRANDBOOK) | -| `partnership` | Партнёрство | (из BRANDBOOK) | -| `paid` | Оплачено | (из BRANDBOOK) | -| `closed` | Закрыта | (из BRANDBOOK) | -| `test_drive` | Тест-драйв | (из BRANDBOOK) | -| `hot` | Горячая | (из BRANDBOOK) | -| `replacement` | Замена | (из BRANDBOOK) | -| `final_missed` | Финальный недозвон | (из BRANDBOOK) | - -- [ ] **Step E1.4: Записать таблицу в BRANDBOOK_v2.md** - -После §3.6 (14 OKLCH-статусов) добавить раздел: - -```markdown -### 3.6.1. Mapping slug → имя → OKLCH (v2.1, audit P1-05) - -Связывает 14 schema-slug'ов из `db/schema.sql:2170-2200` (INSERT lead_statuses) с 14 OKLCH-цветами из §3.6. Источник истины по slug'ам — schema.sql; по именам и цветам — этот brandbook. При расхождении приоритет — schema (см. CLAUDE.md §1). - -| slug (schema) | Имя статуса (RU) | OKLCH-cell | -|---|---|---| -| `new` | Новая | (cell #1 §3.6) | -| `viewed` | Просмотрена | (cell #2) | -| ... | ... | ... | -| `final_missed` | Финальный недозвон | (cell #14) | - -Если в новой версии schema добавляется/удаляется slug — обновить эту таблицу одновременно (CLAUDE.md §5 п.8: правки db/schema.sql ⇒ запись в db/CHANGELOG_schema.md ⇒ синхр. handoff). -``` - -Конкретные cell-номера — заполнить после Step E1.3 (по фактическому порядку в BRANDBOOK_v2 §3.6). - -### Task E2: DEVELOPER_HANDOFF — axe-claim documentation + font-display - -**Files:** - -- Modify: `liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md` (строки 250 и 430+518) - -- [ ] **Step E2.1: P1-04 — axe-claim documentation** - -В `DEVELOPER_HANDOFF.md` найти строки с заявлением «0 violations» (~430, 518). Через Edit tool заменить (приблизительно): - -Старое: - -```text -Все прошли axe-core 4.10.2 = 0 violations на 1680/1440/768/375 (где применимо). -``` - -Новое: - -```text -Все прошли axe-core 4.10.2 = 0 violations на 1680/1440/768/375 (где применимо). Дата прогона: <ДАТА из архива handoff'а или "требует подтверждения после фикса pa11y.config (см. audit P1-12, повторный прогон через `npm run a11y` на путях liderra_v8_handoff/concepts/v8_*.html)">. Отчёт axe в архиве — <путь к отчёту>, или "не сохранён, перепрогнать после P1-12 fix". -``` - -Если конкретный отчёт не найден в архиве — оставить пометку «требует подтверждения». Не удалять заявление (handoff = историческая фиксация). - -- [ ] **Step E2.2: O-stack-09 — font-display strategy** - -В `DEVELOPER_HANDOFF.md` найти блок §4 Типографика (строки ~250-260). После существующего описания шрифтов (Inter/JetBrains Mono) добавить раздел: - -```markdown -### 4.X. Стратегия загрузки шрифтов (O-stack-09) - -Используется `` с Google Fonts API v1 + параметр `&display=swap`. - -- **`&display=swap`:** браузер сразу рендерит fallback-шрифт (system-ui), переключается на загруженный Inter/JetBrains Mono когда тот доступен. Стратегия FOUT (Flash of Unstyled Text), но без невидимого текста — лучше для UX, чем `&display=block`. -- **WOFF2 формат:** Google Fonts отдаёт WOFF2 по умолчанию для современных браузеров (Chrome 36+, Firefox 39+, Safari 12+). Сжатие лучше WOFF на 20-30%. -- **Preconnect:** для ускорения первого byte рекомендуется добавить: - - ```html - - - ``` - - Это сокращает TTFB на медленных сетях (~50-200 мс). - -- **Совместимость:** для целевой аудитории Chrome 100+, Safari 15+, Firefox 95+ дополнительный fallback не нужен. Для расширенной аудитории — добавить @font-face с WOFF (legacy). - -``` - -### Task E3: Verification Phase E - -- [ ] **Step E3.1: Docs check** - -```bash -npm run check:docs -``` - -Expected: markdownlint/cspell/lychee 0 errors на handoff'е (handoff включён в `npm run lint:md` через glob). - -### Task E4: Commit Phase E - -- [ ] **Step E4.1: Stage и commit** - -```bash -git add liderra_v8_handoff/docs/BRANDBOOK_v2.md liderra_v8_handoff/docs/DEVELOPER_HANDOFF.md -git commit -m "$(cat <<'EOF' -docs(handoff): status-slug mapping table + axe-claim documentation + font-display strategy (audit P1-04/05 + O-stack-09) - -Закрытие аудита 2026-05-09 (b6ae8dd) для handoff-блока: -- P1-05: BRANDBOOK_v2 §3.6.1 — таблица mapping 14 schema-slug ↔ имя статуса ↔ OKLCH-cell. -- P1-04: DEVELOPER_HANDOFF axe-claim связан с воспроизводимым evidence или пометкой - "требует подтверждения после pa11y.config fix (P1-12)". -- O-stack-09: DEVELOPER_HANDOFF §4 — Font loading strategy (&display=swap + WOFF2 + preconnect). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Phase F. Registry - -### Task F1: Открытые_вопросы — добавить P1-10, P1-11 link, P1-09 - -**Files:** - -- Modify: `docs/Открытые_вопросы_v8_3.md` - -- [ ] **Step F1.1: Прочитать структуру файла** - -```bash -head -50 docs/Открытые_вопросы_v8_3.md -grep -nE "^### (Биз|CTO|Ю|Диз|DO|OPEN)-" docs/Открытые_вопросы_v8_3.md | head -20 -``` - -- [ ] **Step F1.2: Найти максимальный CTO-номер для нового пункта** - -```bash -grep -oE "CTO-[0-9]+" docs/Открытые_вопросы_v8_3.md | sort -t- -k2 -n | tail -3 -``` - -Expected: например `CTO-17`, `CTO-18`. Новый = следующее число. - -- [ ] **Step F1.3: Найти максимальный OPEN-номер** - -```bash -grep -oE "OPEN-[0-9]+" docs/Открытые_вопросы_v8_3.md | sort -t- -k2 -n | tail -3 -``` - -- [ ] **Step F1.4: Добавить новые записи** - -Найти раздел открытых CTO-вопросов и добавить: - -```markdown -### CTO-XX (новый, открыт 09.05.2026 по аудиту P1-10) — auth+tenant middleware на /api/deals на pre-prod миграции - -**Контекст:** на MVP `/api/deals` использует `tenant_id` query-param для tenant-resolution (см. `app/routes/web.php:103-110`). На prod-миграции это **обязательно** перейти на `auth:sanctum + tenant` middleware с резолюцией через subdomain или header `X-Tenant-Id`. - -**Trigger закрытия:** prod-миграция (после Б-1, регистрации ООО). До этого момента MVP-flow с query-param остаётся. - -**Связанные находки:** [docs/audit_2026-05-09.md](audit_2026-05-09.md) P1-10. - -**Состояние:** открыто. -``` - -В разделе **Б-1** (admin SSO) добавить (или дополнить существующий блок): - -```markdown -> **Связанная находка аудита (P1-11):** `/api/admin/*` маршруты в `app/routes/web.php:74-99` без auth — тот же блокер. Закрывается одновременно с Б-1 (SSO Yandex 360). -``` - -Найти раздел OPEN-вопросов и добавить: - -```markdown -### OPEN-XX (новый, открыт 09.05.2026 по аудиту P1-09) — Histoire ↔ Vite 8 миграционный долг - -**Контекст:** Histoire 1.0-beta.1 заявляет peerDep `vite ^7`, у нас Vite 8. Установлен через `npm install --legacy-peer-deps` в `app/`. Smoke-test (`histoire build`) пройден; Vuetify auto-import регистрируется через `setupFile`. - -**Trigger закрытия:** релиз Histoire с peerDep `vite ^8` (отслеживать [github.com/histoire-dev/histoire/releases](https://github.com/histoire-dev/histoire/releases)). - -**Связанные находки:** [docs/audit_2026-05-09.md](audit_2026-05-09.md) P1-09. - -**Состояние:** открыто, не блокирующее. -``` - -Заменить `XX` на актуальные номера из Step F1.2/F1.3. - -### Task F2: Verification Phase F - -- [ ] **Step F2.1: lychee + markdownlint** - -```bash -npm run lint:md docs/Открытые_вопросы_v8_3.md -npm run links 2>&1 | grep -E "Errors|OK" -``` - -Expected: 0 errors. - -- [ ] **Step F2.2: cspell** - -```bash -npx cspell docs/Открытые_вопросы_v8_3.md -``` - -Expected: 0 issues. - -### Task F3: Commit Phase F - -- [ ] **Step F3.1: Stage и commit** - -```bash -git add docs/Открытые_вопросы_v8_3.md -git commit -m "$(cat <<'EOF' -docs(registry): новые открытые вопросы по аудиту P1-10/11/09 - -Закрытие аудита 2026-05-09 (b6ae8dd) для registry-блока: -- P1-10: новый CTO-вопрос — auth+tenant middleware на /api/deals на pre-prod. - Trigger: prod-миграция (после Б-1). -- P1-11: cross-link к Б-1 — /api/admin/* без auth = часть SSO-блока. -- P1-09: новый OPEN-вопрос — Histoire 1.0-beta.1 ↔ Vite 8 совместимость. - Trigger: релиз Histoire с peerDep vite ^8. - -Все 3 пункта аудита P1, не правки в коде. После закрытия Sprint 1 -эти пункты остаются открытыми в реестре. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## DoD (соответствие spec §5) - -После выполнения всех 6 фаз: - -- [ ] Все 22 правки + 3 записи реестра применены, в 6 коммитах с осмысленными scope-сообщениями. -- [ ] `cd app && composer test` — Pest 416/416 PASS. -- [ ] `cd app && composer stan` — 0 errors above baseline. -- [ ] `cd app && npm run type-check` — 0 type errors. -- [ ] `cd app && npm run test:vue` — Vitest 393/393 PASS. -- [ ] `npm run check:docs` — 0 errors. -- [ ] Pre-commit hooks PASS на каждом из 6 коммитов. -- [ ] schema.sql v8.11 / 38 RLS / 97 индексов — подтверждено grep'ом. -- [ ] `git log --oneline -6` — видно 6 коммитов Sprint 1 после `b6ae8dd`. -- [ ] `docs/Открытые_вопросы_v8_3.md` содержит 3 новые/обновлённые записи (CTO-XX + Б-1 link + OPEN-XX). -- [ ] CLAUDE.md обновлён до v1.82, Pravila — до v1.7, Tooling — до v1.11. - -**Не входит в DoD:** - -- Реализация O-* за пределами 6 заявленных. -- Прогон pa11y на handoff концептах в браузере (manual после Phase C). -- Спринт 2 (modernization) и Спринт 3 (big refactors). - ---- - -## Бюджет - -| Фаза | Tasks | Wall-clock | -|---|---|---| -| A. DB | A1-A7 | 15-20 мин | -| B. Backend | B1-B7 | 30-40 мин | -| C. Configs | C1-C8 | 30-40 мин | -| D. Docs (narrative) | D1-D5 | 30-45 мин (через claude-md-management) | -| E. Docs (handoff) | E1-E4 | 30-45 мин | -| F. Registry | F1-F3 | 10-15 мин | -| **Итого** | **6 phases / 41 tasks** | **2.5-3.5 часа** | - ---- - -## Risks - -- **B6 (Pest fail):** регистрация tenant middleware на auth-routes может сломать тесты, не имеющие `SET LOCAL app.current_tenant_id`. Митигация — в Step B6.1 явная инструкция «STOP» при padении тестов; добавить fixture в test setup. Не пропускать падение. -- **C2 (lychee regex):** если regex для exclude захватит слишком много, потеряем покрытие реальных битых ссылок. Митигация — после Step C2.3 проверить число errors: должно быть близко к 0, но не строго 0 (могут остаться внешние URL). -- **D2 (claude-md-improver):** skill может сделать больше, чем заявлено в args (расширить scope). Митигация — Step D2.2 проверяет git status перед коммитом; если ≠ 3 файлов — STOP, разобраться. -- **E (handoff repo):** если `liderra_v8_handoff/` — не tracked в текущем git, Phase E пропускается с пометкой. Не блокирует Sprint 1. -- **F1 (CTO-/OPEN-номера):** Step F1.2/F1.3 динамически выбирают следующий номер. Если параллельно открывается другой вопрос — конфликт. Митигация — выполнять последовательно, без параллельных правок Открытые_вопросы. -- **D2/F1 кросс-влияние:** D2 правит CLAUDE.md/Pravila/Tooling, F1 правит Открытые_вопросы. Эти правки независимы, но обе требуют чистого dir state. Если есть pending изменения в registry — закоммитить отдельно. diff --git a/docs/superpowers/plans/2026-05-10-sprint4-audit-tail-plan.md b/docs/superpowers/plans/2026-05-10-sprint4-audit-tail-plan.md deleted file mode 100644 index 82925b8..0000000 --- a/docs/superpowers/plans/2026-05-10-sprint4-audit-tail-plan.md +++ /dev/null @@ -1,768 +0,0 @@ -# Sprint 4 «Audit tail» 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:** Закрыть 3 оставшихся O-* пункта audit'а (после Sprint 1–3): keyset pagination в `DealController::index` (O-perf-04), split 8 Vue-компонентов >300 строк (O-refactor-04 хвост), dead-code detection через bundle analyzer + удаление unused exports (O-refactor-06). - -**Architecture:** 3 фазы / 3 PR-коммита (Phase A backend keyset, Phase B frontend split, Phase C frontend cleanup) + 1 финальный регрессионный коммит. Каждая фаза независима — порядок гибкий, но рекомендация A → B → C ради монотонной валидации. - -**Tech Stack:** Laravel 13 + Pest 4 + Vue 3.5 + Vuetify 3.12 + Vite 8 + rollup-plugin-visualizer - -**Spec:** [docs/superpowers/specs/2026-05-10-roadmap-to-production-design.md](../specs/2026-05-10-roadmap-to-production-design.md) §3.1 (Sprint 4 содержание) - -**Базовый HEAD:** `77b018d` (после commit'а roadmap design'а) - -**Pre-requisites:** - -- Проект собран (`cd app && composer install && npm install`). -- PG/Redis запущены (Memurai/PostgreSQL service running на dev-стенде). -- `php artisan migrate:fresh --seed` если нужна свежая БД. - ---- - -## Phase A — O-perf-04: keyset pagination в DealController::index - -**Контекст:** Текущий `DealController::index` использует OFFSET/LIMIT. На больших offset'ах (deep pagination) это O(N) — PG считает все записи до offset'а, затем выкидывает их. Keyset pagination использует cursor `(received_at, id)` — клиент передаёт «последние видимые значения», PG идёт по индексу с WHERE `(received_at, id) < (cursor_received_at, cursor_id)`. O(1) при любой глубине. - -**Решение API:** добавить опциональный query-параметр `cursor` (base64-encoded JSON `{r: timestamp, i: id}`). При передаче cursor — игнорируем offset, используем keyset. Без cursor — текущее поведение OFFSET (для совместимости с frontend). - -**Files:** - -- Modify: `app/app/Http/Controllers/Api/DealController.php:59-143` (метод `index`) -- Test: `app/tests/Feature/DealIndexTest.php` (добавить 3 теста) -- Modify: `app/resources/js/composables/useDealsList.ts` (если есть; иначе — пометить как Post-Sprint-4 follow-up для frontend integration) - -### Task A.1 — Pest tests для keyset (TDD) - -- [ ] **Step A.1.1: Открыть `app/tests/Feature/DealIndexTest.php` и добавить 3 failing-теста в конец файла (перед закрывающей `});` если она есть, или просто в конец).** - -```php -test('GET /api/deals с cursor возвращает следующую страницу через keyset', function () { - // Создаём 5 сделок с разными received_at (через 1 минуту). - $base = now()->subHours(5); - $ids = []; - for ($i = 0; $i < 5; $i++) { - $ids[] = Deal::factory() - ->for($this->tenant) - ->for($this->project) - ->create([ - 'status' => 'new', - 'received_at' => $base->copy()->addMinutes($i), - ])->id; - } - - // Первая страница без cursor: limit=2 → последние 2 (по received_at DESC). - $r1 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2'); - $r1->assertStatus(200); - expect($r1->json('deals'))->toHaveLength(2); - expect($r1->json('deals.0.id'))->toBe($ids[4]); // последняя по времени - expect($r1->json('deals.1.id'))->toBe($ids[3]); - - // Cursor для следующей страницы — последний id и его received_at. - $cursor = base64_encode(json_encode([ - 'r' => $r1->json('deals.1.received_at'), - 'i' => $r1->json('deals.1.id'), - ])); - - $r2 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&cursor='.$cursor); - $r2->assertStatus(200); - expect($r2->json('deals'))->toHaveLength(2); - expect($r2->json('deals.0.id'))->toBe($ids[2]); - expect($r2->json('deals.1.id'))->toBe($ids[1]); -}); - -test('GET /api/deals c невалидным cursor возвращает 422', function () { - $r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&cursor=not-base64-json'); - $r->assertStatus(422); - expect($r->json('message'))->toBeString(); -}); - -test('GET /api/deals c cursor возвращает next_cursor когда есть ещё страницы', function () { - $base = now()->subHours(3); - for ($i = 0; $i < 3; $i++) { - Deal::factory()->for($this->tenant)->for($this->project)->create([ - 'status' => 'new', - 'received_at' => $base->copy()->addMinutes($i), - ]); - } - - $r = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2'); - $r->assertStatus(200); - expect($r->json('next_cursor'))->toBeString(); // base64-encoded - expect($r->json('next_cursor'))->not->toBeEmpty(); - - // Последняя страница: next_cursor = null. - $cursor = $r->json('next_cursor'); - $r2 = $this->getJson('/api/deals?tenant_id='.$this->tenant->id.'&limit=2&cursor='.$cursor); - $r2->assertStatus(200); - expect($r2->json('next_cursor'))->toBeNull(); -}); -``` - -- [ ] **Step A.1.2: Запустить тесты — они должны упасть.** - -```bash -cd app && php vendor/bin/pest --filter="DealIndex" --stop-on-failure -``` - -Expected: 3 новых теста FAIL (либо assertion mismatch на `next_cursor`, либо 422 не вернётся для невалидного cursor — текущий код игнорирует параметр). - -### Task A.2 — Реализация keyset - -- [ ] **Step A.2.1: Открыть `app/app/Http/Controllers/Api/DealController.php` и заменить метод `index` (строки 59–143).** - -Логика: - -- Если `cursor` пустой/отсутствует → старое поведение OFFSET (возвращаем `limit/offset/total`). -- Если `cursor` присутствует → декодировать `base64_decode → json_decode` → проверить ключи `r` (timestamp) + `i` (int id). При ошибке — 422. -- При валидном cursor → keyset query: `WHERE (received_at, id) < (cursor.r, cursor.i)` через `whereRaw('(received_at, id) < (?, ?)', [...])` (PG row constructor comparison). -- Возвращать `next_cursor` (base64-encoded `{r, i}` от последней записи в выдаче), `null` если страница неполная (выдано < limit). -- `total` возвращать только при OFFSET-режиме (при keyset — невозможно без COUNT(*) который дорого). - -```php -public function index(Request $request): JsonResponse -{ - $tenantId = (int) $request->query('tenant_id', '0'); - if ($tenantId < 1) { - return response()->json(['message' => 'Параметр tenant_id обязателен.'], 422); - } - - $tenant = Tenant::find($tenantId); - if ($tenant === null) { - return response()->json(['message' => 'Тенант не найден.'], 404); - } - - $statuses = (array) $request->query('status_in', []); - $projectId = $request->query('project_id') !== null ? (int) $request->query('project_id') : null; - $managerId = $request->query('manager_id') !== null ? (int) $request->query('manager_id') : null; - $search = trim((string) $request->query('search', '')); - $limit = max(1, min(500, (int) $request->query('limit', '100'))); - $offset = max(0, (int) $request->query('offset', '0')); - $onlyDeleted = $request->boolean('only_deleted'); - $cursorRaw = (string) $request->query('cursor', ''); - - $cursor = null; - if ($cursorRaw !== '') { - $decoded = base64_decode($cursorRaw, true); - if ($decoded === false) { - return response()->json(['message' => 'Невалидный cursor (не base64).'], 422); - } - $parsed = json_decode($decoded, true); - if (! is_array($parsed) || ! isset($parsed['r'], $parsed['i'])) { - return response()->json(['message' => 'Невалидный cursor (нет полей r/i).'], 422); - } - $cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']]; - } - - [$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor) { - DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); - - $query = Deal::query() - ->where('tenant_id', $tenantId) - ->with(['project:id,name', 'manager:id,email,first_name,last_name']); - - if ($onlyDeleted) { - $query->withTrashed()->whereNotNull('deleted_at'); - } - - if ($statuses !== []) { - $query->whereIn('status', array_filter($statuses, 'is_string')); - } - if ($projectId !== null) { - $query->where('project_id', $projectId); - } - if ($managerId !== null) { - $query->where('manager_id', $managerId); - } - if ($search !== '') { - $like = '%'.$search.'%'; - $query->where(function ($q) use ($like) { - $q->where('phone', 'ilike', $like) - ->orWhere('contact_name', 'ilike', $like); - }); - } - - if ($cursor !== null) { - // Keyset pagination — PG row constructor comparison через индекс - // (received_at DESC, id DESC). Не считаем total (дорого без COUNT). - $query->whereRaw('(received_at, id) < (?, ?)', [$cursor['r'], $cursor['i']]); - $rows = $query->orderByDesc('received_at')->orderByDesc('id') - ->limit($limit + 1)->get(); // +1 чтобы понять, есть ли следующая страница - $hasNext = $rows->count() > $limit; - if ($hasNext) { - $rows = $rows->slice(0, $limit)->values(); - } - $next = null; - if ($hasNext && $rows->isNotEmpty()) { - $last = $rows->last(); - $next = base64_encode(json_encode([ - 'r' => $last->received_at?->toIso8601String(), - 'i' => $last->id, - ])); - } - - return [$rows, null, $next]; - } - - // Старый OFFSET-путь (backward-compat для frontend). - $total = (clone $query)->count(); - $rows = $query->orderByDesc('received_at')->orderByDesc('id') - ->limit($limit + 1)->offset($offset)->get(); - $hasNext = $rows->count() > $limit; - if ($hasNext) { - $rows = $rows->slice(0, $limit)->values(); - } - $next = null; - if ($hasNext && $rows->isNotEmpty()) { - $last = $rows->last(); - $next = base64_encode(json_encode([ - 'r' => $last->received_at?->toIso8601String(), - 'i' => $last->id, - ])); - } - - return [$rows, $total, $next]; - }); - - $payload = [ - 'deals' => $deals->map(fn (Deal $d) => [ - 'id' => $d->id, - 'tenant_id' => $d->tenant_id, - 'project_id' => $d->project_id, - 'project_name' => $d->project?->name, - 'phone' => $d->phone, - 'contact_name' => $d->contact_name, - 'status' => $d->status, - 'manager_id' => $d->manager_id, - 'manager_name' => $d->manager - ? ManagerController::formatName($d->manager->first_name, $d->manager->last_name, $d->manager->email) - : null, - 'manager_initials' => $d->manager - ? ManagerController::formatInitials($d->manager->first_name, $d->manager->last_name, $d->manager->email) - : null, - 'received_at' => $d->received_at?->toIso8601String(), - ]), - 'limit' => $limit, - 'next_cursor' => $nextCursor, - ]; - - if ($cursor === null) { - $payload['total'] = $total; - $payload['offset'] = $offset; - } - - return response()->json($payload); -} -``` - -- [ ] **Step A.2.2: Запустить тесты — должны пройти.** - -```bash -cd app && php vendor/bin/pest --filter="DealIndex" -``` - -Expected: ВСЕ 3 новых теста PASS, старые тесты `DealIndexTest` тоже PASS (backward-compat сохранён). - -- [ ] **Step A.2.3: Запустить полный Pest для регрессии.** - -```bash -cd app && composer test -``` - -Expected: 419+ tests PASS (418 baseline + 3 новых; точное число зависит от других возможных skipped). - -- [ ] **Step A.2.4: Larastan + Pint.** - -```bash -cd app && composer pint && composer stan -``` - -Expected: Pint без diff, Larastan 0 errors. - -- [ ] **Step A.2.5: Commit.** - -```bash -git add app/app/Http/Controllers/Api/DealController.php app/tests/Feature/DealIndexTest.php -git commit -m "$(cat <<'EOF' -feat(backend): Sprint 4 Phase A — keyset pagination в DealController::index (audit O-perf-04) - -Добавлен опциональный query-параметр `cursor` (base64-encoded {r:timestamp,i:id}). -При cursor — keyset через PG row constructor `(received_at, id) < (?, ?)`, -O(1) при любой глубине. Без cursor — старое OFFSET-поведение (backward-compat). - -Возвращает `next_cursor` в обоих режимах (NULL = последняя страница). -`total` возвращается только при OFFSET (при keyset COUNT(*) дорог). - -3 новых Pest-теста: keyset pagination, 422 на невалидный cursor, -next_cursor flow. - -Frontend integration в `useDealsList`/`DealsView` — отдельным шагом -(не блокирует backend deploy, OFFSET путь жив). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Phase B — O-refactor-04 хвост: split 8 Vue-компонентов >300 строк - -**Контекст:** Top-3 Vue-компонента (DealsView 852, ReportsView 592, DealDetailDrawer 580) split закрыт в Sprint 3 Phase C. Осталось 8 компонентов >300 строк: - -| Компонент | Текущий size | Целевой | Стратегия split'а | -|---|---|---|---| -| `layouts/AppLayout.vue` | 466 | <200 | Извлечь `` (nav-tree) + `` (search/notifications/user-chip) | -| `views/admin/AdminTenantDetailView.vue` | 436 | <200 | Извлечь `` (base-info), ``, ``, `` | -| `views/BillingView.vue` | 416 | <200 | Извлечь ``, ``, ``, `` | -| `views/admin/AdminTenantsView.vue` | 377 | <200 | Извлечь ``, `` (typed VDataTable slots), `` | -| `views/settings/SecurityTab.vue` | 354 | <200 | Извлечь ``, ``, ``, `` | -| `views/RemindersView.vue` | 345 | <200 | Извлечь ``, ``, `` | -| `views/errors/ErrorView.vue` | 320 | <200 | Извлечь `` (404/403/500 SVG), `` (Login/Home/Back buttons) | -| `views/DashboardView.vue` | 302 | <200 | Извлечь ``, ``, `` | - -**Принцип split'а:** - -- Sub-компоненты живут рядом (например, `AppSidebar.vue` и `AppTopbar.vue` в `layouts/parts/` или в `components/layout/`). -- Props down (родитель передаёт data), events up (sub-компонент эмитит actions). -- TypeScript: явные интерфейсы props. -- Vuetify: использовать typed slots для VDataTable где уместно (Sprint 2 Phase B уже начал — продолжать). -- Тесты: если sub-компонент имеет state/logic → отдельный Vitest test file. Чисто-визуальные slots — без tests. -- Story (Histoire): для каждого нового sub-компонента — `.story.vue` если разумно (variants для разных состояний). - -**Files:** - -- Modify: 8 view-файлов выше -- Create: ~25 новых sub-компонентов (3-4 на каждый split) -- Create: ~10-15 новых Vitest-тестов на sub-компоненты с логикой -- Modify: при необходимости — `composables/` для общих state-helper'ов - -### Task B.1 — Группа 1: 3 admin views (AppLayout + AdminTenantDetailView + AdminTenantsView) - -- [ ] **Step B.1.1: AppLayout.vue split.** - -Создать `app/resources/js/components/layout/AppSidebar.vue` (nav-tree из текущего AppLayout строки ~40–250) с props `:nav-groups`, `:active-route`, events `@navigate`. ImportTL в AppLayout, заменить inline. - -Создать `app/resources/js/components/layout/AppTopbar.vue` (topbar из текущего AppLayout строки ~250–420) с props `:user`, `:notifications-count`, events `@logout`, `@open-search`, `@toggle-notifications`. Импортировать в AppLayout. - -После split: `wc -l app/resources/js/layouts/AppLayout.vue` должен показать <200. - -- [ ] **Step B.1.2: AdminTenantDetailView.vue split.** - -Создать в `app/resources/js/components/admin/tenant-detail/`: - -- `TenantHeader.vue` (base info card: name, subdomain, status, tariff, mrr_rub, runway_days) -- `TenantUsersTable.vue` (VDataTable + 4 columns) -- `TenantBalanceHistory.vue` (VDataTable + transaction badges) -- `TenantActivityList.vue` (VList + actor + summary) - -Каждый принимает `:tenant-detail` (или подмножество) через props, без emits (read-only view). - -После split: `wc -l app/resources/js/views/admin/AdminTenantDetailView.vue` <200. - -- [ ] **Step B.1.3: AdminTenantsView.vue split.** - -Создать в `app/resources/js/components/admin/tenants/`: - -- `TenantsFilters.vue` (search + status select + tariff select), props `:filters`, emit `@update:filters` -- `TenantsTable.vue` (VDataTable с typed slots: tenant column, status chip, MRR, last_active), props `:tenants`, emit `@row-click` -- `TenantStatusChip.vue` (chip с цветом по статусу: active/trial/suspended/churned) - -После split: `wc -l app/resources/js/views/admin/AdminTenantsView.vue` <200. - -- [ ] **Step B.1.4: Vitest для новых компонентов.** - -Минимум по 1 test-file на компонент с логикой (TenantsFilters, TenantStatusChip — props/emits; TenantsTable — slot rendering; TenantHeader — computed display). - -Шаблон: `tests/Vue/components/admin/tenants/TenantsFilters.test.ts` с `mount(TenantsFilters, { props: {...}})`, `wrapper.find('.v-text-field input').setValue(...)`, `expect(wrapper.emitted('update:filters')).toBeTruthy()`. - -- [ ] **Step B.1.5: Регрессия.** - -```bash -cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build -``` - -Expected: ESLint 0, vue-tsc 0, Vitest все PASS (включая новые), build successful. - -- [ ] **Step B.1.6: Commit.** - -```bash -git add app/resources/js/layouts/AppLayout.vue \ - app/resources/js/components/layout/ \ - app/resources/js/views/admin/AdminTenantDetailView.vue \ - app/resources/js/components/admin/tenant-detail/ \ - app/resources/js/views/admin/AdminTenantsView.vue \ - app/resources/js/components/admin/tenants/ \ - app/tests/Vue/components/ -git commit -m "refactor(frontend): Sprint 4 Phase B/1 — split 3 admin/layout views (audit O-refactor-04 хвост) - -AppLayout 466→<200 (+ AppSidebar + AppTopbar) -AdminTenantDetailView 436→<200 (+ TenantHeader/UsersTable/BalanceHistory/ActivityList) -AdminTenantsView 377→<200 (+ TenantsFilters/Table/StatusChip) - -+N Vitest на новые sub-компоненты с логикой. - -Co-Authored-By: Claude Opus 4.7 (1M context) " -``` - -### Task B.2 — Группа 2: 3 user views (BillingView + SecurityTab + RemindersView) - -- [ ] **Step B.2.1: BillingView.vue split.** - -Создать в `app/resources/js/components/billing/`: - -- `BalanceCard.vue` (текущий баланс + auto-topup status), props `:balance`, `:auto-topup-config` -- `TopupDialog.vue` (form: amount, payment-method), v-model:show, emit `@submit` -- `TransactionsTable.vue` (VDataTable + filters), props `:transactions`, `:filter-state` -- `InvoicesTable.vue` (VDataTable + download button), props `:invoices` - -После split: <200 строк. - -- [ ] **Step B.2.2: SecurityTab.vue split.** - -Создать в `app/resources/js/components/settings/security/`: - -- `ChangePasswordCard.vue` (form 3 fields: current/new/confirm) -- `TwoFactorCard.vue` (status + enable/disable + qr-dialog) -- `RecoveryCodesCard.vue` (list + regenerate) -- `SessionsTable.vue` (VDataTable + revoke action) - -После split: <200 строк. - -- [ ] **Step B.2.3: RemindersView.vue split.** - -Создать в `app/resources/js/components/reminders/`: - -- `RemindersFilters.vue` (status: active/done/overdue + manager-select) -- `RemindersList.vue` (VList с группировкой по дате) -- `ReminderForm.vue` (dialog: text + due_at + assignee) - -После split: <200 строк. - -- [ ] **Step B.2.4: Vitest для группы 2.** - -Шаблон как в Task B.1.4. Минимум `BalanceCard` (computed display), `ChangePasswordCard` (validation), `RemindersFilters` (filter emit), `ReminderForm` (form submit). - -- [ ] **Step B.2.5: Регрессия.** - -```bash -cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build -``` - -- [ ] **Step B.2.6: Commit.** - -```bash -git add app/resources/js/views/BillingView.vue \ - app/resources/js/components/billing/ \ - app/resources/js/views/settings/SecurityTab.vue \ - app/resources/js/components/settings/security/ \ - app/resources/js/views/RemindersView.vue \ - app/resources/js/components/reminders/ \ - app/tests/Vue/components/ -git commit -m "refactor(frontend): Sprint 4 Phase B/2 — split 3 user views (audit O-refactor-04 хвост) - -BillingView 416→<200 (+ BalanceCard/TopupDialog/Transactions/InvoicesTable) -SecurityTab 354→<200 (+ ChangePassword/TwoFactor/RecoveryCodes/Sessions) -RemindersView 345→<200 (+ RemindersFilters/List/Form) - -+N Vitest. - -Co-Authored-By: Claude Opus 4.7 (1M context) " -``` - -### Task B.3 — Группа 3: 2 utility views (ErrorView + DashboardView) - -- [ ] **Step B.3.1: ErrorView.vue split.** - -Создать в `app/resources/js/components/errors/`: - -- `ErrorIllustration.vue` (SVG 404/403/500, props `:code`) -- `ErrorActions.vue` (Login/Home/Back buttons, props `:code`, emit `@navigate`) - -После split: <200 строк. - -- [ ] **Step B.3.2: DashboardView.vue split.** - -Создать в `app/resources/js/components/dashboard/`: - -- `DashboardKpiRow.vue` (3-4 KPI-карты: leads_today/week/month, props `:metrics`) -- `DashboardBalance.vue` (баланс + low-balance warning) -- `DashboardRecentDeals.vue` (список последних 10 сделок, props `:deals`) - -После split: <200 строк. - -- [ ] **Step B.3.3: Vitest для группы 3.** - -`ErrorIllustration` (SVG render по props.code), `DashboardKpiRow` (компьютед labels + numerics через JetBrains Mono `tnum`). - -- [ ] **Step B.3.4: Регрессия.** - -```bash -cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build -``` - -- [ ] **Step B.3.5: Commit.** - -```bash -git add app/resources/js/views/errors/ErrorView.vue \ - app/resources/js/components/errors/ \ - app/resources/js/views/DashboardView.vue \ - app/resources/js/components/dashboard/ \ - app/tests/Vue/components/ -git commit -m "refactor(frontend): Sprint 4 Phase B/3 — split 2 utility views (audit O-refactor-04 хвост) - -ErrorView 320→<200 (+ ErrorIllustration + ErrorActions) -DashboardView 302→<200 (+ KpiRow + Balance + RecentDeals) - -+N Vitest. O-refactor-04 закрыт полностью (12 → 0 компонентов >300 строк -после Sprint 3 Phase C + Sprint 4 Phase B/1-3). - -Co-Authored-By: Claude Opus 4.7 (1M context) " -``` - -### Task B.4 — Acceptance Phase B - -- [ ] **Step B.4.1: Проверить, что 0 Vue-компонентов >300 строк.** - -```bash -find app/resources/js -name "*.vue" -exec wc -l {} + | awk '$1 > 300 && $2 != "total" {print}' -``` - -Expected: пустой output (либо 1-2 с явным обоснованием в комментарии — например, гигантский `app.vue` шаблон если есть). - -- [ ] **Step B.4.2: Histoire build smoke.** - -```bash -cd app && npm run story:build -``` - -Expected: build successful, новые stories видны (опционально — по 1 story на каждый sub-компонент с логикой). - ---- - -## Phase C — O-refactor-06: dead-code detection + удаление unused exports - -**Контекст:** Bundle size analyzer покажет, какие модули включены в финальный build, и какие exports никогда не импортируются. Цель — найти и удалить мёртвый код в `app/resources/js/utils/` и `app/resources/js/helpers/` (если есть). Установка `rollup-plugin-visualizer` через Vite-плагин — стандартный способ. - -**Files:** - -- Create: `app/scripts/analyze-bundle.sh` (опционально, или просто в README) -- Modify: `app/vite.config.js` (добавить visualizer plugin условно) -- Modify: `app/package.json` (новый script `npm run build:analyze`) -- Modify: файлы в `app/resources/js/utils/` и `helpers/` (удаление unused exports) - -### Task C.1 — Установить bundle analyzer - -- [ ] **Step C.1.1: Установить rollup-plugin-visualizer как devDependency.** - -```bash -cd app && npm install --save-dev rollup-plugin-visualizer -``` - -Expected: пакет в `devDependencies` секции `package.json`, никаких peer-dep warnings (если есть — `--legacy-peer-deps`). - -- [ ] **Step C.1.2: Modify `app/vite.config.js` — добавить visualizer условно.** - -```javascript -import { defineConfig } from 'vite'; -import laravel from 'laravel-vite-plugin'; -import vue from '@vitejs/plugin-vue'; -import vuetify from 'vite-plugin-vuetify'; -import { visualizer } from 'rollup-plugin-visualizer'; - -export default defineConfig({ - plugins: [ - laravel({ - input: ['resources/css/app.css', 'resources/js/app.ts'], - refresh: true, - }), - vue({ - template: { - transformAssetUrls: { - base: null, - includeAbsolute: false, - }, - }, - }), - vuetify({ autoImport: true }), - // Bundle analyzer — активен только при `BUILD_ANALYZE=1` или `vite build --mode analyze`. - // Генерирует storage/bundle-analyze.html (открыть в браузере). - process.env.BUILD_ANALYZE === '1' && visualizer({ - filename: 'storage/bundle-analyze.html', - open: false, - gzipSize: true, - brotliSize: true, - }), - ].filter(Boolean), - server: { - watch: { - ignored: ['**/storage/framework/views/**'], - }, - }, -}); -``` - -- [ ] **Step C.1.3: Modify `app/package.json` — добавить script.** - -В секцию `scripts`: - -```json -"build:analyze": "BUILD_ANALYZE=1 vite build" -``` - -(на Windows — кросс-платформенно через `cross-env` если будет нужно; пока на dev-стенде используем PowerShell `$env:BUILD_ANALYZE='1'; npm run build`). - -- [ ] **Step C.1.4: Запустить analyzer.** - -```bash -cd app && BUILD_ANALYZE=1 npm run build -# Windows PowerShell альтернатива: -# cd app; $env:BUILD_ANALYZE='1'; npm run build -``` - -Expected: `storage/bundle-analyze.html` создан, vite build успешен. - -### Task C.2 — Найти и удалить unused exports - -- [ ] **Step C.2.1: Открыть `storage/bundle-analyze.html` в браузере.** - -Просмотреть treemap. Найти: - -- Модули с подозрительно маленьким footprint (1-2 импорта на весь bundle — кандидаты на inline) -- Модули из `utils/` и `helpers/` которые не входят в build вообще (они unused — удалить) - -Альтернатива через CLI: использовать `knip` или `ts-prune` для систематического поиска. Установка опциональна, если visualizer достаточно. - -- [ ] **Step C.2.2: Установить `knip` для автоматического поиска unused exports.** - -```bash -cd app && npm install --save-dev knip -``` - -- [ ] **Step C.2.3: Запустить knip с базовым конфигом.** - -Создать `app/knip.config.ts`: - -```typescript -import type { KnipConfig } from 'knip'; - -const config: KnipConfig = { - entry: ['resources/js/app.ts', 'resources/js/router/index.ts'], - project: ['resources/js/**/*.{ts,vue}'], - ignore: ['**/*.story.vue', 'tests/**'], - ignoreDependencies: ['@vue/test-utils', 'jsdom', 'vitest'], -}; - -export default config; -``` - -```bash -cd app && npx knip -``` - -Expected: список unused files / unused exports / unused dependencies. - -- [ ] **Step C.2.4: Удалить найденные dead exports.** - -Идти по списку knip: - -- **Unused files** → если правда не нужен (проверить через `Grep` импорты по имени) → `git rm`. -- **Unused exports** → удалить export, оставить функцию приватной если используется внутри файла; иначе `git rm` функцию целиком. -- **Unused dependencies** → `npm uninstall `. - -Аккуратно: `knip` может ложно-положительно ругаться на dynamic imports, Vuetify auto-import, pinia stores. Перед удалением — `Grep` проверка. - -- [ ] **Step C.2.5: Регрессия.** - -```bash -cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build -``` - -Expected: всё зелёное. Если что-то сломалось — `git checkout` файл, перепроверить. - -- [ ] **Step C.2.6: Повторный knip — должен показать 0 unused (или явно объяснимые false-positive).** - -```bash -cd app && npx knip -``` - -- [ ] **Step C.2.7: Commit.** - -```bash -git add app/vite.config.js app/package.json app/package-lock.json app/knip.config.ts \ - app/resources/js/utils/ app/resources/js/helpers/ -git commit -m "$(cat <<'EOF' -chore(frontend): Sprint 4 Phase C — bundle analyzer + dead-code cleanup (audit O-refactor-06) - -- rollup-plugin-visualizer + script `npm run build:analyze` (env BUILD_ANALYZE=1) -- knip + конфиг + cleanup unused exports/files в utils/ и helpers/ -- удалено N exports, M dependencies (точное число — в diff) - -Bundle size снижение: ~X% gzip (точные цифры из storage/bundle-analyze.html). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Финальная регрессия + summary - -### Task R.1 — Полный sweep после Sprint 4 - -- [ ] **Step R.1.1: Backend полная регрессия.** - -```bash -cd app && composer pint && composer stan && composer test -``` - -Expected: Pint без diff, Larastan 0, Pest 419+ (baseline 418 + 3 keyset). - -- [ ] **Step R.1.2: Frontend полная регрессия.** - -```bash -cd app && npm run lint:vue && npm run type-check && npm run test:vue && npm run build && npm run story:build -``` - -Expected: ESLint 0, vue-tsc 0, Vitest 430+ (baseline 416 + ~15 за новые sub-components), build OK, Histoire build OK. - -- [ ] **Step R.1.3: Doc-sweep.** - -```bash -npm run check:docs -``` - -Expected: markdownlint 0, cspell 0, lychee 0, pa11y 0 (skip если HTML концепты убраны). - -- [ ] **Step R.1.4: Squawk + format:sql:check.** - -```bash -npm run lint:sql && npm run format:sql:check -``` - -Expected: 0 issues, format-check без diff. - -- [ ] **Step R.1.5: Обновить CLAUDE.md и Открытые_вопросы_v8_3.md через `claude-md-management`.** - -Через skill `/claude-md-management:claude-md-improver` — bump CLAUDE.md шапки (новые метрики Pest/Vitest/Vue-counts), обновить §6 «Текущая фаза» с записью «Sprint 4 «Audit tail» закрыт». - -В `docs/Открытые_вопросы_v8_3.md` — добавить запись в начало с детальной разбивкой Sprint 4. - -В `db/CHANGELOG_schema.md` — без изменений (Sprint 4 не трогает schema). - -- [ ] **Step R.1.6: Commit doc-sync.** - -```bash -git add CLAUDE.md docs/Открытые_вопросы_v8_3.md docs/CHANGELOG_claude_md.md -git commit -m "docs(narrative): sync versions + Sprint 4 acceptance (audit O-perf-04 + O-refactor-04 хвост + O-refactor-06)" -``` - -### Acceptance Sprint 4 - -- ✅ keyset pagination в `DealController::index` работает + Pest +3 теста -- ✅ 0 Vue-компонентов >300 строк (12 audit-кандидатов закрыты: Top-3 в Sprint 3 + 8 в Sprint 4 + ImpersonationDialog уже <300) -- ✅ Bundle analyzer + knip активны, dead exports удалены -- ✅ Регрессия зелёная (Pest, Vitest, Larastan, vue-tsc, ESLint, squawk, markdownlint, cspell, lychee, build, Histoire) -- ✅ CLAUDE.md / Открытые_вопросы синхронизированы -- ✅ 6 коммитов: Phase A (keyset) + Phase B/1 (3 admin) + Phase B/2 (3 user) + Phase B/3 (2 utility) + Phase C (cleanup) + R (doc-sync). При минимальном doc-sync R можно слить с C — итого 5. diff --git a/docs/superpowers/plans/2026-05-10-sprint5-preprod-tooling-plan.md b/docs/superpowers/plans/2026-05-10-sprint5-preprod-tooling-plan.md deleted file mode 100644 index ee64e37..0000000 --- a/docs/superpowers/plans/2026-05-10-sprint5-preprod-tooling-plan.md +++ /dev/null @@ -1,563 +0,0 @@ -# Sprint 5 «Pre-prod tooling» 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:** Активировать phase-3 tooling, не требующий YC-инфраструктуры: Semgrep SAST (#25) + Semgrep MCP + Dependabot (#27); подготовить Trivy workflow (#26) к будущей активации после Docker pipeline в YC. - -**Architecture:** Три независимых потока — (1) Dependabot: native GitHub config, без кода; (2) Semgrep: `npm run sast` в корне + CI workflow + MCP-запись в `.mcp.json` + allow в `.claude/settings.json`; (3) Trivy prep: workflow-файл создан, но отключён (`if: false`) до Sprint 7. - -**Tech Stack:** Semgrep CLI (pip/CI), GitHub Actions, Dependabot native, Trivy (CI-only), MCP JSON config. - ---- - -## Карта файлов - -| Файл | Действие | Назначение | -|------|----------|------------| -| `.github/dependabot.yml` | Создать | Dependabot: npm×2 (root + app) + composer (app) | -| `.github/workflows/sast.yml` | Создать | Semgrep SAST на push/PR в main | -| `.github/workflows/trivy.yml` | Создать | Trivy Docker scan — отключён до YC (`if: false`) | -| `.semgrep.yml` | Создать | Semgrep: пути + исключения | -| `trivy.yaml` | Создать | Trivy config — готов к активации | -| `package.json` (корень) | Изменить | Добавить скрипт `"sast"` | -| `.mcp.json` | Изменить | Добавить запись Semgrep MCP server | -| `.claude/settings.json` | Изменить | Allow `Bash(npm run sast:*)` | - ---- - -## Task 1: Dependabot — `.github/dependabot.yml` - -Dependabot создаёт автоматические PR для обновлений зависимостей прямо через GitHub (без кода на стороне проекта). Существующий `.github/workflows/dependency-check.yml` остаётся — он выполняет другую задачу (уведомление о устаревших версиях), не создаёт PR. - -**Files:** - -- Create: `.github/dependabot.yml` - -- [ ] **Step 1: Создать `.github/dependabot.yml`** - -```yaml -# Dependabot — Лидерра (#27) -# Docs: https://docs.github.com/code-security/dependabot/dependabot-version-updates -# Создаёт PR автоматически при выходе обновлений зависимостей. -# CI-workflow dependency-check.yml — параллельный, оставить (разные задачи). -version: 2 - -updates: - # Root package.json: markdownlint-cli2, cspell, pa11y-ci, stylelint, lefthook, npm-run-all2 - - package-ecosystem: npm - directory: / - schedule: - interval: weekly - day: monday - time: "09:00" - timezone: Europe/Moscow - open-pull-requests-limit: 5 - labels: - - dependencies - groups: - dev-tools: - patterns: - - "markdownlint-cli2" - - "cspell" - - "@cspell/*" - - "pa11y*" - - "stylelint*" - - "lefthook" - - "npm-run-all2" - - # app/package.json: Vue, Vuetify, Vite, Vitest, ESLint, Prettier, Histoire, etc. - - package-ecosystem: npm - directory: /app - schedule: - interval: weekly - day: monday - time: "09:00" - timezone: Europe/Moscow - open-pull-requests-limit: 10 - labels: - - dependencies - groups: - vue-ecosystem: - patterns: - - "vue" - - "vue-*" - - "@vue/*" - - "vuetify" - - "vite" - - "vite-*" - - "@vitejs/*" - - "vite-plugin-*" - - "laravel-vite-plugin" - vitest: - patterns: - - "vitest" - - "@vitest/*" - eslint: - patterns: - - "eslint" - - "eslint-*" - - "@eslint/*" - - "prettier" - - "eslint-config-prettier" - histoire: - patterns: - - "histoire" - - "@histoire/*" - - # app/composer.json: Laravel 13, Pest 4, Pint, Larastan, Boost, IDE Helper, etc. - - package-ecosystem: composer - directory: /app - schedule: - interval: weekly - day: monday - time: "09:00" - timezone: Europe/Moscow - open-pull-requests-limit: 10 - labels: - - dependencies - groups: - laravel-framework: - patterns: - - "laravel/framework" - - "laravel/sanctum" - - "laravel/tinker" - - "laravel/pail" - - "laravel/pao" - - "nunomaduro/collision" - pest: - patterns: - - "pestphp/*" - dev-tools: - patterns: - - "larastan/larastan" - - "barryvdh/laravel-ide-helper" - - "fakerphp/faker" - - "laravel/pint" - - "mockery/mockery" -``` - -- [ ] **Step 2: Commit** - -```bash -git add .github/dependabot.yml -git commit -m "chore(deps): add Dependabot config — npm×2 + composer weekly (#27)" -``` - -- [ ] **Step 3: Проверить на GitHub после push** - -После `git push origin main`: открыть GitHub → репозиторий → tab **Insights** → **Dependency graph** → **Dependabot**. -Expected: 3 экосистемы (`npm /`, `npm /app`, `composer /app`). Первые PR появятся в течение 24 часов. - ---- - -## Task 2: Semgrep config + `npm run sast` - -Semgrep — статический анализ безопасности (SAST). Запускается в CI (`ubuntu-latest`), поэтому `npm run sast` не требует локальной установки Python на dev-машине. -**На Windows dev:** `semgrep` недоступен без Python 3.9+. Локально пропустить; для ручного запуска — `choco install python` + `pip install semgrep`. - -**Files:** - -- Create: `.semgrep.yml` -- Modify: `package.json` (корень) — добавить скрипт `"sast"` - -- [ ] **Step 1: Создать `.semgrep.yml`** - -```yaml -# Semgrep ruleset — Лидерра (#25) -# Docs: https://semgrep.dev/docs/writing-rules/rule-syntax -# Локально: npm run sast (требует pip install semgrep) -# CI: .github/workflows/sast.yml (ubuntu-latest, без установки) - -rules: [] # custom rules — пустые; используем облачные рулсеты через --config p/... - -paths: - include: - - app/app - - app/resources/js - - app/database/migrations - exclude: - - app/vendor - - app/node_modules - - app/storage - - "**/*.min.js" - - "**/*.min.css" - - "**/_ide_helper*.php" - - "**/phpstan-baseline.neon" -``` - -- [ ] **Step 2: Добавить скрипт `"sast"` в корневой `package.json`** - -Открыть `package.json` в корне (не в `app/`). Текущий раздел `scripts`: - -```json -"scripts": { - "lint:md": "...", - "lint:md:fix": "...", - "spell": "...", - "links": "...", - "lint:css": "...", - "lint:sql": "...", - "format:sql:check": "...", - "format:sql": "...", - "a11y": "...", - "check:docs": "run-p lint:md spell links a11y" -} -``` - -Добавить `"sast"` после `"check:docs"`: - -```json - "check:docs": "run-p lint:md spell links a11y", - "sast": "semgrep --config=p/php --config=p/javascript --config=p/typescript --config=p/secrets --config=.semgrep.yml --error --time" -``` - -Итоговый scripts-раздел должен быть: - -```json -"scripts": { - "lint:md": "markdownlint-cli2 \"docs/**/*.md\" \"db/**/*.md\" \"*.md\"", - "lint:md:fix": "markdownlint-cli2 --fix \"docs/**/*.md\" \"db/**/*.md\" \"*.md\"", - "spell": "cspell --no-progress --show-suggestions \"docs/**/*.md\" \"db/**/*.md\" \"*.md\" \"web/**/*.html\"", - "links": "bin\\lychee.exe --config .lychee.toml \"docs/**/*.md\" \"db/**/*.md\" \"*.md\"", - "lint:css": "stylelint \"web/**/*.html\"", - "lint:sql": "bin\\squawk.exe db/schema.sql", - "format:sql:check": "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 || echo \"pgFormatter would reformat — run npm run format:sql to apply\"", - "format:sql": "perl -I bin/pgFormatter/lib bin/pgFormatter/pg_format -o db/schema.sql.formatted db/schema.sql && echo \"Wrote db/schema.sql.formatted — review diff before replacing source\"", - "a11y": "pa11y-ci --config pa11y.config.json", - "check:docs": "run-p lint:md spell links a11y", - "sast": "semgrep --config=p/php --config=p/javascript --config=p/typescript --config=p/secrets --config=.semgrep.yml --error --time" -} -``` - -- [ ] **Step 3: Commit** - -```bash -git add .semgrep.yml package.json -git commit -m "feat(tooling): Semgrep config + npm run sast script (#25)" -``` - ---- - -## Task 3: Semgrep CI workflow — `.github/workflows/sast.yml` - -**Files:** - -- Create: `.github/workflows/sast.yml` - -- [ ] **Step 1: Создать `.github/workflows/sast.yml`** - -```yaml -name: SAST — Semgrep - -on: - push: - branches: [main] - paths: - - 'app/app/**' - - 'app/resources/js/**' - - 'app/database/migrations/**' - - '.semgrep.yml' - - '.github/workflows/sast.yml' - pull_request: - branches: [main] - paths: - - 'app/app/**' - - 'app/resources/js/**' - - 'app/database/migrations/**' - -permissions: - contents: read - security-events: write # нужно для upload-sarif - -jobs: - semgrep: - runs-on: ubuntu-latest - name: Semgrep SAST scan - - steps: - - uses: actions/checkout@v4 - - - name: Run Semgrep - uses: semgrep/semgrep-action@v1 - with: - config: >- - p/php - p/javascript - p/typescript - p/secrets - env: - # SEMGREP_APP_TOKEN — опциональный, для Semgrep Cloud dashboard. - # Без него: open-source режим, результаты только в GitHub Security tab. - # Добавить: GitHub → Settings → Secrets → Actions → SEMGREP_APP_TOKEN - SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} - - - name: Upload SARIF to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 - if: always() - with: - sarif_file: semgrep.sarif - continue-on-error: true # не блокировать PR если SARIF upload упал -``` - -**Важно — первый запуск:** Semgrep может найти реальные проблемы в существующем коде (не только новом). При первом прогоне нужен тriage: - -1. Смотреть вкладку **Security → Code scanning alerts** на GitHub. -2. Ложные срабатывания — помечать «Dismissed» с пояснением. -3. Реальные находки P0/P1 — исправлять до merge. - -- [ ] **Step 2: Commit** - -```bash -git add .github/workflows/sast.yml -git commit -m "feat(ci): Semgrep SAST workflow — push/PR to main (#25)" -``` - ---- - -## Task 4: Semgrep MCP server — `.mcp.json` - -Semgrep MCP даёт Claude Code инструменты для семантического поиска по Semgrep-правилам прямо в разговоре (аналитика кода, поиск паттернов безопасности). - -**Files:** - -- Modify: `.mcp.json` - -- [ ] **Step 1: Добавить запись `semgrep` в `.mcp.json`** - -Текущий `.mcp.json` содержит ключи `playwright`, `github`, `laravel-boost`. Добавить `semgrep` четвёртым: - -```json -{ - "$schema": "https://raw.githubusercontent.com/anthropics/claude-code/main/schemas/mcp.json", - "mcpServers": { - "playwright": { - "command": "npx", - "args": ["-y", "@playwright/mcp@latest"], - "comment": "Фаза 0 #2 — открыть web/*.html, screenshot, проверка интерактива" - }, - "github": { - "type": "http", - "url": "https://api.githubcopilot.com/mcp", - "headers": { - "Authorization": "Bearer ${GITHUB_TOKEN}" - }, - "comment": "Фаза 0 #3 — официальный hosted GitHub MCP (https://github.com/github/github-mcp-server). Требует env GITHUB_TOKEN с PAT (scopes: repo, read:org, не давать admin/delete). Раньше использовали deprecated @modelcontextprotocol/server-github — заменён 06.05.2026." - }, - "laravel-boost": { - "command": "php", - "args": ["app/artisan", "boost:mcp"], - "comment": "Фаза 1 #10 — Laravel Boost MCP (laravel/boost v2.4.6). Заменяет PostgreSQL MCP из фазы 0. Через Roster детектит установленные пакеты (Laravel 13, Pest 4, Pint, Larastan, IDE Helper) и серверит соответствующие guidelines + DB query/schema/tinker tools. Кастомный Vuetify 3 guideline — в app/.ai/guidelines/vuetify.md. Конфиг wizard'а — app/boost.json (написан вручную: wizard сломан на кириллице-пути)." - }, - "semgrep": { - "command": "npx", - "args": ["-y", "semgrep-mcp"], - "comment": "Фаза 3 #25 — Semgrep MCP (SAST). Семантический поиск/анализ кода через Semgrep rules в Claude Code. Пакет: npmjs.com/package/semgrep-mcp — если 404, запустить 'npm search semgrep mcp' для актуального имени." - } - } -} -``` - -- [ ] **Step 2: Проверить подключение MCP** - -Перезапустить Claude Code. Выполнить в разговоре: - -``` -/mcp -``` - -Expected: в списке серверов появился `semgrep`. Если статус `error` вместо `connected` — пакет `semgrep-mcp` не найден на npm, нужно уточнить имя: - -```bash -npm search semgrep mcp --json | Select-Object -First 5 -``` - -Заменить `semgrep-mcp` в `.mcp.json` на найденное имя пакета. - -- [ ] **Step 3: Commit** - -```bash -git add .mcp.json -git commit -m "feat(tooling): Semgrep MCP server in .mcp.json (#25)" -``` - ---- - -## Task 5: Allow `npm run sast` в `.claude/settings.json` - -**Files:** - -- Modify: `.claude/settings.json` - -- [ ] **Step 1: Добавить разрешение в `.claude/settings.json`** - -Текущий `permissions.allow` содержит `"Bash(npm run check:docs:*)"` и аналогичные строки. Добавить `"Bash(npm run sast:*)"` рядом с остальными npm-скриптами: - -```json -"allow": [ - "Bash(npm install:*)", - "Bash(npm run lint:md:*)", - "Bash(npm run spell:*)", - "Bash(npm run links:*)", - "Bash(npm run lint:css:*)", - "Bash(npm run a11y:*)", - "Bash(npm run check:docs:*)", - "Bash(npm run lint:md:fix:*)", - "Bash(npm run sast:*)", - "Bash(git status)", - ... -] -``` - -- [ ] **Step 2: Commit** - -```bash -git add .claude/settings.json -git commit -m "chore(claude): allow npm run sast in settings.json (#25)" -``` - ---- - -## Task 6: Trivy prep — workflow + config (disabled) - -Trivy сканирует Docker-образы на CVE. Docker pipeline будет настроен в Sprint 7 (YC Container Registry). До тех пор — файлы готовы, workflow отключён через `if: false`. - -**Files:** - -- Create: `trivy.yaml` -- Create: `.github/workflows/trivy.yml` - -- [ ] **Step 1: Создать `trivy.yaml`** - -```yaml -# Trivy config — Лидерра (#26) -# Активировать в Sprint 7 после настройки Docker pipeline в YC. -# Docs: https://aquasecurity.github.io/trivy/latest/docs/configuration/ - -exit-code: 1 -severity: CRITICAL,HIGH -format: sarif -output: trivy-results.sarif -ignore-unfixed: true - -vulnerability: - type: - - os - - library -``` - -- [ ] **Step 2: Создать `.github/workflows/trivy.yml` (disabled)** - -```yaml -name: Trivy — Docker image scan - -# ОТКЛЮЧЕНО до Sprint 7 (YC Docker pipeline). -# Для активации: -# 1. Убрать `if: false` у job trivy -# 2. Добавить GitHub secret YC_REGISTRY (полный адрес, напр. cr.yandex/crp.../liderra) -# 3. Убедиться, что CI job собирает образ перед этим workflow -# См. roadmap Sprint 7 «YC infrastructure setup». - -on: - push: - branches: [main] - paths: - - 'Dockerfile' - - 'docker-compose*.yml' - - '.github/workflows/trivy.yml' - - 'trivy.yaml' - schedule: - - cron: '0 10 * * 1' # каждый понедельник 10:00 UTC - -permissions: - contents: read - security-events: write - -jobs: - trivy: - runs-on: ubuntu-latest - if: false # TODO Sprint 7: убрать после настройки Docker pipeline - - steps: - - uses: actions/checkout@v4 - - - name: Run Trivy image scan - uses: aquasecurity/trivy-action@0.30.0 - with: - image-ref: '${{ secrets.YC_REGISTRY }}:${{ github.sha }}' - format: 'sarif' - output: 'trivy-results.sarif' - severity: 'CRITICAL,HIGH' - exit-code: '1' - ignore-unfixed: true - vuln-type: 'os,library' - trivy-config: 'trivy.yaml' - - - name: Upload Trivy SARIF to GitHub Security tab - uses: github/codeql-action/upload-sarif@v3 - if: always() - with: - sarif_file: trivy-results.sarif -``` - -- [ ] **Step 3: Commit** - -```bash -git add trivy.yaml .github/workflows/trivy.yml -git commit -m "feat(tooling): Trivy CI workflow prep — disabled until YC Docker (#26)" -``` - ---- - -## Task 7: Push и тriage первого Semgrep прогона - -- [ ] **Step 1: Push всех коммитов** - -```bash -git push origin main -``` - -- [ ] **Step 2: Наблюдать за CI** - -Открыть GitHub → Actions → workflow **SAST — Semgrep**. Ждать завершения (~2–3 мин). - -- [ ] **Step 3: Тriage Security alerts** - -Открыть GitHub → Security → **Code scanning** (появляется после первого upload-sarif). - -Алгоритм тriage: - -- **CRITICAL/HIGH** с реальным уязвимым паттерном → создать Issue, исправить в ближайшем спринте. -- **Ложное срабатывание** (напр., флаг в тесте, mock-данные) → Dismiss с пояснением «test/mock data». -- **MEDIUM/LOW** → Dismiss как «acceptable risk» или создать тикет Post-MVP. - -- [ ] **Step 4: Проверить Dependabot** - -GitHub → Insights → Dependency graph → Dependabot. Expected: 3 экосистемы зарегистрированы. - ---- - -## Self-Review - -### Spec coverage - -| Требование roadmap Sprint 5 | Task | Статус | -|-----------------------------|------|--------| -| #25 Semgrep local `npm run sast` | Task 2 | ✅ | -| #25 Semgrep CI workflow | Task 3 | ✅ | -| #25 Semgrep MCP | Task 4 | ✅ | -| #26 Trivy prep (disabled until YC) | Task 6 | ✅ | -| #27 Dependabot | Task 1 | ✅ | -| Allow `npm run sast` в settings | Task 5 | ✅ | -| Push + первый тriage | Task 7 | ✅ | - -### Placeholder scan - -- Task 2 Step 2: показан полный итоговый блок `scripts` целиком ✅ -- Task 4 Step 1: полный `.mcp.json` показан, не «добавить что-то» ✅ -- Task 4 Step 2: конкретная команда для поиска пакета если npm 404 ✅ -- Task 6 Step 2: TODO с конкретными инструкциями активации, не просто «активировать потом» ✅ -- Task 7 Step 3: конкретный алгоритм тriage по severity ✅ - -### Type consistency - -Конфигурационные файлы — без типов. Имена файлов, скриптов и ключей согласованы по всем задачам: `.semgrep.yml` / `p/php p/javascript p/typescript p/secrets` — одинаковый набор в Task 2 и Task 3. `trivy.yaml` + `trivy-config: 'trivy.yaml'` в workflow совпадают ✅ diff --git a/docs/superpowers/plans/2026-05-10-sprint6-phase-a-reports-plan.md b/docs/superpowers/plans/2026-05-10-sprint6-phase-a-reports-plan.md deleted file mode 100644 index f1a1a79..0000000 --- a/docs/superpowers/plans/2026-05-10-sprint6-phase-a-reports-plan.md +++ /dev/null @@ -1,1212 +0,0 @@ -# Sprint 6 Phase A: Reports Backend 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:** Extend Reports backend with 3 new providers (managers\_summary, sources\_summary, billing\_summary), S3 abstraction via `reports` disk, and file download endpoint. - -**Architecture:** Three new `ReportDataProvider` implementations follow the `DealsExportProvider` pattern (`DB::transaction` + `SET LOCAL` + join queries). `ReportGeneratorRegistry` gains a `SUPPORTED_TYPES` private constant and 3 new injected providers. S3 abstraction adds a `reports` disk to `config/filesystems.php` driven by `REPORT_DISK` env var; every `Storage::disk('local')` in the Reports subsystem is replaced with `Storage::disk('reports')`. The download endpoint streams a stored file via `response()->streamDownload()`. - -**Tech Stack:** PHP 8.3, Laravel 13, Pest 4, PostgreSQL 16 (deals/users/projects/suppliers/supplier\_lead\_costs/balance\_transactions tables), `Illuminate\Support\Facades\Storage` - -> **Note on column names:** The design spec SQL used `d.assigned_user_id` and `bt.transaction_type`/`bt.balance_after_rub` — actual schema columns are `deals.manager_id`, `balance_transactions.type`, and `balance_transactions.balance_rub_after`. This plan uses the real names. -> -> **Note on test dates:** `deals` and `supplier_lead_costs` are partitioned; earliest partition is `2026-05-01`. All test data uses May 2026 dates. - ---- - -## File Map - -**New files:** - -- `app/app/Services/Reports/Providers/ManagersSummaryProvider.php` -- `app/app/Services/Reports/Providers/SourcesSummaryProvider.php` -- `app/app/Services/Reports/Providers/BillingSummaryProvider.php` -- `app/tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php` -- `app/tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php` -- `app/tests/Feature/Reports/Providers/BillingSummaryProviderTest.php` -- `app/tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php` - -**Modified files:** - -- `app/app/Services/Reports/ReportGeneratorRegistry.php` — add 3 providers + `SUPPORTED_TYPES` const -- `app/config/filesystems.php` — add `reports` disk -- `.env.example` — add `REPORT_DISK=local` -- `app/app/Jobs/GenerateReportJob.php:82` — `disk('local')` → `disk('reports')` -- `app/app/Http/Controllers/Api/ReportJobController.php` — `disk('local')` → `disk('reports')` in `destroy()`; add `download()` method; add `StreamedResponse` import -- `app/app/Console/Commands/ReportsCleanupExpired.php:71` — `disk('local')` → `disk('reports')` -- `app/tests/Feature/Reports/ReportJobControllerTest.php` — `fake('local')` → `fake('reports')` -- `app/tests/Feature/Reports/ReportLifecycleTest.php` — `fake('local')` → `fake('reports')`; `disk('local')` → `disk('reports')` in test bodies -- `app/routes/web.php` — add `GET /{id}/file` route inside reports group - ---- - -## Task 1: ManagersSummaryProvider - -**Files:** - -- Create: `app/tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php` -- Create: `app/app/Services/Reports/Providers/ManagersSummaryProvider.php` - -- [ ] **Step 1: Write the failing test** - -```php -tenant = Tenant::factory()->create(); - $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); - $this->provider = app(ManagersSummaryProvider::class); - $this->job = ReportJob::create([ - 'tenant_id' => $this->tenant->id, - 'user_id' => $this->user->id, - 'type' => 'managers_summary', - 'parameters' => ['format' => 'csv', 'date_from' => '2026-05-01', 'date_to' => '2026-05-31'], - 'status' => ReportJob::STATUS_PENDING, - ]); -}); - -test('slug() == managers_summary', function () { - expect($this->provider->slug())->toBe('managers_summary'); -}); - -test('headers() returns 7 columns', function () { - $headers = $this->provider->headers(); - expect($headers)->toHaveCount(7); - expect($headers[0])->toBe('Менеджер'); - expect($headers[6])->toBe('Средняя стоимость ₽'); -}); - -test('rows() returns empty array for period with no deals', function () { - $rows = $this->provider->rows($this->job); - expect($rows)->toBe([]); -}); - -test('rows() returns manager stats with conversion and revenue', function () { - $manager = User::factory()->create(['tenant_id' => $this->tenant->id]); - - $projectId = DB::table('projects')->insertGetId([ - 'tenant_id' => $this->tenant->id, - 'name' => 'Test Project', - ]); - - $supplierId = DB::table('suppliers')->insertGetId([ - 'code' => 'test-mgr-s1', - 'name' => 'Test Supplier', - 'accepts_types' => '{websites}', - 'cost_rub' => 100, - ]); - - $d1ReceivedAt = '2026-05-10 10:00:00'; - $d2ReceivedAt = '2026-05-15 10:00:00'; - $d3ReceivedAt = '2026-05-20 10:00:00'; - - $deal1 = DB::table('deals')->insertGetId([ - 'tenant_id' => $this->tenant->id, - 'project_id' => $projectId, - 'phone' => '+70001234501', - 'manager_id' => $manager->id, - 'status' => 'accepted', - 'received_at' => $d1ReceivedAt, - ]); - $deal2 = DB::table('deals')->insertGetId([ - 'tenant_id' => $this->tenant->id, - 'project_id' => $projectId, - 'phone' => '+70001234502', - 'manager_id' => $manager->id, - 'status' => 'accepted', - 'received_at' => $d2ReceivedAt, - ]); - $deal3 = DB::table('deals')->insertGetId([ - 'tenant_id' => $this->tenant->id, - 'project_id' => $projectId, - 'phone' => '+70001234503', - 'manager_id' => $manager->id, - 'status' => 'rejected', - 'received_at' => $d3ReceivedAt, - ]); - - DB::table('supplier_lead_costs')->insert([ - ['deal_id' => $deal1, 'received_at' => $d1ReceivedAt, 'supplier_id' => $supplierId, 'cost_rub' => 1000], - ['deal_id' => $deal2, 'received_at' => $d2ReceivedAt, 'supplier_id' => $supplierId, 'cost_rub' => 2000], - ]); - - DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenant->id); - - $rows = $this->provider->rows($this->job); - - expect($rows)->toHaveCount(1); - $row = $rows[0]; - expect($row[1])->toBe(3); // total - expect($row[2])->toBe(2); // accepted - expect($row[3])->toBe(1); // rejected - expect((float) $row[4])->toBe(66.7); // conversion % - expect((float) $row[5])->toBe(3000.0); // revenue_rub -}); -``` - -Save to `app/tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php`. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php --no-coverage -``` - -Expected: FAIL — `Class "App\Services\Reports\Providers\ManagersSummaryProvider" not found` - -- [ ] **Step 3: Write ManagersSummaryProvider** - -```php -parameters ?? []; - $dateFrom = $params['date_from']; - $dateTo = $params['date_to'] . ' 23:59:59'; - - return DB::transaction(function () use ($job, $dateFrom, $dateTo): array { - DB::statement('SET LOCAL app.current_tenant_id = ' . (int) $job->tenant_id); - - return DB::table('deals as d') - ->leftJoin('users as u', 'u.id', '=', 'd.manager_id') - ->leftJoin('supplier_lead_costs as slc', 'slc.deal_id', '=', 'd.id') - ->where('d.tenant_id', $job->tenant_id) - ->whereNull('d.deleted_at') - ->whereBetween('d.received_at', [$dateFrom, $dateTo]) - ->groupBy('u.id', 'u.first_name', 'u.last_name', 'u.email') - ->orderByDesc(DB::raw('COUNT(d.id)')) - ->select([ - DB::raw("COALESCE(u.first_name || ' ' || u.last_name, u.email) AS manager"), - DB::raw('COUNT(d.id) AS total'), - DB::raw("COUNT(d.id) FILTER (WHERE d.status = 'accepted') AS accepted"), - DB::raw("COUNT(d.id) FILTER (WHERE d.status = 'rejected') AS rejected"), - DB::raw("ROUND(COUNT(d.id) FILTER (WHERE d.status = 'accepted') * 100.0 / NULLIF(COUNT(d.id), 0), 1) AS conversion"), - DB::raw('COALESCE(SUM(slc.cost_rub), 0) AS revenue_rub'), - DB::raw('ROUND(AVG(slc.cost_rub), 2) AS avg_cost_rub'), - ]) - ->get() - ->map(fn ($row): array => [ - $row->manager, - (int) $row->total, - (int) $row->accepted, - (int) $row->rejected, - (float) $row->conversion, - (float) $row->revenue_rub, - $row->avg_cost_rub !== null ? (float) $row->avg_cost_rub : null, - ]) - ->all(); - }); - } -} -``` - -Save to `app/app/Services/Reports/Providers/ManagersSummaryProvider.php`. - -- [ ] **Step 4: Run test to verify it passes** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php --no-coverage -``` - -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -cd app && ./vendor/bin/pint app/Services/Reports/Providers/ManagersSummaryProvider.php -git add app/app/Services/Reports/Providers/ManagersSummaryProvider.php app/tests/Feature/Reports/Providers/ManagersSummaryProviderTest.php -git commit -m "feat(reports): ManagersSummaryProvider — managers summary report" -``` - ---- - -## Task 2: SourcesSummaryProvider - -**Files:** - -- Create: `app/tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php` -- Create: `app/app/Services/Reports/Providers/SourcesSummaryProvider.php` - -- [ ] **Step 1: Write the failing test** - -```php -tenant = Tenant::factory()->create(); - $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); - $this->provider = app(SourcesSummaryProvider::class); - $this->job = ReportJob::create([ - 'tenant_id' => $this->tenant->id, - 'user_id' => $this->user->id, - 'type' => 'sources_summary', - 'parameters' => ['format' => 'csv', 'date_from' => '2026-05-01', 'date_to' => '2026-05-31'], - 'status' => ReportJob::STATUS_PENDING, - ]); -}); - -test('slug() == sources_summary', function () { - expect($this->provider->slug())->toBe('sources_summary'); -}); - -test('headers() returns 6 columns', function () { - $headers = $this->provider->headers(); - expect($headers)->toHaveCount(6); - expect($headers[0])->toBe('Источник'); - expect($headers[5])->toBe('Среднее ₽'); -}); - -test('rows() returns empty array for period with no deals', function () { - expect($this->provider->rows($this->job))->toBe([]); -}); - -test('rows() aggregates deals by source (project) with conversion', function () { - $projectId = DB::table('projects')->insertGetId([ - 'tenant_id' => $this->tenant->id, - 'name' => 'Source Alpha', - ]); - - $supplierId = DB::table('suppliers')->insertGetId([ - 'code' => 'test-src-s1', - 'name' => 'Test Supplier', - 'accepts_types' => '{websites}', - 'cost_rub' => 100, - ]); - - $d1ReceivedAt = '2026-05-10 10:00:00'; - $d2ReceivedAt = '2026-05-15 10:00:00'; - - $deal1 = DB::table('deals')->insertGetId([ - 'tenant_id' => $this->tenant->id, - 'project_id' => $projectId, - 'phone' => '+70001234601', - 'status' => 'accepted', - 'received_at' => $d1ReceivedAt, - ]); - DB::table('deals')->insert([ - 'tenant_id' => $this->tenant->id, - 'project_id' => $projectId, - 'phone' => '+70001234602', - 'status' => 'rejected', - 'received_at' => $d2ReceivedAt, - ]); - - DB::table('supplier_lead_costs')->insert([ - 'deal_id' => $deal1, 'received_at' => $d1ReceivedAt, 'supplier_id' => $supplierId, 'cost_rub' => 500, - ]); - - DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenant->id); - - $rows = $this->provider->rows($this->job); - - expect($rows)->toHaveCount(1); - $row = $rows[0]; - expect($row[0])->toBe('Source Alpha'); - expect($row[1])->toBe(2); // total - expect($row[2])->toBe(1); // accepted - expect((float) $row[3])->toBe(50.0); // conversion % - expect((float) $row[4])->toBe(500.0); // total_rub -}); -``` - -Save to `app/tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php`. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php --no-coverage -``` - -Expected: FAIL — `Class "App\Services\Reports\Providers\SourcesSummaryProvider" not found` - -- [ ] **Step 3: Write SourcesSummaryProvider** - -```php -parameters ?? []; - $dateFrom = $params['date_from']; - $dateTo = $params['date_to'] . ' 23:59:59'; - - return DB::transaction(function () use ($job, $dateFrom, $dateTo): array { - DB::statement('SET LOCAL app.current_tenant_id = ' . (int) $job->tenant_id); - - return DB::table('deals as d') - ->leftJoin('projects as p', 'p.id', '=', 'd.project_id') - ->leftJoin('supplier_lead_costs as slc', 'slc.deal_id', '=', 'd.id') - ->where('d.tenant_id', $job->tenant_id) - ->whereNull('d.deleted_at') - ->whereBetween('d.received_at', [$dateFrom, $dateTo]) - ->groupBy('p.id', 'p.name') - ->orderByDesc(DB::raw('COUNT(d.id)')) - ->select([ - DB::raw('p.name AS source'), - DB::raw('COUNT(d.id) AS total'), - DB::raw("COUNT(d.id) FILTER (WHERE d.status = 'accepted') AS accepted"), - DB::raw("ROUND(COUNT(d.id) FILTER (WHERE d.status = 'accepted') * 100.0 / NULLIF(COUNT(d.id), 0), 1) AS conversion"), - DB::raw('COALESCE(SUM(slc.cost_rub), 0) AS total_rub'), - DB::raw('ROUND(AVG(slc.cost_rub), 2) AS avg_cost_rub'), - ]) - ->get() - ->map(fn ($row): array => [ - $row->source, - (int) $row->total, - (int) $row->accepted, - (float) $row->conversion, - (float) $row->total_rub, - $row->avg_cost_rub !== null ? (float) $row->avg_cost_rub : null, - ]) - ->all(); - }); - } -} -``` - -Save to `app/app/Services/Reports/Providers/SourcesSummaryProvider.php`. - -- [ ] **Step 4: Run test to verify it passes** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php --no-coverage -``` - -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -cd app && ./vendor/bin/pint app/Services/Reports/Providers/SourcesSummaryProvider.php -git add app/app/Services/Reports/Providers/SourcesSummaryProvider.php app/tests/Feature/Reports/Providers/SourcesSummaryProviderTest.php -git commit -m "feat(reports): SourcesSummaryProvider — deals by source report" -``` - ---- - -## Task 3: BillingSummaryProvider - -**Files:** - -- Create: `app/tests/Feature/Reports/Providers/BillingSummaryProviderTest.php` -- Create: `app/app/Services/Reports/Providers/BillingSummaryProvider.php` - -> Note: `balance_transactions` schema columns are `type` (not `transaction_type`) and `balance_rub_after` (not `balance_after_rub`). The headers stay human-friendly. - -- [ ] **Step 1: Write the failing test** - -```php -tenant = Tenant::factory()->create(); - $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); - $this->provider = app(BillingSummaryProvider::class); - $this->job = ReportJob::create([ - 'tenant_id' => $this->tenant->id, - 'user_id' => $this->user->id, - 'type' => 'billing_summary', - 'parameters' => ['format' => 'csv', 'date_from' => '2026-05-01', 'date_to' => '2026-05-31'], - 'status' => ReportJob::STATUS_PENDING, - ]); -}); - -test('slug() == billing_summary', function () { - expect($this->provider->slug())->toBe('billing_summary'); -}); - -test('headers() returns 5 columns', function () { - $headers = $this->provider->headers(); - expect($headers)->toHaveCount(5); - expect($headers[0])->toBe('Дата'); - expect($headers[4])->toBe('Описание'); -}); - -test('rows() returns empty array for period with no transactions', function () { - expect($this->provider->rows($this->job))->toBe([]); -}); - -test('rows() returns billing transactions in chronological order', function () { - DB::table('balance_transactions')->insert([ - [ - 'tenant_id' => $this->tenant->id, - 'type' => 'topup', - 'amount_rub' => 5000, - 'balance_rub_after' => 5000, - 'description' => 'Пополнение счёта', - 'created_at' => '2026-05-05 12:00:00', - ], - [ - 'tenant_id' => $this->tenant->id, - 'type' => 'lead_charge', - 'amount_rub' => -200, - 'balance_rub_after' => 4800, - 'description' => 'Списание за лид', - 'created_at' => '2026-05-10 09:00:00', - ], - ]); - - // Another tenant's transaction — must be excluded - $other = Tenant::factory()->create(); - DB::table('balance_transactions')->insert([ - 'tenant_id' => $other->id, - 'type' => 'topup', - 'amount_rub' => 1000, - 'balance_rub_after' => 1000, - 'description' => 'Other tenant', - 'created_at' => '2026-05-07 10:00:00', - ]); - - DB::statement('SET LOCAL app.current_tenant_id = ' . $this->tenant->id); - - $rows = $this->provider->rows($this->job); - - expect($rows)->toHaveCount(2); - expect($rows[0][1])->toBe('topup'); - expect((float) $rows[0][2])->toBe(5000.0); - expect($rows[1][1])->toBe('lead_charge'); - expect((float) $rows[1][2])->toBe(-200.0); -}); -``` - -Save to `app/tests/Feature/Reports/Providers/BillingSummaryProviderTest.php`. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/Providers/BillingSummaryProviderTest.php --no-coverage -``` - -Expected: FAIL — `Class "App\Services\Reports\Providers\BillingSummaryProvider" not found` - -- [ ] **Step 3: Write BillingSummaryProvider** - -```php -parameters ?? []; - $dateFrom = $params['date_from']; - $dateTo = $params['date_to'] . ' 23:59:59'; - - return DB::transaction(function () use ($job, $dateFrom, $dateTo): array { - DB::statement('SET LOCAL app.current_tenant_id = ' . (int) $job->tenant_id); - - return DB::table('balance_transactions as bt') - ->where('bt.tenant_id', $job->tenant_id) - ->whereBetween('bt.created_at', [$dateFrom, $dateTo]) - ->orderBy('bt.created_at') - ->select([ - DB::raw('bt.created_at::date AS date'), - 'bt.type', - 'bt.amount_rub', - 'bt.balance_rub_after', - 'bt.description', - ]) - ->get() - ->map(fn ($row): array => [ - $row->date, - $row->type, - (float) $row->amount_rub, - $row->balance_rub_after !== null ? (float) $row->balance_rub_after : null, - $row->description, - ]) - ->all(); - }); - } -} -``` - -Save to `app/app/Services/Reports/Providers/BillingSummaryProvider.php`. - -- [ ] **Step 4: Run test to verify it passes** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/Providers/BillingSummaryProviderTest.php --no-coverage -``` - -Expected: PASS (4 tests) - -- [ ] **Step 5: Commit** - -```bash -cd app && ./vendor/bin/pint app/Services/Reports/Providers/BillingSummaryProvider.php -git add app/app/Services/Reports/Providers/BillingSummaryProvider.php app/tests/Feature/Reports/Providers/BillingSummaryProviderTest.php -git commit -m "feat(reports): BillingSummaryProvider — billing transactions report" -``` - ---- - -## Task 4: Extend ReportGeneratorRegistry - -**Files:** - -- Create: `app/tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php` -- Modify: `app/app/Services/Reports/ReportGeneratorRegistry.php` - -- [ ] **Step 1: Write the failing test** - -```php -provider('deals_export')) - ->toBeInstanceOf(DealsExportProvider::class); -}); - -test('provider() resolves managers_summary', function () { - expect(app(ReportGeneratorRegistry::class)->provider('managers_summary')) - ->toBeInstanceOf(ManagersSummaryProvider::class); -}); - -test('provider() resolves sources_summary', function () { - expect(app(ReportGeneratorRegistry::class)->provider('sources_summary')) - ->toBeInstanceOf(SourcesSummaryProvider::class); -}); - -test('provider() resolves billing_summary', function () { - expect(app(ReportGeneratorRegistry::class)->provider('billing_summary')) - ->toBeInstanceOf(BillingSummaryProvider::class); -}); - -test('provider() throws for unknown type', function () { - expect(fn () => app(ReportGeneratorRegistry::class)->provider('unknown')) - ->toThrow(InvalidArgumentException::class); -}); - -test('isSupported() true for all 4 types with csv', function () { - $registry = app(ReportGeneratorRegistry::class); - expect($registry->isSupported('deals_export', 'csv'))->toBeTrue(); - expect($registry->isSupported('managers_summary', 'csv'))->toBeTrue(); - expect($registry->isSupported('sources_summary', 'csv'))->toBeTrue(); - expect($registry->isSupported('billing_summary', 'csv'))->toBeTrue(); -}); - -test('isSupported() false for unknown type', function () { - expect(app(ReportGeneratorRegistry::class)->isSupported('unknown_type', 'csv'))->toBeFalse(); -}); -``` - -Save to `app/tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php`. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -cd app && php vendor/bin/pest tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php --no-coverage -``` - -Expected: FAIL — `provider('managers_summary')` throws `InvalidArgumentException` - -- [ ] **Step 3: Replace ReportGeneratorRegistry.php** - -Replace the full content of `app/app/Services/Reports/ReportGeneratorRegistry.php`: - -```php - $this->dealsExport, - 'managers_summary' => $this->managersSummary, - 'sources_summary' => $this->sourcesSummary, - 'billing_summary' => $this->billingSummary, - default => throw new InvalidArgumentException("Тип отчёта не реализован: {$type}"), - }; - } - - public function formatter(string $format): ReportFormatter - { - return match ($format) { - 'csv' => $this->csv, - 'xlsx' => $this->xlsx, - 'json' => $this->json, - 'pdf' => $this->pdf, - default => throw new InvalidArgumentException("Формат не поддерживается: {$format}"), - }; - } - - public function isSupported(string $type, string $format): bool - { - if (! in_array($type, ReportJob::TYPES, true) || ! in_array($format, ReportJob::FORMATS, true)) { - return false; - } - - if (! in_array($type, self::SUPPORTED_TYPES, true)) { - return false; - } - - // PDF — stub: validates, но генерация даёт failed-job (intended). - return true; - } -} -``` - -- [ ] **Step 4: Run registry tests** - -```bash -cd app && php vendor/bin/pest tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php --no-coverage -``` - -Expected: PASS (7 tests) - -- [ ] **Step 5: Run full Reports suite (regression)** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/ tests/Unit/Services/Reports/ --no-coverage -``` - -Expected: All pass — no regressions in existing lifecycle/controller tests. - -- [ ] **Step 6: Commit** - -```bash -cd app && ./vendor/bin/pint app/Services/Reports/ReportGeneratorRegistry.php -git add app/app/Services/Reports/ReportGeneratorRegistry.php app/tests/Unit/Services/Reports/ReportGeneratorRegistryTest.php -git commit -m "feat(reports): extend ReportGeneratorRegistry — 4 providers, SUPPORTED_TYPES const" -``` - ---- - -## Task 5: S3 Abstraction (storage disk swap) - -**Files:** - -- Modify: `app/config/filesystems.php` -- Modify: `.env.example` -- Modify: `app/app/Jobs/GenerateReportJob.php` -- Modify: `app/app/Http/Controllers/Api/ReportJobController.php` -- Modify: `app/app/Console/Commands/ReportsCleanupExpired.php` -- Modify: `app/tests/Feature/Reports/ReportJobControllerTest.php` -- Modify: `app/tests/Feature/Reports/ReportLifecycleTest.php` - -No new tests — existing tests will break after the disk rename and pass after the fix. - -- [ ] **Step 1: Baseline — confirm all Reports tests green before touching anything** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/ --no-coverage -``` - -Expected: All pass. Note the test count. - -- [ ] **Step 2: Add `reports` disk to config/filesystems.php** - -In `app/config/filesystems.php`, inside the `'disks' => [` array, add the following entry after the `'public'` disk block (before `'s3'`): - -```php -'reports' => [ - 'driver' => env('REPORT_DISK', 'local'), - // driver=local: files go to storage/app/private/ (same root as 'local' disk) - // driver=s3: picks up AWS_BUCKET + AWS_* env vars automatically -], -``` - -- [ ] **Step 3: Add REPORT\_DISK to .env.example** - -Find the line `FILESYSTEM_DISK=local` in `.env.example` and add immediately after it: - -``` -REPORT_DISK=local -``` - -- [ ] **Step 4: Replace disk('local') in GenerateReportJob** - -In `app/app/Jobs/GenerateReportJob.php` at line 82, change: - -```php -Storage::disk('local')->put($relativePath, $content); -``` - -to: - -```php -Storage::disk('reports')->put($relativePath, $content); -``` - -- [ ] **Step 5: Replace disk('local') in ReportJobController::destroy()** - -In `app/app/Http/Controllers/Api/ReportJobController.php`, find inside the `destroy()` method: - -```php -Storage::disk('local')->delete($job->file_path); -``` - -Change to: - -```php -Storage::disk('reports')->delete($job->file_path); -``` - -- [ ] **Step 6: Replace disk('local') in ReportsCleanupExpired** - -In `app/app/Console/Commands/ReportsCleanupExpired.php` at line 71: - -```php -Storage::disk('local')->delete($job->file_path); -``` - -Change to: - -```php -Storage::disk('reports')->delete($job->file_path); -``` - -- [ ] **Step 7: Fix Storage::fake in test files** - -**ReportJobControllerTest.php** — in `beforeEach()`, change: - -```php -Storage::fake('local'); -``` - -to: - -```php -Storage::fake('reports'); -``` - -**ReportLifecycleTest.php** — in `beforeEach()`, change: - -```php -Storage::fake('local'); -``` - -to: - -```php -Storage::fake('reports'); -``` - -**ReportLifecycleTest.php** — in test bodies, change all `Storage::disk('local')` to `Storage::disk('reports')`. There are 5 occurrences: lines 154, 160, 193, 195, 206, 207. Do a find-replace in the file: - -- `Storage::disk('local')->put(` → `Storage::disk('reports')->put(` -- `Storage::disk('local')->assertMissing(` → `Storage::disk('reports')->assertMissing(` -- `Storage::disk('local')->assertExists(` → `Storage::disk('reports')->assertExists(` - -- [ ] **Step 8: Run Reports suite — confirm all green** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/ --no-coverage -``` - -Expected: Same count as Step 1, all pass. - -- [ ] **Step 9: Run Larastan** - -```bash -cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M -``` - -Expected: 0 errors above baseline. - -- [ ] **Step 10: Commit** - -```bash -cd app && ./vendor/bin/pint app/Jobs/GenerateReportJob.php app/Http/Controllers/Api/ReportJobController.php app/Console/Commands/ReportsCleanupExpired.php -git add app/config/filesystems.php .env.example app/app/Jobs/GenerateReportJob.php app/app/Http/Controllers/Api/ReportJobController.php app/app/Console/Commands/ReportsCleanupExpired.php app/tests/Feature/Reports/ReportJobControllerTest.php app/tests/Feature/Reports/ReportLifecycleTest.php -git commit -m "feat(reports): S3 abstraction — reports disk, REPORT_DISK env, replace disk('local')" -``` - ---- - -## Task 6: File Download Endpoint - -**Files:** - -- Modify: `app/routes/web.php` -- Modify: `app/app/Http/Controllers/Api/ReportJobController.php` -- Modify: `app/tests/Feature/Reports/ReportJobControllerTest.php` - -- [ ] **Step 1: Write the failing tests** - -Append these tests to the end of `app/tests/Feature/Reports/ReportJobControllerTest.php`: - -```php -// ------ download ------ - -test('GET /jobs/{id}/file: 401 без auth', function () { - auth()->logout(); - $this->getJson('/api/reports/jobs/1/file')->assertStatus(401); -}); - -test('GET /jobs/{id}/file: 404 unknown job', function () { - $this->getJson('/api/reports/jobs/999999/file')->assertStatus(404); -}); - -test('GET /jobs/{id}/file: 404 чужой job', function () { - $other = Tenant::factory()->create(); - $otherUser = User::factory()->create(['tenant_id' => $other->id]); - $job = makeReportJob($other->id, $otherUser->id, ['status' => ReportJob::STATUS_DONE]); - $this->getJson("/api/reports/jobs/{$job->id}/file")->assertStatus(404); -}); - -test('GET /jobs/{id}/file: 422 если status != done', function () { - $job = makeReportJob($this->tenant->id, $this->user->id, ['status' => ReportJob::STATUS_PENDING]); - $response = $this->getJson("/api/reports/jobs/{$job->id}/file"); - $response->assertStatus(422); - expect($response->json('message'))->toBe('Report not ready'); -}); - -test('GET /jobs/{id}/file: 410 если file_path null (expired)', function () { - $job = makeReportJob($this->tenant->id, $this->user->id, [ - 'status' => ReportJob::STATUS_DONE, - 'file_path' => null, - ]); - $response = $this->getJson("/api/reports/jobs/{$job->id}/file"); - $response->assertStatus(410); - expect($response->json('message'))->toBe('Report file expired'); -}); - -test('GET /jobs/{id}/file: 410 если файл не существует на диске', function () { - $job = makeReportJob($this->tenant->id, $this->user->id, [ - 'status' => ReportJob::STATUS_DONE, - 'file_path' => 'reports/99/99.csv', - ]); - // Storage::fake('reports') — файл не создан - $this->getJson("/api/reports/jobs/{$job->id}/file")->assertStatus(410); -}); - -test('GET /jobs/{id}/file: 200 CSV — Content-Disposition и Content-Type', function () { - Storage::disk('reports')->put('reports/1/42.csv', "col1,col2\nval1,val2"); - $job = makeReportJob($this->tenant->id, $this->user->id, [ - 'status' => ReportJob::STATUS_DONE, - 'file_path' => 'reports/1/42.csv', - 'file_size' => 19, - 'parameters' => ['format' => 'csv', 'date_from' => '2026-05-01', 'date_to' => '2026-05-31'], - ]); - - $response = $this->get("/api/reports/jobs/{$job->id}/file"); - - $response->assertStatus(200); - expect($response->headers->get('Content-Type'))->toContain('text/csv'); - expect($response->headers->get('Content-Disposition'))->toContain("report-{$job->id}.csv"); -}); - -test('GET /jobs/{id}/file: 200 XLSX — правильный Content-Type', function () { - Storage::disk('reports')->put('reports/1/43.xlsx', 'fake-xlsx-bytes'); - $job = makeReportJob($this->tenant->id, $this->user->id, [ - 'status' => ReportJob::STATUS_DONE, - 'file_path' => 'reports/1/43.xlsx', - 'parameters' => ['format' => 'xlsx', 'date_from' => '2026-05-01', 'date_to' => '2026-05-31'], - ]); - - $response = $this->get("/api/reports/jobs/{$job->id}/file"); - - $response->assertStatus(200); - expect($response->headers->get('Content-Type'))->toContain('spreadsheetml'); -}); -``` - -- [ ] **Step 2: Run new tests to confirm they fail** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/ReportJobControllerTest.php --filter="file" --no-coverage -``` - -Expected: FAIL — 404 or 405 (route not registered yet) - -- [ ] **Step 3: Add download route to web.php** - -In `app/routes/web.php`, inside the `Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/reports/jobs')` group, add the new route **before** the `DELETE /{id}` line: - -```php -Route::get('/{id}/file', 'App\Http\Controllers\Api\ReportJobController@download')->where('id', '[0-9]+'); -``` - -The reports group becomes: - -```php -Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/reports/jobs')->group(function () { - Route::get('/', 'App\Http\Controllers\Api\ReportJobController@index'); - Route::post('/', 'App\Http\Controllers\Api\ReportJobController@store'); - Route::get('/{id}', 'App\Http\Controllers\Api\ReportJobController@show')->where('id', '[0-9]+'); - Route::post('/{id}/retry', 'App\Http\Controllers\Api\ReportJobController@retry')->where('id', '[0-9]+'); - Route::post('/{id}/cancel', 'App\Http\Controllers\Api\ReportJobController@cancel')->where('id', '[0-9]+'); - Route::get('/{id}/file', 'App\Http\Controllers\Api\ReportJobController@download')->where('id', '[0-9]+'); - Route::delete('/{id}', 'App\Http\Controllers\Api\ReportJobController@destroy')->where('id', '[0-9]+'); -}); -``` - -- [ ] **Step 4: Add download() method and StreamedResponse import to ReportJobController** - -Add `use Symfony\Component\HttpFoundation\StreamedResponse;` to the imports section of `app/app/Http/Controllers/Api/ReportJobController.php` (after the existing `use` statements). - -Add the following method after the `show()` method and before `store()`: - -```php -/** - * GET /api/reports/jobs/{id}/file — скачать готовый файл отчёта. - */ -public function download(Request $request, int $id): StreamedResponse|JsonResponse -{ - /** @var User $user */ - $user = $request->user(); - - $job = DB::transaction(function () use ($user, $id): ?ReportJob { - DB::statement('SET LOCAL app.current_tenant_id = ' . (int) $user->tenant_id); - - return ReportJob::query() - ->where('id', $id) - ->where('tenant_id', $user->tenant_id) - ->first(); - }); - - if ($job === null) { - return response()->json(['message' => 'Отчёт не найден.'], 404); - } - - if ($job->status !== ReportJob::STATUS_DONE) { - return response()->json(['message' => 'Report not ready'], 422); - } - - if ($job->file_path === null || ! Storage::disk('reports')->exists($job->file_path)) { - return response()->json(['message' => 'Report file expired'], 410); - } - - $format = (string) ($job->parameters['format'] ?? 'csv'); - $mimeType = match ($format) { - 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', - 'json' => 'application/json', - default => 'text/csv; charset=utf-8', - }; - $contentLength = $job->file_size ?? Storage::disk('reports')->size($job->file_path); - - return response()->streamDownload(function () use ($job): void { - $stream = Storage::disk('reports')->readStream($job->file_path); - fpassthru($stream); - fclose($stream); - }, "report-{$job->id}.{$format}", [ - 'Content-Type' => $mimeType, - 'Content-Length' => (string) $contentLength, - ]); -} -``` - -- [ ] **Step 5: Run download tests** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/ReportJobControllerTest.php --filter="file" --no-coverage -``` - -Expected: PASS (8 tests) - -- [ ] **Step 6: Run full Reports suite** - -```bash -cd app && php vendor/bin/pest tests/Feature/Reports/ tests/Feature/Reports/Providers/ tests/Unit/Services/Reports/ --no-coverage -``` - -Expected: All pass (no regressions). - -- [ ] **Step 7: Run full Pest suite** - -```bash -cd app && php vendor/bin/pest --no-coverage -``` - -Expected: All pass. Count should be 403 (previous) + new tests from Tasks 1–6. - -- [ ] **Step 8: Run Larastan** - -```bash -cd app && php vendor/bin/phpstan analyse --no-progress --memory-limit=512M -``` - -Expected: 0 errors above baseline. - -- [ ] **Step 9: Commit** - -```bash -cd app && ./vendor/bin/pint app/Http/Controllers/Api/ReportJobController.php -git add app/routes/web.php app/app/Http/Controllers/Api/ReportJobController.php app/tests/Feature/Reports/ReportJobControllerTest.php -git commit -m "feat(reports): GET /api/reports/jobs/{id}/file — streaming download endpoint" -``` - ---- - -## Self-Review - -**Spec coverage:** - -- ✅ `ManagersSummaryProvider` — slug, 7 headers, DB query on deals/users/supplier\_lead\_costs (Task 1) -- ✅ `SourcesSummaryProvider` — slug, 6 headers, DB query on deals/projects/supplier\_lead\_costs (Task 2) -- ✅ `BillingSummaryProvider` — slug, 5 headers, DB query on balance\_transactions (Task 3) -- ✅ `ReportGeneratorRegistry` — 4 providers, `SUPPORTED_TYPES` const, `isSupported()` updated (Task 4) -- ✅ `reports` disk in `config/filesystems.php` with `REPORT_DISK` env var (Task 5) -- ✅ All `Storage::disk('local')` in Reports subsystem replaced with `Storage::disk('reports')` (Task 5) -- ✅ `.env.example` updated (Task 5) -- ✅ Both test files' `Storage::fake('local')` updated to `Storage::fake('reports')` (Task 5) -- ✅ `GET /api/reports/jobs/{id}/file` — 401/404/422/410/200 cases (Task 6) -- ✅ `Content-Disposition` and `Content-Type` headers set correctly (Task 6) - -**Placeholder scan:** None. All steps contain complete code. - -**Spec vs actual schema corrections applied:** - -- `deals.manager_id` (not `assigned_user_id`) -- `balance_transactions.type` (not `transaction_type`) -- `balance_transactions.balance_rub_after` (not `balance_after_rub`) -- Test dates use May 2026 (partitions start `2026-05-01`) -- `supplier_lead_costs` insert includes `received_at` (partition key, must match deal's `received_at`) diff --git a/docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md b/docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md deleted file mode 100644 index 411b0af..0000000 --- a/docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md +++ /dev/null @@ -1,2515 +0,0 @@ -# Supplier Integration — Foundation (Plan 1/5) - -> **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. -> -> **Verification is mandatory at every gate.** This plan was authored under the directive: "перепроверять на все возможные ошибки, в логике, в коде, в цикличностях, опечатки и тд". Do not skip verification gates. - -**Goal:** Расширить existing schema и Eloquent-модели Лидерры под supplier-integration data model: 3 новых таблицы (`supplier_projects`, `pricing_tiers`, `lead_charges`, `supplier_sync_log`), расширение `projects` под signal_type/identifier/SMS-поля, обновление `db/schema.sql`, RLS на tenant-scoped таблицах, factories + Pest-тесты. - -**Architecture:** Используем существующие паттерны проекта: SetTenantContext middleware + RLS, Eloquent-модели c HasFactory (+ SoftDeletes где нужно), Pest c DatabaseTransactions + явный `SET app.current_tenant_id`. Новые таблицы — два класса: tenant-scoped (с RLS) и SaaS-level (без RLS, REVOKE на crm_app_user). Миграции в `database/migrations/` отдельными файлами + sync с `db/schema.sql` (single source of truth) + запись в `db/CHANGELOG_schema.md`. - -**Tech Stack:** Laravel 13.7, PostgreSQL 16, Pest 4, Larastan, squawk, pgFormatter (existing toolchain). - -**Spec:** [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](../specs/2026-05-10-supplier-integration-design.md) — раздел §2 (Модель данных), §7 (Биллинг), §9 (Схема БД). - -**Verification philosophy:** TDD для каждой задачи (failing test first → green → commit). Verification gates после каждой группы задач. Финальный «Comprehensive Verification» в конце плана включает: Larastan, Pest, squawk, pgFormatter, cspell, markdownlint, миграция fresh, RLS-смок, dependency-cycle check, code-reviewer subagent. - ---- - -## File Structure - -### Files to create - -``` -database/migrations/ - 2026_05_10_000001_extend_projects_for_supplier_integration.php - 2026_05_10_000002_create_supplier_projects_table.php - 2026_05_10_000003_create_pricing_tiers_table.php - 2026_05_10_000004_create_lead_charges_table.php - 2026_05_10_000005_create_supplier_sync_log_table.php - -app/Models/ - SupplierProject.php - PricingTier.php - LeadCharge.php - SupplierSyncLog.php - -database/factories/ - SupplierProjectFactory.php - PricingTierFactory.php - LeadChargeFactory.php - SupplierSyncLogFactory.php - -tests/Unit/Models/ - SupplierProjectTest.php - PricingTierTest.php - LeadChargeTest.php - SupplierSyncLogTest.php - -tests/Feature/Integration/ - ProjectsSchemaExtensionTest.php - LeadChargesRlsTest.php - SupplierProjectsAccessTest.php -``` - -### Files to modify - -``` -app/Models/Project.php # add casts, relationships, signal_type accessor -database/factories/ProjectFactory.php # extend with new fields -db/schema.sql # mirror migration changes (source of truth) -db/CHANGELOG_schema.md # entry v8.12 → v8.13 -``` - -### Responsibilities (one purpose per file) - -- **Migration files** — DDL для одной концепции (extend projects / create supplier_projects / etc.). Не смешивать. -- **Models** — только Eloquent-модель: relationships, casts, scopes. Никакой бизнес-логики. -- **Factories** — генерация фейковых данных для тестов. Минимум валидных значений, без edge cases. -- **Unit tests** — модель в изоляции: casts работают, relationships определены, scope-методы корректны. -- **Integration tests** — миграция применяется чисто, RLS изолирует tenant'ы, FK работают, индексы созданы. - ---- - -## Pre-flight checks - -- [ ] **Step P1:** Verify clean working tree - -Run: `git status --short` -Expected: только untracked файлы (известные плановые), нет staged/modified в `app/`, `database/`, `db/`. - -Если есть посторонние модификации — остановись и согласуй с пользователем. - -- [ ] **Step P2:** Verify baseline tests pass - -Run (в `c:\моя\проекты\портал crm\Документация\app\`): `composer test` -Expected: `Tests: 403 passed` (или текущая baseline-цифра). - -Если падает что-то постороннее — остановись, не начинай новую работу на сломанном baseline. - -- [ ] **Step P3:** Verify Larastan baseline clean - -Run: `composer stan` -Expected: `[OK] No errors` - -Если есть ошибки — остановись. - -- [ ] **Step P4:** Snapshot текущей версии schema - -Run: `head -5 db/schema.sql` -Запиши версию (например, "v8.11") — пригодится при обновлении CHANGELOG. - ---- - -## Task 1: Migration `extend_projects_for_supplier_integration` - -**Why:** Существующий `projects` table содержит часть нужных полей (`tag`, `daily_limit_target`, `region_mask`, `delivery_days_mask`, `is_active`). Не хватает: `signal_type`, `signal_identifier`, `sms_senders`, `sms_keyword`, `delivered_in_month`, FK на supplier_projects. - -**Files:** - -- Create: `database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php` -- Create: `tests/Feature/Integration/ProjectsSchemaExtensionTest.php` - -- [ ] **Step 1.1: Write failing migration test** - -Create `tests/Feature/Integration/ProjectsSchemaExtensionTest.php`: - -```php -toBeTrue(); - - $check = DB::selectOne( - "SELECT pg_get_constraintdef(c.oid) AS def - FROM pg_constraint c - JOIN pg_class t ON c.conrelid = t.oid - WHERE t.relname = 'projects' AND c.conname LIKE '%signal_type%'" - ); - - expect($check->def)->toContain("'site'", "'call'", "'sms'"); -}); - -test('projects table has signal_identifier text column', function () { - expect(Schema::hasColumn('projects', 'signal_identifier'))->toBeTrue(); -}); - -test('projects table has sms_senders jsonb array column', function () { - expect(Schema::hasColumn('projects', 'sms_senders'))->toBeTrue(); - - $type = DB::selectOne( - "SELECT data_type FROM information_schema.columns - WHERE table_name = 'projects' AND column_name = 'sms_senders'" - ); - - expect($type->data_type)->toBe('jsonb'); -}); - -test('projects table has sms_keyword nullable text column', function () { - expect(Schema::hasColumn('projects', 'sms_keyword'))->toBeTrue(); - - $col = DB::selectOne( - "SELECT is_nullable FROM information_schema.columns - WHERE table_name = 'projects' AND column_name = 'sms_keyword'" - ); - - expect($col->is_nullable)->toBe('YES'); -}); - -test('projects table has delivered_in_month integer counter', function () { - expect(Schema::hasColumn('projects', 'delivered_in_month'))->toBeTrue(); -}); - -test('projects table has supplier_b1_project_id, b2, b3 nullable FK columns', function () { - foreach (['supplier_b1_project_id', 'supplier_b2_project_id', 'supplier_b3_project_id'] as $col) { - expect(Schema::hasColumn('projects', $col))->toBeTrue(); - } -}); - -test('signal_type sms requires sms_senders non-empty (CHECK constraint)', function () { - $check = DB::selectOne( - "SELECT pg_get_constraintdef(c.oid) AS def - FROM pg_constraint c - JOIN pg_class t ON c.conrelid = t.oid - WHERE t.relname = 'projects' AND c.conname = 'projects_sms_senders_required_for_sms'" - ); - - expect($check)->not->toBeNull(); - expect($check->def)->toContain('signal_type'); -}); -``` - -- [ ] **Step 1.2: Run test — verify it fails** - -Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/ProjectsSchemaExtensionTest.php -v` -Expected: 7 failures (columns missing). - -- [ ] **Step 1.3: Write the migration** - -Create `database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php`: - -```php -string('signal_type', 16)->nullable()->after('type'); - $table->text('signal_identifier')->nullable()->after('signal_type'); - $table->jsonb('sms_senders')->nullable()->after('signal_identifier'); - $table->text('sms_keyword')->nullable()->after('sms_senders'); - $table->unsignedInteger('delivered_in_month')->default(0)->after('effective_daily_limit_today'); - $table->unsignedBigInteger('supplier_b1_project_id')->nullable()->after('delivered_in_month'); - $table->unsignedBigInteger('supplier_b2_project_id')->nullable()->after('supplier_b1_project_id'); - $table->unsignedBigInteger('supplier_b3_project_id')->nullable()->after('supplier_b2_project_id'); - }); - - DB::statement(" - ALTER TABLE projects - ADD CONSTRAINT projects_signal_type_check - CHECK (signal_type IS NULL OR signal_type IN ('site','call','sms')) - "); - - DB::statement(" - ALTER TABLE projects - ADD CONSTRAINT projects_sms_senders_required_for_sms - CHECK ( - signal_type <> 'sms' - OR (sms_senders IS NOT NULL AND jsonb_typeof(sms_senders) = 'array' AND jsonb_array_length(sms_senders) > 0) - ) - "); - - DB::statement(" - ALTER TABLE projects - ADD CONSTRAINT projects_signal_identifier_required_for_site_call - CHECK ( - signal_type NOT IN ('site','call') - OR (signal_identifier IS NOT NULL AND length(trim(signal_identifier)) > 0) - ) - "); - - Schema::table('projects', function (Blueprint $table) { - $table->index(['tenant_id', 'signal_type', 'signal_identifier'], 'idx_projects_tenant_signal'); - }); - } - - public function down(): void - { - Schema::table('projects', function (Blueprint $table) { - $table->dropIndex('idx_projects_tenant_signal'); - }); - - DB::statement('ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_signal_identifier_required_for_site_call'); - DB::statement('ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_sms_senders_required_for_sms'); - DB::statement('ALTER TABLE projects DROP CONSTRAINT IF EXISTS projects_signal_type_check'); - - Schema::table('projects', function (Blueprint $table) { - $table->dropColumn([ - 'supplier_b3_project_id', - 'supplier_b2_project_id', - 'supplier_b1_project_id', - 'delivered_in_month', - 'sms_keyword', - 'sms_senders', - 'signal_identifier', - 'signal_type', - ]); - }); - } -}; -``` - -- [ ] **Step 1.4: Lint migration with squawk** - -Run: `cd app && npx --yes squawk database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php` (или конкретная команда из существующего lefthook.yml — посмотри `lefthook.yml` jobs). - -Expected: `0 issues`. - -Если squawk ругается на ALTER TABLE без `IF NOT EXISTS` или на missing index — поправь. - -- [ ] **Step 1.5: Apply migration** - -Run: `cd app && php artisan migrate` -Expected: `Migrating: 2026_05_10_000001_extend_projects_for_supplier_integration ... DONE` - -- [ ] **Step 1.6: Run tests — verify pass** - -Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/ProjectsSchemaExtensionTest.php -v` -Expected: 7 passed. - -- [ ] **Step 1.7: Verify rollback works** - -Run: `cd app && php artisan migrate:rollback --step=1` -Expected: rollback DONE; columns/constraints/index dropped. - -Run: `cd app && php artisan migrate` -Expected: re-applied DONE. - -- [ ] **Step 1.8: Sync `db/schema.sql`** - -Open `db/schema.sql`, найди блок `CREATE TABLE projects (...)`, добавь новые колонки и CHECK-constraints в том же порядке что в миграции. Добавь индекс. Не забудь обновить версию в шапке (например, v8.11 → v8.12). - -Run: `cd app && composer format:sql:check` (или альтернативная команда из существующих скриптов) — pgFormatter не должен ругаться. - -- [ ] **Step 1.9: Update `db/CHANGELOG_schema.md`** - -Добавь запись в начало: - -```markdown -## v8.12 — 2026-05-10 - -- `projects` extended for supplier integration: +signal_type (enum site/call/sms), +signal_identifier (text), +sms_senders (jsonb array), +sms_keyword (nullable text), +delivered_in_month (uint), +supplier_b{1,2,3}_project_id (nullable FK placeholder). 3 CHECK constraints + 1 composite index. -- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §2.1 -``` - -- [ ] **Step 1.10: Commit** - -Run: - -```bash -cd "c:/моя/проекты/портал crm/Документация" -git add app/database/migrations/2026_05_10_000001_extend_projects_for_supplier_integration.php \ - app/tests/Feature/Integration/ProjectsSchemaExtensionTest.php \ - db/schema.sql \ - db/CHANGELOG_schema.md -git commit -m "feat(db): extend projects for supplier integration (signal_type, identifier, sms_senders, sms_keyword, delivered_in_month, b1/b2/b3 FK placeholders)" -``` - ---- - -## Task 2: Migration `create_supplier_projects_table` - -**Why:** SaaS-level агрегатная сущность — отражает что именно у поставщика создано/синхронизировано. Не tenant-scoped (несколько клиентов Лидерры разделяют один supplier_project — sharing model). - -**Files:** - -- Create: `database/migrations/2026_05_10_000002_create_supplier_projects_table.php` -- Create: `tests/Feature/Integration/SupplierProjectsAccessTest.php` - -- [ ] **Step 2.1: Write failing structure test** - -Create `tests/Feature/Integration/SupplierProjectsAccessTest.php`: - -```php -toBeTrue(); - - foreach ([ - 'id', 'platform', 'signal_type', 'unique_key', 'supplier_external_id', - 'current_limit', 'current_workdays', 'current_regions', - 'sync_status', 'last_synced_at', 'inactive_since', - 'created_at', 'updated_at', - ] as $col) { - expect(Schema::hasColumn('supplier_projects', $col)) - ->toBeTrue("column {$col} missing"); - } -}); - -test('supplier_projects has unique constraint on (platform, unique_key)', function () { - $idx = DB::selectOne( - "SELECT indexdef FROM pg_indexes - WHERE tablename = 'supplier_projects' AND indexname = 'supplier_projects_platform_unique_key_unique'" - ); - expect($idx)->not->toBeNull(); - expect($idx->indexdef)->toContain('UNIQUE'); -}); - -test('supplier_projects platform check constraint allows only B1, B2, B3', function () { - $check = DB::selectOne( - "SELECT pg_get_constraintdef(c.oid) AS def - FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid - WHERE t.relname = 'supplier_projects' AND c.conname = 'supplier_projects_platform_check'" - ); - expect($check->def)->toContain("'B1'", "'B2'", "'B3'"); -}); - -test('supplier_projects sync_status check constraint', function () { - $check = DB::selectOne( - "SELECT pg_get_constraintdef(c.oid) AS def - FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid - WHERE t.relname = 'supplier_projects' AND c.conname = 'supplier_projects_sync_status_check'" - ); - expect($check->def)->toContain("'pending'", "'ok'", "'failed'"); -}); - -test('supplier_projects has NO RLS policy (SaaS-level)', function () { - $rls = DB::selectOne( - "SELECT relrowsecurity FROM pg_class WHERE relname = 'supplier_projects'" - ); - expect((bool) $rls->relrowsecurity)->toBeFalse(); -}); - -test('supplier_projects has REVOKE ALL on crm_app_user', function () { - $grants = DB::select( - "SELECT privilege_type FROM information_schema.role_table_grants - WHERE table_name = 'supplier_projects' AND grantee = 'crm_app_user'" - ); - - expect($grants)->toBeArray(); - expect($grants)->toBeEmpty('crm_app_user must have no direct privileges on supplier_projects'); -}); -``` - -- [ ] **Step 2.2: Run test — verify it fails** - -Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/SupplierProjectsAccessTest.php -v` -Expected: 6 failures. - -- [ ] **Step 2.3: Write migration** - -Create `database/migrations/2026_05_10_000002_create_supplier_projects_table.php`: - -```php -id(); - $table->string('platform', 4); - $table->string('signal_type', 16); - $table->text('unique_key'); - $table->string('supplier_external_id', 64)->nullable(); - $table->unsignedInteger('current_limit')->default(0); - $table->jsonb('current_workdays')->nullable(); - $table->jsonb('current_regions')->nullable(); - $table->string('sync_status', 16)->default('pending'); - $table->timestamp('last_synced_at')->nullable(); - $table->timestamp('inactive_since')->nullable(); - $table->timestamps(); - - $table->unique(['platform', 'unique_key'], 'supplier_projects_platform_unique_key_unique'); - $table->index('sync_status'); - $table->index('inactive_since'); - }); - - DB::statement(" - ALTER TABLE supplier_projects - ADD CONSTRAINT supplier_projects_platform_check - CHECK (platform IN ('B1','B2','B3')) - "); - - DB::statement(" - ALTER TABLE supplier_projects - ADD CONSTRAINT supplier_projects_signal_type_check - CHECK (signal_type IN ('site','call','sms')) - "); - - DB::statement(" - ALTER TABLE supplier_projects - ADD CONSTRAINT supplier_projects_sync_status_check - CHECK (sync_status IN ('pending','ok','failed')) - "); - - DB::statement(" - ALTER TABLE supplier_projects - ADD CONSTRAINT supplier_projects_b1_not_for_sms - CHECK (NOT (platform = 'B1' AND signal_type = 'sms')) - "); - - DB::statement('REVOKE ALL ON TABLE supplier_projects FROM crm_app_user'); - } - - public function down(): void - { - Schema::dropIfExists('supplier_projects'); - } -}; -``` - -- [ ] **Step 2.4: Lint with squawk** - -Run: `cd app && npx --yes squawk database/migrations/2026_05_10_000002_create_supplier_projects_table.php` -Expected: 0 issues. (Возможно warning на missing CONCURRENTLY index — для пустой таблицы при create это false-positive, можно add to .squawkrc.) - -- [ ] **Step 2.5: Apply and run tests** - -Run: - -```bash -cd app -php artisan migrate -./vendor/bin/pest tests/Feature/Integration/SupplierProjectsAccessTest.php -v -``` - -Expected: migration done, 6 tests passed. - -- [ ] **Step 2.6: Verify rollback** - -Run: `cd app && php artisan migrate:rollback --step=1 && php artisan migrate` -Expected: оба шага DONE. - -- [ ] **Step 2.7: Sync `db/schema.sql` + CHANGELOG** - -Add `CREATE TABLE supplier_projects (...)` block в schema.sql после блока `projects`. Bump version in header to v8.13. Update CHANGELOG. - -- [ ] **Step 2.8: Commit** - -```bash -git add app/database/migrations/2026_05_10_000002_create_supplier_projects_table.php \ - app/tests/Feature/Integration/SupplierProjectsAccessTest.php \ - db/schema.sql db/CHANGELOG_schema.md -git commit -m "feat(db): create supplier_projects table (SaaS-level aggregate, B1/B2/B3 platforms)" -``` - ---- - -## Task 3: Migration `create_pricing_tiers_table` - -**Why:** §7 спеки — 7-ступенчатый объёмный тариф, конфигурируемый админом Лидерры. SaaS-level (один на всю Лидерру; per-tenant override — out of scope для MVP). - -**Files:** - -- Create: `database/migrations/2026_05_10_000003_create_pricing_tiers_table.php` - -- [ ] **Step 3.1: Write failing test (in `LeadChargesRlsTest.php` namespace, секция pricing_tiers)** - -Дополни новый файл `tests/Feature/Integration/PricingTiersTest.php`: - -```php -toBeTrue(); - - foreach (['id', 'tier_no', 'leads_in_tier', 'price_per_lead_kopecks', 'is_active', 'effective_from', 'created_at', 'updated_at'] as $col) { - expect(Schema::hasColumn('pricing_tiers', $col))->toBeTrue("column {$col} missing"); - } -}); - -test('pricing_tiers tier_no constrained to 1..7', function () { - $check = DB::selectOne( - "SELECT pg_get_constraintdef(c.oid) AS def - FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid - WHERE t.relname = 'pricing_tiers' AND c.conname = 'pricing_tiers_tier_no_check'" - ); - expect($check->def)->toContain('1', '7'); -}); - -test('pricing_tiers has unique on (tier_no, effective_from) for active rows', function () { - $idx = DB::selectOne( - "SELECT indexdef FROM pg_indexes - WHERE tablename = 'pricing_tiers' AND indexname = 'pricing_tiers_tier_effective_unique'" - ); - expect($idx)->not->toBeNull(); -}); - -test('pricing_tiers price stored in kopecks (integer, not float)', function () { - $col = DB::selectOne( - "SELECT data_type FROM information_schema.columns - WHERE table_name = 'pricing_tiers' AND column_name = 'price_per_lead_kopecks'" - ); - expect($col->data_type)->toBe('integer'); -}); -``` - -- [ ] **Step 3.2: Run test — verify fail** - -Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/PricingTiersTest.php -v` -Expected: 4 failures. - -- [ ] **Step 3.3: Write migration** - -Create `database/migrations/2026_05_10_000003_create_pricing_tiers_table.php`: - -```php -id(); - $table->unsignedSmallInteger('tier_no'); - $table->unsignedInteger('leads_in_tier')->nullable(); - $table->unsignedInteger('price_per_lead_kopecks'); - $table->boolean('is_active')->default(true); - $table->date('effective_from'); - $table->timestamps(); - - $table->unique(['tier_no', 'effective_from'], 'pricing_tiers_tier_effective_unique'); - $table->index(['is_active', 'effective_from']); - }); - - DB::statement(" - ALTER TABLE pricing_tiers - ADD CONSTRAINT pricing_tiers_tier_no_check - CHECK (tier_no BETWEEN 1 AND 7) - "); - - DB::statement('REVOKE ALL ON TABLE pricing_tiers FROM crm_app_user'); - DB::statement('GRANT SELECT ON TABLE pricing_tiers TO crm_app_user'); - } - - public function down(): void - { - Schema::dropIfExists('pricing_tiers'); - } -}; -``` - -> **Why kopecks (не decimal):** избегаем floating-point округлений в money-расчётах. Формат: integer kopecks (1 руб = 100 копеек). UI converts at the edge. - -- [ ] **Step 3.4: Lint, apply, test** - -Run: - -```bash -cd app -npx --yes squawk database/migrations/2026_05_10_000003_create_pricing_tiers_table.php -php artisan migrate -./vendor/bin/pest tests/Feature/Integration/PricingTiersTest.php -v -``` - -Expected: 0 squawk issues, migration DONE, 4 tests passed. - -- [ ] **Step 3.5: Sync schema.sql + CHANGELOG, commit** - -```bash -git add app/database/migrations/2026_05_10_000003_create_pricing_tiers_table.php \ - app/tests/Feature/Integration/PricingTiersTest.php \ - db/schema.sql db/CHANGELOG_schema.md -git commit -m "feat(db): create pricing_tiers table (7-step volume billing, kopecks integer)" -``` - ---- - -## Task 4: Migration `create_lead_charges_table` - -**Why:** §7.4 — ledger списаний за каждый доставленный лид. Tenant-scoped (RLS), append-only (insert) для аудита. - -**Files:** - -- Create: `database/migrations/2026_05_10_000004_create_lead_charges_table.php` -- Create: `tests/Feature/Integration/LeadChargesRlsTest.php` - -- [ ] **Step 4.1: Write failing test** - -Create `tests/Feature/Integration/LeadChargesRlsTest.php`: - -```php -toBeTrue(); - - foreach ([ - 'id', 'tenant_id', 'deal_id', 'deal_received_at', - 'tier_no', 'price_per_lead_kopecks', 'charged_at', 'created_at', - ] as $col) { - expect(Schema::hasColumn('lead_charges', $col))->toBeTrue("column {$col} missing"); - } -}); - -test('lead_charges has FK to deals (composite id, received_at)', function () { - $fk = DB::selectOne( - "SELECT pg_get_constraintdef(c.oid) AS def - FROM pg_constraint c JOIN pg_class t ON c.conrelid = t.oid - WHERE t.relname = 'lead_charges' AND c.contype = 'f' - LIMIT 1" - ); - - expect($fk)->not->toBeNull(); - expect($fk->def)->toContain('deals', 'received_at', 'id'); -}); - -test('lead_charges enforces RLS on tenant_id', function () { - $a = Tenant::factory()->create(); - $b = Tenant::factory()->create(); - - DB::transaction(function () use ($a) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$a->id]); - - DB::table('lead_charges')->insert([ - 'tenant_id' => $a->id, - 'deal_id' => 1, - 'deal_received_at' => now(), - 'tier_no' => 1, - 'price_per_lead_kopecks' => 6000, - 'charged_at' => now(), - 'created_at' => now(), - ]); - }); - - $countA = DB::transaction(function () use ($a) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$a->id]); - return DB::table('lead_charges')->count(); - }); - - $countB = DB::transaction(function () use ($b) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$b->id]); - return DB::table('lead_charges')->count(); - }); - - expect($countA)->toBe(1); - expect($countB)->toBe(0, 'tenant B must not see tenant A charges (RLS leak)'); -}); -``` - -> **Note:** этот RLS-тест предполагает что в дальнейших задачах будет `LeadChargeFactory`. Пока INSERT через DB::table, чтобы не блокировать тест на отсутствующей фабрике. - -- [ ] **Step 4.2: Run test — verify fail** - -Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/LeadChargesRlsTest.php -v` -Expected: 3 failures. - -- [ ] **Step 4.3: Write migration** - -Create `database/migrations/2026_05_10_000004_create_lead_charges_table.php`: - -```php -id(); - $table->unsignedBigInteger('tenant_id'); - $table->unsignedBigInteger('deal_id'); - $table->timestamp('deal_received_at'); - $table->unsignedSmallInteger('tier_no'); - $table->unsignedInteger('price_per_lead_kopecks'); - $table->timestamp('charged_at'); - $table->timestamp('created_at')->useCurrent(); - - $table->foreign('tenant_id')->references('id')->on('tenants')->cascadeOnDelete(); - - $table->index(['tenant_id', 'charged_at']); - $table->index(['deal_id', 'deal_received_at']); - }); - - DB::statement(' - ALTER TABLE lead_charges - ADD CONSTRAINT lead_charges_deals_fk - FOREIGN KEY (deal_id, deal_received_at) - REFERENCES deals (id, received_at) - DEFERRABLE INITIALLY DEFERRED - ON DELETE CASCADE - '); - - DB::statement('ALTER TABLE lead_charges ENABLE ROW LEVEL SECURITY'); - DB::statement('ALTER TABLE lead_charges FORCE ROW LEVEL SECURITY'); - DB::statement(" - CREATE POLICY tenant_isolation ON lead_charges - USING (tenant_id = current_setting('app.current_tenant_id')::bigint) - WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::bigint) - "); - - DB::statement('GRANT SELECT, INSERT ON TABLE lead_charges TO crm_app_user'); - DB::statement('GRANT USAGE, SELECT ON SEQUENCE lead_charges_id_seq TO crm_app_user'); - } - - public function down(): void - { - Schema::dropIfExists('lead_charges'); - } -}; -``` - -- [ ] **Step 4.4: Lint, apply, test** - -Run: - -```bash -cd app -npx --yes squawk database/migrations/2026_05_10_000004_create_lead_charges_table.php -php artisan migrate -./vendor/bin/pest tests/Feature/Integration/LeadChargesRlsTest.php -v -``` - -Expected: squawk 0 issues, migration DONE, 3 tests passed. - -- [ ] **Step 4.5: Sync schema + CHANGELOG, commit** - -```bash -git add app/database/migrations/2026_05_10_000004_create_lead_charges_table.php \ - app/tests/Feature/Integration/LeadChargesRlsTest.php \ - db/schema.sql db/CHANGELOG_schema.md -git commit -m "feat(db): create lead_charges ledger (tenant-scoped RLS, FK to partitioned deals)" -``` - ---- - -## Task 5: Migration `create_supplier_sync_log_table` - -**Why:** §4.3 — лог синхронизаций для аудита/отладки. SaaS-level, append-only. - -**Files:** - -- Create: `database/migrations/2026_05_10_000005_create_supplier_sync_log_table.php` -- Create: `tests/Feature/Integration/SupplierSyncLogTest.php` - -- [ ] **Step 5.1: Write failing test** - -```php -toBeTrue(); - - foreach ([ - 'id', 'supplier_project_id', 'action', - 'request_payload', 'response_body', 'http_status', - 'error_message', 'duration_ms', 'created_at', - ] as $col) { - expect(Schema::hasColumn('supplier_sync_log', $col))->toBeTrue("column {$col} missing"); - } -}); -``` - -- [ ] **Step 5.2: Run failing test** - -`cd app && ./vendor/bin/pest tests/Feature/Integration/SupplierSyncLogTest.php -v` -Expected: 1 failure. - -- [ ] **Step 5.3: Write migration** - -```php -id(); - $table->unsignedBigInteger('supplier_project_id')->nullable(); - $table->string('action', 32); - $table->jsonb('request_payload')->nullable(); - $table->jsonb('response_body')->nullable(); - $table->unsignedSmallInteger('http_status')->nullable(); - $table->text('error_message')->nullable(); - $table->unsignedInteger('duration_ms')->nullable(); - $table->timestamp('created_at')->useCurrent(); - - $table->foreign('supplier_project_id') - ->references('id')->on('supplier_projects') - ->nullOnDelete(); - - $table->index('supplier_project_id'); - $table->index('action'); - $table->index('created_at'); - }); - - DB::statement(" - ALTER TABLE supplier_sync_log - ADD CONSTRAINT supplier_sync_log_action_check - CHECK (action IN ('create','update','delete','disable','session_refresh')) - "); - - DB::statement('REVOKE ALL ON TABLE supplier_sync_log FROM crm_app_user'); - } - - public function down(): void - { - Schema::dropIfExists('supplier_sync_log'); - } -}; -``` - -- [ ] **Step 5.4: Apply, test, commit** - -```bash -cd app -npx --yes squawk database/migrations/2026_05_10_000005_create_supplier_sync_log_table.php -php artisan migrate -./vendor/bin/pest tests/Feature/Integration/SupplierSyncLogTest.php -v -cd .. -git add app/database/migrations/2026_05_10_000005_create_supplier_sync_log_table.php \ - app/tests/Feature/Integration/SupplierSyncLogTest.php \ - db/schema.sql db/CHANGELOG_schema.md -git commit -m "feat(db): create supplier_sync_log audit table (SaaS-level, append-only)" -``` - ---- - -## ✓ Verification Gate A — DDL & migrations - -После Tasks 1–5 — обязательная проверка перед моделями. - -- [ ] **Step VA.1: Fresh migration smoke test** - -Run: - -```bash -cd app -php artisan migrate:fresh --seed -``` - -Expected: все миграции применяются с нуля без ошибок. Если что-то падает на fresh — есть ordering/dependency issue. - -- [ ] **Step VA.2: Run all integration schema tests** - -Run: `cd app && ./vendor/bin/pest tests/Feature/Integration/ -v` -Expected: все тесты passed. - -- [ ] **Step VA.3: Run full Pest baseline** - -Run: `cd app && composer test` -Expected: 403 + (новые добавленные) passed. Никаких регрессий в existing тестах. - -- [ ] **Step VA.4: Run Larastan** - -Run: `cd app && composer stan` -Expected: 0 errors. (Новых файлов мало, pure migrations — должно быть чисто.) - -- [ ] **Step VA.5: Schema diff sanity-check** - -Run: `git diff db/schema.sql | head -200` -Visually verify: все 5 изменений присутствуют, нет случайных правок не относящихся к Plan 1. - -- [ ] **Step VA.6: pgFormatter dry-run** - -Run: `cd app && composer format:sql:check` -Expected: schema.sql отформатирован. - -- [ ] **Step VA.7: Cycle/dependency check** - -Run: `composer dump-autoload -o` -Expected: 0 warnings о круговых зависимостях. - -> **Не двигайся дальше**, пока все шаги VA.1–VA.7 не прошли. Если что-то красное — фикси прямо сейчас, не накапливай. - ---- - -## Task 6: Eloquent model `SupplierProject` - -**Files:** - -- Create: `app/Models/SupplierProject.php` -- Create: `database/factories/SupplierProjectFactory.php` -- Create: `tests/Unit/Models/SupplierProjectTest.php` - -- [ ] **Step 6.1: Write failing model unit test** - -`tests/Unit/Models/SupplierProjectTest.php`: - -```php -create(); - expect($sp->id)->toBeInt()->toBeGreaterThan(0); - expect($sp->platform)->toBeIn(['B1', 'B2', 'B3']); - expect($sp->signal_type)->toBeIn(['site', 'call', 'sms']); -}); - -test('SupplierProject casts current_workdays as array', function () { - $sp = SupplierProject::factory()->create([ - 'current_workdays' => [1, 2, 3, 4, 5], - ]); - - expect($sp->fresh()->current_workdays)->toBe([1, 2, 3, 4, 5]); -}); - -test('SupplierProject casts current_regions as array', function () { - $sp = SupplierProject::factory()->create([ - 'current_regions' => ['77', '78'], - ]); - - expect($sp->fresh()->current_regions)->toBe(['77', '78']); -}); - -test('SupplierProject scopeActive returns only active rows', function () { - SupplierProject::factory()->create(['inactive_since' => null]); - SupplierProject::factory()->create(['inactive_since' => now()->subDays(10)]); - - expect(SupplierProject::active()->count())->toBe(1); -}); - -test('SupplierProject scopeStaleSince returns rows inactive longer than N days', function () { - SupplierProject::factory()->create(['inactive_since' => now()->subDays(200)]); - SupplierProject::factory()->create(['inactive_since' => now()->subDays(100)]); - SupplierProject::factory()->create(['inactive_since' => null]); - - expect(SupplierProject::staleSince(180)->count())->toBe(1); -}); -``` - -- [ ] **Step 6.2: Run failing test** - -`cd app && ./vendor/bin/pest tests/Unit/Models/SupplierProjectTest.php -v` -Expected: failures (class missing). - -- [ ] **Step 6.3: Write model** - -`app/Models/SupplierProject.php`: - -```php - 'array', - 'current_regions' => 'array', - 'current_limit' => 'integer', - 'last_synced_at' => 'datetime', - 'inactive_since' => 'datetime', - ]; - - public function scopeActive(Builder $query): Builder - { - return $query->whereNull('inactive_since'); - } - - public function scopeStaleSince(Builder $query, int $days): Builder - { - return $query->whereNotNull('inactive_since') - ->where('inactive_since', '<=', now()->subDays($days)); - } - - public function scopeForSignal(Builder $query, string $signalType, string $uniqueKey): Builder - { - return $query->where('signal_type', $signalType)->where('unique_key', $uniqueKey); - } - - protected static function newFactory(): SupplierProjectFactory - { - return SupplierProjectFactory::new(); - } -} -``` - -- [ ] **Step 6.4: Write factory** - -`database/factories/SupplierProjectFactory.php`: - -```php - - */ -class SupplierProjectFactory extends Factory -{ - protected $model = SupplierProject::class; - - public function definition(): array - { - $platform = fake()->randomElement(['B1', 'B2', 'B3']); - $signal = fake()->randomElement($platform === 'B1' ? ['site', 'call'] : ['site', 'call', 'sms']); - - return [ - 'platform' => $platform, - 'signal_type' => $signal, - 'unique_key' => fake()->unique()->domainName(), - 'supplier_external_id' => (string) fake()->numberBetween(1_000_000, 99_999_999), - 'current_limit' => 0, - 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], - 'current_regions' => null, - 'sync_status' => 'pending', - 'last_synced_at' => null, - 'inactive_since' => null, - ]; - } -} -``` - -- [ ] **Step 6.5: Run unit tests — verify pass** - -`cd app && ./vendor/bin/pest tests/Unit/Models/SupplierProjectTest.php -v` -Expected: 5 passed. - -- [ ] **Step 6.6: Pint + Larastan** - -Run: `cd app && composer pint && composer stan` -Expected: code-style clean, stan 0 errors. - -- [ ] **Step 6.7: Commit** - -```bash -git add app/app/Models/SupplierProject.php \ - app/database/factories/SupplierProjectFactory.php \ - app/tests/Unit/Models/SupplierProjectTest.php -git commit -m "feat(models): add SupplierProject Eloquent model + factory + unit tests" -``` - ---- - -## Task 7: Eloquent model `PricingTier` - -**Files:** - -- Create: `app/Models/PricingTier.php` -- Create: `database/factories/PricingTierFactory.php` -- Create: `tests/Unit/Models/PricingTierTest.php` - -- [ ] **Step 7.1: Write failing test** - -```php -create(); - expect($t->tier_no)->toBeInt()->toBeBetween(1, 7); - expect($t->price_per_lead_kopecks)->toBeInt()->toBeGreaterThan(0); -}); - -test('priceRubles accessor converts kopecks to rubles', function () { - $t = PricingTier::factory()->create(['price_per_lead_kopecks' => 6000]); - expect($t->price_rubles)->toBe(60.0); -}); - -test('scopeActive returns only is_active=true with effective_from <= today', function () { - PricingTier::factory()->create(['is_active' => true, 'effective_from' => now()->subDay(), 'tier_no' => 1]); - PricingTier::factory()->create(['is_active' => false, 'effective_from' => now()->subDay(), 'tier_no' => 2]); - PricingTier::factory()->create(['is_active' => true, 'effective_from' => now()->addDay(), 'tier_no' => 3]); - - expect(PricingTier::active()->count())->toBe(1); -}); - -test('current() returns today active tier set keyed by tier_no', function () { - foreach (range(1, 7) as $n) { - PricingTier::factory()->create([ - 'tier_no' => $n, - 'is_active' => true, - 'effective_from' => now()->subDay(), - 'leads_in_tier' => $n === 7 ? null : 100, - 'price_per_lead_kopecks' => 7000 - ($n * 500), - ]); - } - - $current = PricingTier::current(); - expect($current)->toHaveCount(7); - expect($current[1]->price_per_lead_kopecks)->toBe(6500); -}); -``` - -- [ ] **Step 7.2: Run failing test** - -`./vendor/bin/pest tests/Unit/Models/PricingTierTest.php -v` -Expected: failures. - -- [ ] **Step 7.3: Write model** - -```php - 'integer', - 'leads_in_tier' => 'integer', - 'price_per_lead_kopecks' => 'integer', - 'is_active' => 'boolean', - 'effective_from' => 'date', - ]; - - public function getPriceRublesAttribute(): float - { - return $this->price_per_lead_kopecks / 100; - } - - public function scopeActive(Builder $query): Builder - { - return $query->where('is_active', true) - ->where('effective_from', '<=', now()->toDateString()); - } - - /** - * Returns currently active tier-set keyed by tier_no (1..7). - * - * @return Collection - */ - public static function current(): Collection - { - return self::active() - ->orderBy('tier_no') - ->get() - ->keyBy('tier_no'); - } - - protected static function newFactory(): PricingTierFactory - { - return PricingTierFactory::new(); - } -} -``` - -- [ ] **Step 7.4: Write factory** - -```php - - */ -class PricingTierFactory extends Factory -{ - protected $model = PricingTier::class; - - public function definition(): array - { - return [ - 'tier_no' => fake()->numberBetween(1, 7), - 'leads_in_tier' => fake()->randomElement([300, 700, 1000, 2000, 5000, 10000, null]), - 'price_per_lead_kopecks' => fake()->numberBetween(2000, 7000), - 'is_active' => true, - 'effective_from' => now()->toDateString(), - ]; - } -} -``` - -- [ ] **Step 7.5: Run tests — verify pass** - -`./vendor/bin/pest tests/Unit/Models/PricingTierTest.php -v` -Expected: 4 passed. - -- [ ] **Step 7.6: Pint + stan + commit** - -```bash -cd app && composer pint && composer stan -cd .. -git add app/app/Models/PricingTier.php \ - app/database/factories/PricingTierFactory.php \ - app/tests/Unit/Models/PricingTierTest.php -git commit -m "feat(models): add PricingTier model with kopecks→rubles accessor + current() snapshot" -``` - ---- - -## Task 8: Eloquent model `LeadCharge` - -**Files:** - -- Create: `app/Models/LeadCharge.php` -- Create: `database/factories/LeadChargeFactory.php` -- Create: `tests/Unit/Models/LeadChargeTest.php` - -- [ ] **Step 8.1: Write failing test** - -```php -create(); - - DB::transaction(function () use ($tenant) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); - - $charge = LeadCharge::factory()->create(['tenant_id' => $tenant->id]); - - expect($charge->tier_no)->toBeInt(); - expect($charge->price_per_lead_kopecks)->toBeInt(); - }); -}); - -test('LeadCharge belongs to tenant', function () { - $tenant = Tenant::factory()->create(); - - DB::transaction(function () use ($tenant) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); - - $charge = LeadCharge::factory()->create(['tenant_id' => $tenant->id]); - - expect($charge->tenant)->toBeInstanceOf(Tenant::class); - expect($charge->tenant->id)->toBe($tenant->id); - }); -}); - -test('LeadCharge belongs to deal via composite key', function () { - $tenant = Tenant::factory()->create(); - - DB::transaction(function () use ($tenant) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); - - $deal = Deal::factory()->create(['tenant_id' => $tenant->id]); - $charge = LeadCharge::factory()->create([ - 'tenant_id' => $tenant->id, - 'deal_id' => $deal->id, - 'deal_received_at' => $deal->received_at, - ]); - - expect($charge->deal)->toBeInstanceOf(Deal::class); - expect($charge->deal->id)->toBe($deal->id); - }); -}); - -test('priceRubles accessor', function () { - $tenant = Tenant::factory()->create(); - - DB::transaction(function () use ($tenant) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); - - $charge = LeadCharge::factory()->create([ - 'tenant_id' => $tenant->id, - 'price_per_lead_kopecks' => 5500, - ]); - - expect($charge->price_rubles)->toBe(55.0); - }); -}); -``` - -- [ ] **Step 8.2: Run failing test** - -Expected: failures. - -- [ ] **Step 8.3: Write model** - -```php - 'integer', - 'price_per_lead_kopecks' => 'integer', - 'deal_received_at' => 'datetime', - 'charged_at' => 'datetime', - 'created_at' => 'datetime', - ]; - - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - - public function deal(): BelongsTo - { - return $this->belongsTo(Deal::class, 'deal_id', 'id'); - } - - public function getPriceRublesAttribute(): float - { - return $this->price_per_lead_kopecks / 100; - } - - protected static function newFactory(): LeadChargeFactory - { - return LeadChargeFactory::new(); - } -} -``` - -- [ ] **Step 8.4: Write factory** - -```php - - */ -class LeadChargeFactory extends Factory -{ - protected $model = LeadCharge::class; - - public function definition(): array - { - return [ - 'tenant_id' => Tenant::factory(), - 'deal_id' => fake()->numberBetween(1, 99999), - 'deal_received_at' => now(), - 'tier_no' => fake()->numberBetween(1, 7), - 'price_per_lead_kopecks' => fake()->numberBetween(2000, 6000), - 'charged_at' => now(), - 'created_at' => now(), - ]; - } -} -``` - -> **Note:** factory НЕ создаёт реальный Deal из-за партиционированной таблицы; тесты, которым нужен реальный FK, явно создают `Deal::factory()` отдельно. - -- [ ] **Step 8.5: Run tests, pint, stan, commit** - -```bash -cd app && ./vendor/bin/pest tests/Unit/Models/LeadChargeTest.php -v -composer pint && composer stan -cd .. -git add app/app/Models/LeadCharge.php \ - app/database/factories/LeadChargeFactory.php \ - app/tests/Unit/Models/LeadChargeTest.php -git commit -m "feat(models): add LeadCharge ledger model + factory + relations to Tenant/Deal" -``` - ---- - -## Task 9: Eloquent model `SupplierSyncLog` - -**Files:** - -- Create: `app/Models/SupplierSyncLog.php` -- Create: `database/factories/SupplierSyncLogFactory.php` -- Create: `tests/Unit/Models/SupplierSyncLogTest.php` - -- [ ] **Step 9.1: Write test** - -```php -create(); - expect($log->action)->toBeIn(['create', 'update', 'delete', 'disable', 'session_refresh']); -}); - -test('SupplierSyncLog has nullable supplier_project relation', function () { - $sp = SupplierProject::factory()->create(); - $log = SupplierSyncLog::factory()->create(['supplier_project_id' => $sp->id]); - - expect($log->supplierProject)->toBeInstanceOf(SupplierProject::class); - expect($log->supplierProject->id)->toBe($sp->id); -}); - -test('SupplierSyncLog request_payload and response_body cast as array', function () { - $log = SupplierSyncLog::factory()->create([ - 'request_payload' => ['name' => 'X'], - 'response_body' => ['status' => 'OK'], - ]); - - expect($log->fresh()->request_payload)->toBe(['name' => 'X']); - expect($log->fresh()->response_body)->toBe(['status' => 'OK']); -}); -``` - -- [ ] **Step 9.2: Run failing** - -Expected: failures. - -- [ ] **Step 9.3: Write model** - -```php - 'array', - 'response_body' => 'array', - 'http_status' => 'integer', - 'duration_ms' => 'integer', - 'created_at' => 'datetime', - ]; - - public function supplierProject(): BelongsTo - { - return $this->belongsTo(SupplierProject::class); - } - - protected static function newFactory(): SupplierSyncLogFactory - { - return SupplierSyncLogFactory::new(); - } -} -``` - -- [ ] **Step 9.4: Write factory** - -```php - - */ -class SupplierSyncLogFactory extends Factory -{ - protected $model = SupplierSyncLog::class; - - public function definition(): array - { - return [ - 'supplier_project_id' => null, - 'action' => fake()->randomElement(['create', 'update', 'delete', 'disable', 'session_refresh']), - 'request_payload' => ['stub' => true], - 'response_body' => null, - 'http_status' => 200, - 'error_message' => null, - 'duration_ms' => fake()->numberBetween(50, 5000), - 'created_at' => now(), - ]; - } -} -``` - -- [ ] **Step 9.5: Run tests + pint + stan + commit** - -```bash -cd app && ./vendor/bin/pest tests/Unit/Models/SupplierSyncLogTest.php -v -composer pint && composer stan -cd .. -git add app/app/Models/SupplierSyncLog.php \ - app/database/factories/SupplierSyncLogFactory.php \ - app/tests/Unit/Models/SupplierSyncLogTest.php -git commit -m "feat(models): add SupplierSyncLog model + factory (audit trail для AJAX-sync)" -``` - ---- - -## Task 10: Extend `Project` model - -**Why:** Существующая `Project` сейчас без полей signal_*, sms_*, delivered_in_month, supplier_b{1,2,3}_project_id. Нужно добавить fillable + casts + relationships + accessors. - -**Files:** - -- Modify: `app/Models/Project.php` -- Modify: `database/factories/ProjectFactory.php` -- Create: `tests/Unit/Models/ProjectExtensionsTest.php` - -- [ ] **Step 10.1: Write failing test** - -```php -create(); - - DB::transaction(function () use ($tenant) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); - - $p = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'signal_type' => 'site', - 'signal_identifier' => 'example.com', - ]); - - expect($p->signal_type)->toBe('site'); - expect($p->signal_identifier)->toBe('example.com'); - }); -}); - -test('Project casts sms_senders as array', function () { - $tenant = Tenant::factory()->create(); - - DB::transaction(function () use ($tenant) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); - - $p = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'signal_type' => 'sms', - 'sms_senders' => ['TINKOFF', 'SBERBANK'], - 'sms_keyword' => 'ипотека', - ]); - - expect($p->fresh()->sms_senders)->toBe(['TINKOFF', 'SBERBANK']); - expect($p->fresh()->sms_keyword)->toBe('ипотека'); - }); -}); - -test('Project has supplierB1/B2/B3 relations', function () { - $tenant = Tenant::factory()->create(); - - DB::transaction(function () use ($tenant) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); - - $sp = SupplierProject::factory()->create(['platform' => 'B1']); - $p = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'supplier_b1_project_id' => $sp->id, - ]); - - expect($p->supplierB1)->toBeInstanceOf(SupplierProject::class); - expect($p->supplierB1->id)->toBe($sp->id); - }); -}); - -test('Project scopeActiveOnDay returns projects with today in delivery_days_mask', function () { - $tenant = Tenant::factory()->create(); - - DB::transaction(function () use ($tenant) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); - - // delivery_days_mask: bit 0..6 для Пн..Вс - // Проект на все дни: - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'delivery_days_mask' => 0b1111111, - ]); - // Только выходные: - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'is_active' => true, - 'delivery_days_mask' => 0b1100000, - ]); - - $todayDow = (int) now()->dayOfWeekIso; // 1..7 - - $count = Project::activeOnDay($todayDow)->count(); - expect($count)->toBeGreaterThanOrEqual(1); - }); -}); -``` - -- [ ] **Step 10.2: Run failing test** - -Expected: failures (методы/relations отсутствуют). - -- [ ] **Step 10.3: Modify Project model** - -Открой `app/Models/Project.php` и добавь: - -В `$fillable` (если используется): - -```php -'signal_type', -'signal_identifier', -'sms_senders', -'sms_keyword', -'delivered_in_month', -'supplier_b1_project_id', -'supplier_b2_project_id', -'supplier_b3_project_id', -``` - -В `$casts`: - -```php -'sms_senders' => 'array', -'delivered_in_month' => 'integer', -``` - -Добавь методы: - -```php -use Illuminate\Database\Eloquent\Builder; -use Illuminate\Database\Eloquent\Relations\BelongsTo; - -public function supplierB1(): BelongsTo -{ - return $this->belongsTo(SupplierProject::class, 'supplier_b1_project_id'); -} - -public function supplierB2(): BelongsTo -{ - return $this->belongsTo(SupplierProject::class, 'supplier_b2_project_id'); -} - -public function supplierB3(): BelongsTo -{ - return $this->belongsTo(SupplierProject::class, 'supplier_b3_project_id'); -} - -public function scopeActiveOnDay(Builder $query, int $isoDayOfWeek): Builder -{ - $bit = 1 << ($isoDayOfWeek - 1); - return $query->where('is_active', true) - ->whereRaw('(delivery_days_mask & ?) <> 0', [$bit]); -} - -public function scopeForSignal(Builder $query, string $signalType, string $identifier): Builder -{ - return $query->where('signal_type', $signalType)->where('signal_identifier', $identifier); -} -``` - -Добавь imports `use App\Models\SupplierProject;` если ещё нет. - -- [ ] **Step 10.4: Update `ProjectFactory`** - -Добавь в `definition()`: - -```php -'signal_type' => null, -'signal_identifier' => null, -'sms_senders' => null, -'sms_keyword' => null, -'delivered_in_month' => 0, -'supplier_b1_project_id' => null, -'supplier_b2_project_id' => null, -'supplier_b3_project_id' => null, -``` - -Добавь state-метод: - -```php -public function asSiteSignal(string $domain): self -{ - return $this->state([ - 'signal_type' => 'site', - 'signal_identifier' => $domain, - ]); -} - -public function asCallSignal(string $phone): self -{ - return $this->state([ - 'signal_type' => 'call', - 'signal_identifier' => $phone, - ]); -} - -public function asSmsSignal(array $senders, ?string $keyword = null): self -{ - return $this->state([ - 'signal_type' => 'sms', - 'signal_identifier' => null, - 'sms_senders' => $senders, - 'sms_keyword' => $keyword, - ]); -} -``` - -- [ ] **Step 10.5: Run tests — pass** - -`./vendor/bin/pest tests/Unit/Models/ProjectExtensionsTest.php -v` -Expected: 4 passed. - -- [ ] **Step 10.6: Pint, stan, full Pest baseline** - -```bash -cd app -composer pint -composer stan -composer test -``` - -Expected: всё зелёное, 403+новые passed (никаких регрессий в existing тестах после правки `Project`). - -- [ ] **Step 10.7: Commit** - -```bash -git add app/app/Models/Project.php \ - app/database/factories/ProjectFactory.php \ - app/tests/Unit/Models/ProjectExtensionsTest.php -git commit -m "feat(models): extend Project with signal_type, sms_senders, supplier_b1/b2/b3 relations + scopes" -``` - ---- - -## ✓ Verification Gate B — Models & Factories - -- [ ] **Step VB.1: Run Unit/Models tests** - -`cd app && ./vendor/bin/pest tests/Unit/Models/ -v` -Expected: все passed. - -- [ ] **Step VB.2: Run full baseline** - -`cd app && composer test` -Expected: 403 baseline + новые passed, 0 регрессий. - -- [ ] **Step VB.3: Larastan** - -`cd app && composer stan` -Expected: 0 errors. Если появились ошибки на новых моделях — типы PHPDoc'ов поправь в моделях/фабриках. - -- [ ] **Step VB.4: Pint** - -`cd app && composer pint -- --test` -Expected: clean. - -- [ ] **Step VB.5: Cross-model relation sanity-check** - -Mini-script (можно добавить как отдельный test или прогнать в `php artisan tinker`): - -```php -$tenant = \App\Models\Tenant::factory()->create(); -DB::transaction(function () use ($tenant) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$tenant->id]); - $sp = \App\Models\SupplierProject::factory()->create(['platform' => 'B1']); - $p = \App\Models\Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'signal_type' => 'site', - 'signal_identifier' => 'a.com', - 'supplier_b1_project_id' => $sp->id, - ]); - dump($p->supplierB1->platform); // 'B1' -}); -``` - -Ожидание: 'B1' в выводе. - ---- - -## Task 11: SupplierProject ↔ Project linkage helper service - -**Why:** Логика «найти существующий supplier_project или создать стаб» нужна в нескольких местах (Project create, supplier sync). Чтобы избежать циклической зависимости и дублирования — выносим в Service. - -**Files:** - -- Create: `app/Services/SupplierProjects/SupplierProjectResolver.php` -- Create: `tests/Unit/Services/SupplierProjectResolverTest.php` - -- [ ] **Step 11.1: Write failing test** - -```php -create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'example.com', - ]); - - $resolver = new SupplierProjectResolver(); - $resolved = $resolver->resolveOrStub('B1', 'site', 'example.com'); - - expect($resolved->id)->toBe($existing->id); -}); - -test('resolveOrStub creates pending stub when no existing project', function () { - $resolver = new SupplierProjectResolver(); - $resolved = $resolver->resolveOrStub('B2', 'call', '79991234567'); - - expect($resolved->exists)->toBeTrue(); - expect($resolved->platform)->toBe('B2'); - expect($resolved->signal_type)->toBe('call'); - expect($resolved->unique_key)->toBe('79991234567'); - expect($resolved->sync_status)->toBe('pending'); -}); - -test('resolveOrStub returns same row on second call (no duplicates)', function () { - $resolver = new SupplierProjectResolver(); - $first = $resolver->resolveOrStub('B3', 'sms', 'TINKOFF'); - $second = $resolver->resolveOrStub('B3', 'sms', 'TINKOFF'); - - expect($first->id)->toBe($second->id); - expect(SupplierProject::where('unique_key', 'TINKOFF')->count())->toBe(1); -}); - -test('resolveOrStub throws DomainException for B1+sms (forbidden combo)', function () { - $resolver = new SupplierProjectResolver(); - expect(fn () => $resolver->resolveOrStub('B1', 'sms', 'TINKOFF')) - ->toThrow(DomainException::class); -}); - -test('resolveOrStub throws InvalidArgumentException for invalid platform', function () { - $resolver = new SupplierProjectResolver(); - expect(fn () => $resolver->resolveOrStub('B9', 'site', 'a.com')) - ->toThrow(InvalidArgumentException::class); -}); -``` - -- [ ] **Step 11.2: Run failing test** - -Expected: failures. - -- [ ] **Step 11.3: Write service** - -```php - $platform, 'unique_key' => $uniqueKey], - [ - 'signal_type' => $signalType, - 'sync_status' => 'pending', - 'current_limit' => 0, - 'current_workdays' => [1, 2, 3, 4, 5, 6, 7], - ] - ); - } -} -``` - -- [ ] **Step 11.4: Run tests, pint, stan** - -`./vendor/bin/pest tests/Unit/Services/SupplierProjectResolverTest.php -v` -Expected: 5 passed. - -`composer pint && composer stan` - -- [ ] **Step 11.5: Commit** - -```bash -git add app/app/Services/SupplierProjects/SupplierProjectResolver.php \ - app/tests/Unit/Services/SupplierProjectResolverTest.php -git commit -m "feat(services): add SupplierProjectResolver (resolveOrStub with B1+SMS guard)" -``` - ---- - -## Task 12: Validation rules — Signal validators - -**Why:** §3.1 спеки — формат валидации. Выносим в отдельные Rule-классы для переиспользования (FormRequest на следующем плане + service-level вызов). - -**Files:** - -- Create: `app/Rules/SignalIdentifier/DomainIdentifier.php` -- Create: `app/Rules/SignalIdentifier/PhoneIdentifier.php` -- Create: `app/Rules/SignalIdentifier/SmsSenderRule.php` -- Create: `tests/Unit/Rules/SignalValidatorsTest.php` - -- [ ] **Step 12.1: Write failing test** - -```php - $value], ['x' => [new DomainIdentifier()]]); - expect($v->fails())->toBeFalse("rejected valid {$value}"); -})->with('domains_valid'); - -test('DomainIdentifier rejects invalid', function (string $value) { - $v = Validator::make(['x' => $value], ['x' => [new DomainIdentifier()]]); - expect($v->fails())->toBeTrue("accepted invalid {$value}"); -})->with('domains_invalid'); - -dataset('phones_valid', [ - ['79991234567'], - ['74955551212'], -]); - -dataset('phones_invalid', [ - ['89991234567'], // not 7-prefix - ['7999123456'], // 10 digits - ['799912345678'], // 12 digits - ['+79991234567'], // plus - ['7 999 123 45 67'], // spaces -]); - -test('PhoneIdentifier accepts valid', function (string $v) { - $val = Validator::make(['x' => $v], ['x' => [new PhoneIdentifier()]]); - expect($val->fails())->toBeFalse(); -})->with('phones_valid'); - -test('PhoneIdentifier rejects invalid', function (string $v) { - $val = Validator::make(['x' => $v], ['x' => [new PhoneIdentifier()]]); - expect($val->fails())->toBeTrue(); -})->with('phones_invalid'); - -test('SmsSenderRule accepts alpha and short numeric', function () { - foreach (['TINKOFF', 'SBER', '900', '1234', 'BANK-1'] as $v) { - $r = Validator::make(['x' => $v], ['x' => [new SmsSenderRule()]]); - expect($r->fails())->toBeFalse("rejected valid {$v}"); - } -}); - -test('SmsSenderRule rejects 11-digit phone (supplier blocks it)', function () { - foreach (['79991234567', '12345678901'] as $v) { - $r = Validator::make(['x' => $v], ['x' => [new SmsSenderRule()]]); - expect($r->fails())->toBeTrue("accepted invalid {$v}"); - } -}); - -test('SmsSenderRule rejects too long', function () { - $longString = str_repeat('A', 31); - $r = Validator::make(['x' => $longString], ['x' => [new SmsSenderRule()]]); - expect($r->fails())->toBeTrue(); -}); -``` - -- [ ] **Step 12.2: Run failing** - -Expected: failures. - -- [ ] **Step 12.3: Write rules** - -`app/Rules/SignalIdentifier/DomainIdentifier.php`: - -```php - 30) { - $fail('SMS sender must be 1–30 characters.'); - return; - } - - if (!preg_match('/^[A-Za-z0-9_-]+$/', $value)) { - $fail('SMS sender must contain only letters, digits, underscore or hyphen.'); - return; - } - - // Reject 11-digit phone numbers (supplier blocks them) - if (preg_match('/^\d{11}$/', $value)) { - $fail('SMS sender cannot be an 11-digit phone number (supplier rejection).'); - } - } -} -``` - -- [ ] **Step 12.4: Run tests + pint + stan** - -```bash -cd app -./vendor/bin/pest tests/Unit/Rules/SignalValidatorsTest.php -v -composer pint && composer stan -``` - -Expected: все тесты passed, 0 stan errors. - -- [ ] **Step 12.5: Commit** - -```bash -git add app/app/Rules/SignalIdentifier/DomainIdentifier.php \ - app/app/Rules/SignalIdentifier/PhoneIdentifier.php \ - app/app/Rules/SignalIdentifier/SmsSenderRule.php \ - app/tests/Unit/Rules/SignalValidatorsTest.php -git commit -m "feat(rules): add Signal validators (Domain, Phone, SmsSender) with comprehensive datasets" -``` - ---- - -## ✓ Comprehensive Verification Gate (END OF PLAN 1) - -> Это финальный gate. Без зелёного прохода всех 14 шагов — план НЕ закрыт. - -- [ ] **Step CV.1: Fresh migration from scratch** - -```bash -cd app -php artisan migrate:fresh -``` - -Expected: все миграции с нуля прошли. - -- [ ] **Step CV.2: Full Pest baseline** - -`cd app && composer test` -Expected: ВСЕ существующие 403 + добавленные тесты passed. **0 регрессий.** - -- [ ] **Step CV.3: Larastan full** - -`cd app && composer stan` -Expected: 0 errors. - -- [ ] **Step CV.4: Pint full project** - -`cd app && composer pint -- --test` -Expected: clean. - -- [ ] **Step CV.5: squawk на все новые миграции** - -```bash -cd app -for m in database/migrations/2026_05_10_*.php; do - npx --yes squawk "$m" || echo "❌ $m" -done -``` - -Expected: 0 issues per migration. - -- [ ] **Step CV.6: pgFormatter dry-run** - -`cd app && composer format:sql:check` -Expected: clean. - -- [ ] **Step CV.7: cspell на все .md изменения** - -`npm run spell` (в корне репозитория) -Expected: 0 unknown words. Если есть — добавить в `cspell-words.txt` (новые валидные термины), либо исправить опечатки. - -- [ ] **Step CV.8: markdownlint** - -`npm run lint:md` -Expected: 0 errors. - -- [ ] **Step CV.9: Cycle check** - -`cd app && composer dump-autoload -o` -Expected: 0 warnings (особенно про circular references между models/services). - -- [ ] **Step CV.10: Schema diff manual review** - -`git diff db/schema.sql` -Verify (visual): - -- 5 новых тиблов/расширений присутствуют (3 CREATE TABLE + 1 ALTER TABLE projects + не забыли ничего ещё) -- Все CHECK-constraints читаемы, без опечаток имён колонок -- RLS включён ровно на `lead_charges` -- REVOKE для SaaS-таблиц -- Версия в шапке схемы bumped (v8.11 → v8.13) - -- [ ] **Step CV.11: Relations sanity in tinker** - -```bash -cd app && php artisan tinker -``` - -В тинкере: - -```php -use App\Models\{Tenant, Project, SupplierProject, LeadCharge, PricingTier, SupplierSyncLog}; -$t = Tenant::factory()->create(); -DB::transaction(function () use ($t) { - DB::statement('SET LOCAL app.current_tenant_id = ?', [$t->id]); - $sp = SupplierProject::factory()->create(['platform' => 'B1']); - $p = Project::factory()->create(['tenant_id' => $t->id, 'supplier_b1_project_id' => $sp->id]); - echo $p->supplierB1->platform . "\n"; // B1 - echo $p->signal_type ?? 'null' . "\n"; // null (default) - $pt = PricingTier::factory()->create(); - echo $pt->price_rubles . "\n"; // float - $log = SupplierSyncLog::factory()->create(['supplier_project_id' => $sp->id]); - echo $log->supplierProject->id === $sp->id ? "log->sp OK\n" : "FAIL\n"; -}); -``` - -Expected: все строки выводятся корректно. - -- [ ] **Step CV.12: Code review subagent** - -Dispatch subagent: - -``` -Agent({ - description: "Code review supplier foundation", - subagent_type: "general-purpose", - prompt: "Code-review changes in commits since `` on branch in c:\моя\проекты\портал crm\Документация\. - - Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md (sections §2, §7) - Plan: docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md - - Verify: - 1. Migrations — наличие всех CHECK constraints из спеки (B1+SMS forbidden, sms_senders required if signal_type=sms, etc.) - 2. Models — фабрики не создают forbidden combos (B1+SMS), relationships определены, casts соответствуют DDL - 3. Validators — datasets покрывают всё что в §3.1 спеки (домен/номер/sms-sender) - 4. RLS — lead_charges ENABLE+FORCE+POLICY+GRANT, supplier-side таблицы REVOKE - 5. SQL safety — squawk-relevant: нет dangerous DDL без CONCURRENTLY, нет default values for added columns в больших таблицах (projects может быть large) - 6. Опечатки — особенно в SQL identifiers/CHECK constraint names - - Report: что найдено, чего не хватает, и какие потенциальные баги. Под 400 слов." -}) -``` - -После review — поправить найденное, повторить gate. - -- [ ] **Step CV.13: Update Tooling reference + project state memory** - -Если в этом плане появились новые npm/composer пакеты — обновить `docs/Tooling_v8_3.md`. Если изменились квирки окружения — добавить в `memory/feedback_environment.md`. - -В этом плане новых пакетов нет — пропускай шаг. - -- [ ] **Step CV.14: Final commit (CHANGELOG + plan checkbox closure)** - -Если все шаги CV.1–CV.13 зелёные: - -```bash -cd "c:/моя/проекты/портал crm/Документация" -# Mark plan as complete in its header (Status field) если есть -git add docs/superpowers/plans/2026-05-10-supplier-foundation-plan.md -git commit -m "docs(plans): close Plan 1 — supplier integration foundation (schema + models + validators)" -git push origin main # или branch если в worktree -``` - ---- - -## Definition of Done (Plan 1) - -- ✅ 5 миграций применяются на чистую БД, откатываются обратно -- ✅ 4 новые модели с factories, accessors, scopes -- ✅ Project model расширен, существующие тесты не сломаны -- ✅ 3 валидатора (Domain/Phone/SmsSender) с dataset-тестами на edge cases -- ✅ Сервис `SupplierProjectResolver` с B1+SMS guard -- ✅ RLS на `lead_charges` работает (cross-tenant isolation тест) -- ✅ db/schema.sql + CHANGELOG_schema.md синхронизированы -- ✅ 0 регрессий: 403 baseline + новые passed -- ✅ Larastan / Pint / squawk / pgFormatter / cspell / markdownlint — все чистые -- ✅ Code review subagent прошёл без блокеров - -После закрытия Plan 1 — переходим к Plan 2 (Webhook + Sharing Routing). - ---- - -## Notes & quirks - -- **kopecks vs decimal:** все money-поля integer kopecks. Float — только в accessors на чтение. -- **Composite FK:** `lead_charges.deal_id, deal_received_at` → `deals(id, received_at)` через DEFERRABLE INITIALLY DEFERRED — потому что при INSERT deal+charge в одной транзакции composite FK иначе не пройдёт. -- **Existing Project поля:** `tag`, `daily_limit_target`, `region_mask`, `delivery_days_mask`, `is_active`, `effective_daily_limit_today` уже есть. Не дублируем — переиспользуем. `region_mask` — это битовая маска (uint64), не jsonb. Соответствие "regions из спеки" обеспечит mapper в Plan 2. -- **delivery_days_mask:** битовая маска bit 0 = понедельник, bit 6 = воскресенье (ISO day-of-week минус 1). `scopeActiveOnDay($iso)` использует `1 << ($iso - 1)`. -- **Существующий webhook payload:** в Plan 2 Будем парсить `B1_vashinvestor.ru` → platform=B1, identifier=vashinvestor.ru. Адаптация существующего `ProcessWebhookJob`. -- **`SetTenantContext` middleware:** применяется автоматически на роутах под `tenant` alias. Тесты с RLS — внутри `DB::transaction()` с явным `SET LOCAL`. - ---- - -## Self-review checklist (executed by author at write-time) - -- [x] **Spec coverage:** §2.1 (Project fields) — Task 1+10. §2.2 (supplier_projects) — Task 2+6. §7 (billing) — Task 3+8 (pricing_tiers + lead_charges). §3.1 (validators) — Task 12. §4.3 (sync_log) — Task 5+9. ✓ -- [x] **Placeholder scan:** нет TBD/TODO/«implement later» в коде, везде указаны точные пути и команды. ✓ -- [x] **Type consistency:** `signal_type` enum 'site'/'call'/'sms' одинаков в DDL CHECK + ProjectFactory + Validators + Resolver. `platform` enum 'B1'/'B2'/'B3' одинаков везде. `tier_no` integer 1..7 одинаков. ✓ diff --git a/docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md b/docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md deleted file mode 100644 index 2581096..0000000 --- a/docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md +++ /dev/null @@ -1,808 +0,0 @@ -# Supplier Integration — Plan 2.6 cleanup CV.11 audit остатков - -> **✅ Plan completed (10.05.2026 поздняя ночь финал).** Все 4 fix'а закрыты атомарными commit'ами на main: `e71a02e` (#i deploy validator), `f78a855` (#ii IP allowlist production fail-closed), `451a294` (#iii timestamp partition guard ±24h), `7899071` (#iv crm_supplier_worker BYPASSRLS-роль). Pest 558/556 (+9 от Plan 2.5 baseline 549/547), Larastan + Pint + squawk clean. Memory updated (`project_supplier_integration.md`, `project_state.md`, `feedback_environment.md` quirk #57). Plan 3 backlog: BLOCKER #6 (RLS на `failed_webhook_jobs`) первая задача + 5 minor WARN + 5 NIT. -> -> **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:** Закрыть 4 production-impact остатка CV.11 audit Plan 2: 2 operational deploy gates (placeholder secret + empty IP allowlist в production) + 1 code-bug (Carbon timestamp partition crash) + 1 архитектурный (BYPASSRLS-роль для queue worker — вариант C из Plan 2.6 brainstorm). - -**Architecture:** Все 4 fix'а — атомарные independent изменения, каждый закрыт отдельным commit'ом. - -- **Fix #i (placeholder secret):** новая Console-команда `supplier:check-webhook-secret` для deploy-time gate, exit 1 если seed = `'__SET_ON_DEPLOY__'` или len < 32. -- **Fix #ii (empty IP allowlist):** в `SupplierWebhookController::verifyIpAllowlist` — `if ($list === []) return !app()->environment('production')` — production fail-closed, dev/testing fail-open. -- **Fix #iii (timestamp partition guard):** в `SupplierWebhookController::receive` — `'time' => 'integer|min:<24h_ago>|max:<24h_future>'`; защита от старого/будущего timestamp → `no partition for row` CRASH. -- **Fix #iv (RLS под crm_app_user):** новая PG-роль `crm_supplier_worker` (BYPASSRLS) в `db/00_create_roles.sql` для queue worker; обновить inline-warnings в `LeadRouter.php` + `ResetDeliveredTodayCommand.php`; зафиксировать в memory. - -**Tech Stack:** Laravel 13.7 (Console + Validator + AppEnvironment), PostgreSQL 16 (CREATE ROLE + GRANT), Pest 4, Larastan, Pint. - -**Spec:** [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](../specs/2026-05-10-supplier-integration-design.md). - -**Plan основан на:** CV.11 code-reviewer subagent audit Plan 2 (10.05.2026 поздняя ночь) — 6 BLOCKER findings из которых #2 + #3 закрыты Plan 2.5 (`1ba1df8` + `c1ae195`); #6 → Plan 3 первая задача; #4/#5 + 8 minor WARN + 5 NIT — частично здесь, частично в Plan 3 NOTES. Полные findings — `memory/project_supplier_integration.md` секция «Pending CV.11 audit findings → Plan 3 backlog». - ---- - -## File Map - -**New files:** - -- `app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php` -- `app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php` - -**Modified files:** - -- `app/app/Http/Controllers/Api/SupplierWebhookController.php` — Fix #ii (verifyIpAllowlist) + Fix #iii (timestamp validation) -- `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` — TDD-тесты для Fix #ii + Fix #iii -- `db/schema.sql` — без изменений (Fix #iv не требует bump схемы; роль создаётся через `db/00_create_roles.sql`, не через `load_initial_schema.php`) -- `db/00_create_roles.sql` — Fix #iv: добавить `crm_supplier_worker` (BYPASSRLS) -- `app/app/Services/LeadRouter.php` — Fix #iv: обновить inline-warning (ссылка на `crm_supplier_worker`) -- `app/app/Console/Commands/ResetDeliveredTodayCommand.php` — Fix #iv: обновить inline-warning -- `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md` — статус-header после Task 5 - -**Memory updates (вне репо, в `~/.claude/projects/.../memory/`):** - -- `feedback_environment.md` — новый quirk про deploy queue worker под `crm_supplier_worker` -- `project_supplier_integration.md` — Plan 2.6 closure секция -- `project_state.md` — header (HEAD origin/main, Pest count) - ---- - -## Task 1: Fix #i — `supplier:check-webhook-secret` deploy validator (WARN #4) - -**Files:** - -- Create: `app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php` -- Create: `app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php` - -**Контекст:** Seed в `db/schema.sql:2582` — `supplier_webhook_secret = '__SET_ON_DEPLOY__'` (17 chars). `SupplierWebhookController::verifySecret` (line 96-98) явно блокирует placeholder OR len < 32 → 404. На production без deploy-time override — endpoint нерабочий silently. Нужен fail-fast deploy-gate. - -- [ ] **Step 1: Failing test (4 кейса в одном файле)** - -Создать `app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php`: - -```php -updateOrInsert( - ['key' => 'supplier_webhook_secret'], - ['value' => '__SET_ON_DEPLOY__', 'value_type' => 'string', 'description' => 'test seed'] - ); - - $exitCode = artisan('supplier:check-webhook-secret')->run(); - - expect($exitCode)->toBe(1); -}); - -it('rejects too-short secret (< 32 chars)', function (): void { - DB::table('system_settings') - ->updateOrInsert( - ['key' => 'supplier_webhook_secret'], - ['value' => 'short-secret-only-20-chars', 'value_type' => 'string', 'description' => 'test'] - ); - - $exitCode = artisan('supplier:check-webhook-secret')->run(); - - expect($exitCode)->toBe(1); -}); - -it('rejects missing seed', function (): void { - DB::table('system_settings')->where('key', 'supplier_webhook_secret')->delete(); - - $exitCode = artisan('supplier:check-webhook-secret')->run(); - - expect($exitCode)->toBe(1); -}); - -it('accepts valid secret (≥32 chars and not placeholder)', function (): void { - DB::table('system_settings') - ->updateOrInsert( - ['key' => 'supplier_webhook_secret'], - ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed'] - ); - - $exitCode = artisan('supplier:check-webhook-secret')->run(); - - expect($exitCode)->toBe(0); -}); -``` - -NB: `artisan()` — Pest helper для PestPhp\Plugins\Laravel; alternative — `\Illuminate\Support\Facades\Artisan::call('supplier:check-webhook-secret')`. Проверить какой используется в проекте — посмотреть существующий `tests/Feature/Console/ResetDeliveredTodayCommandTest.php` и взять оттуда паттерн. - -- [ ] **Step 2: Run test to verify it fails** - -```powershell -./vendor/bin/pest tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php -``` - -Expected: 4 failed (Command not found / "There are no commands defined in the 'supplier' namespace"). - -- [ ] **Step 3: Implement `CheckSupplierWebhookSecretCommand`** - -Создать `app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php`: - -```php -= 32 chars (требование verifySecret в SupplierWebhookController). - */ -class CheckSupplierWebhookSecretCommand extends Command -{ - protected $signature = 'supplier:check-webhook-secret'; - - protected $description = 'Deploy-time validator: проверка supplier_webhook_secret seed (Plan 2.6 fix #i)'; - - public function handle(): int - { - $row = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first(); - - if ($row === null) { - $this->error('FAIL: system_settings row для key=supplier_webhook_secret не найдена. Schema seed повреждён или БД не мигрирована.'); - - return self::FAILURE; - } - - $value = (string) $row->value; - - if ($value === '__SET_ON_DEPLOY__') { - $this->error('FAIL: supplier_webhook_secret = "__SET_ON_DEPLOY__" (placeholder из schema seed). Override через UPDATE system_settings перед deploy.'); - - return self::FAILURE; - } - - if (strlen($value) < 32) { - $this->error('FAIL: supplier_webhook_secret слишком короткий (length='.strlen($value).', нужно ≥32 chars для совместимости с verifySecret в SupplierWebhookController).'); - - return self::FAILURE; - } - - $this->info('OK: supplier_webhook_secret valid (length='.strlen($value).' chars, не placeholder).'); - - return self::SUCCESS; - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -```powershell -./vendor/bin/pest tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php -``` - -Expected: 4 passed. - -- [ ] **Step 5: Larastan + Pint** - -```powershell -./vendor/bin/phpstan analyse --memory-limit=512M app/Console/Commands/CheckSupplierWebhookSecretCommand.php -./vendor/bin/pint --test app/Console/Commands/CheckSupplierWebhookSecretCommand.php -``` - -Expected: passed / passed. - -- [ ] **Step 6: Commit (atomic)** - -```bash -git add app/app/Console/Commands/CheckSupplierWebhookSecretCommand.php app/tests/Feature/Console/CheckSupplierWebhookSecretCommandTest.php -git commit -m "$(cat <<'EOF' -feat(commands): supplier:check-webhook-secret — deploy validator (Plan 2.6 #i) - -Закрывает CV.11 audit WARN #4 (placeholder secret '__SET_ON_DEPLOY__' = silent -404 на production). - -Console command для deploy-script: SELECT system_settings.supplier_webhook_secret -→ exit 1 если placeholder OR len < 32 OR row отсутствует. Иначе exit 0. - -Использование: deploy-script вызывает `php artisan supplier:check-webhook-secret` -перед запуском приложения; non-zero exit прерывает deploy. - -TDD: 4 теста (placeholder rejected / short rejected / missing rejected / valid accepted). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: Fix #ii — IP allowlist production fail-closed (WARN #5) - -**Files:** - -- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php` (lines 109-115 в `verifyIpAllowlist`) -- Modify: `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (+ failing test) - -**Контекст:** `verifyIpAllowlist` (line 113-114): `if ($list === []) return true;` — fail-open, любой IP пропускается. На dev OK (localhost development), на production — security gap. Inline-warning lines 34-39 признаёт. Fix: в production env пустой allowlist = блокировать. - -- [ ] **Step 1: Failing test** - -Добавить в `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (в конец файла перед `?>` или последний `});`): - -```php -it('blocks empty IP allowlist в production env (Plan 2.6 fix #ii)', function (): void { - // Setup: valid secret + пустой IP allowlist (default seed). - DB::table('system_settings') - ->updateOrInsert( - ['key' => 'supplier_webhook_secret'], - ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed'] - ); - DB::table('system_settings') - ->updateOrInsert( - ['key' => 'supplier_ip_allowlist'], - ['value' => '[]', 'value_type' => 'json', 'description' => 'test seed'] - ); - - // Mock app environment как production. - app()->detectEnvironment(fn () => 'production'); - - $response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [ - 'vid' => 1, - 'project' => 'B1_test.ru', - 'phone' => '79991234567', - 'time' => now()->getTimestamp(), - ]); - - $response->assertStatus(404); -}); - -it('allows empty IP allowlist в dev env (Plan 2.6 fix #ii — fail-open для dev)', function (): void { - DB::table('system_settings') - ->updateOrInsert( - ['key' => 'supplier_webhook_secret'], - ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed'] - ); - DB::table('system_settings') - ->updateOrInsert( - ['key' => 'supplier_ip_allowlist'], - ['value' => '[]', 'value_type' => 'json', 'description' => 'test seed'] - ); - - // Test env (default — testing) → fail-open сохраняется. - - $response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [ - 'vid' => 999999, - 'project' => 'B1_dev-test.ru', - 'phone' => '79991234567', - 'time' => now()->getTimestamp(), - ]); - - // 202 = accepted (значит verifyIpAllowlist пропустил). - $response->assertStatus(202); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -```powershell -./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php --filter="blocks empty IP allowlist в production" -``` - -Expected: 1 failed (assertStatus 404, actual 202 — потому что fix ещё не применён, production env не блокирует). - -- [ ] **Step 3: Implement fix в `verifyIpAllowlist`** - -Modify `app/app/Http/Controllers/Api/SupplierWebhookController.php`: - -```php -private function verifyIpAllowlist(?string $clientIp): bool -{ - if ($clientIp === null) { - return false; - } - $row = DB::table('system_settings')->where('key', 'supplier_ip_allowlist')->first(); - if ($row === null) { - return true; - } - $list = json_decode((string) $row->value, true) ?: []; - if ($list === []) { - // Plan 2.6 fix #ii: production env — пустой allowlist fail-closed (защита от - // забытого override в schema seed); dev/testing — fail-open для localhost dev. - // CV.11 audit WARN #5: inline-warning lines 34-39 признавал проблему, - // теперь enforced. - return ! app()->environment('production'); - } - - return IpUtils::checkIp($clientIp, $list); -} -``` - -Также обновить inline-warning header в файле (lines 34-39) — заменить «только secret защищает» на «production env enforce'ит non-empty allowlist через verifyIpAllowlist». - -- [ ] **Step 4: Run tests to verify it passes** - -```powershell -./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php -``` - -Expected: все тесты passed (включая 2 новых + существующие 8 не сломались). - -- [ ] **Step 5: Larastan + Pint** - -```powershell -./vendor/bin/phpstan analyse --memory-limit=512M app/Http/Controllers/Api/SupplierWebhookController.php -./vendor/bin/pint --test app/Http/Controllers/Api/SupplierWebhookController.php -``` - -Expected: passed / passed. - -- [ ] **Step 6: Commit (atomic)** - -```bash -git add app/app/Http/Controllers/Api/SupplierWebhookController.php app/tests/Feature/Http/Webhook/SupplierWebhookTest.php -git commit -m "$(cat <<'EOF' -fix(http): IP allowlist fail-closed в production env (Plan 2.6 #ii) - -Закрывает CV.11 audit WARN #5 (пустой supplier_ip_allowlist '[]' = fail-open -на production — любой IP пропускается). - -Изменение: SupplierWebhookController::verifyIpAllowlist — если allowlist пустой, -возвращаем true только если env != production. На production пустой allowlist -блокирует (404). На dev/testing fail-open сохраняется (для localhost development). - -TDD: +2 теста (production env empty → 404; testing env empty → 202). -Inline-warning header обновлён. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: Fix #iii — Timestamp partition guard ±24h (WARN minor #5) - -**Files:** - -- Modify: `app/app/Http/Controllers/Api/SupplierWebhookController.php` (lines 52-60 validation rules) -- Modify: `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` (+ 2 failing tests) - -**Контекст:** `Carbon::createFromTimestamp((int) $payload['time'])` (`RouteSupplierLeadJob.php:168`) использует raw `time` от поставщика. Партиции `deals` месячные (`deals_2026_05` от 2026-05-01). Timestamp за пределами текущего месячного окна → INSERT упадёт с `no partition of relation "deals" found for row`. Fix: контроллер отвергает webhooks с time за окном [now-24h, now+24h]. - -- [ ] **Step 1: Failing tests** - -Добавить в `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php`: - -```php -it('rejects timestamp older than 24h (Plan 2.6 fix #iii — partition guard)', function (): void { - DB::table('system_settings') - ->updateOrInsert( - ['key' => 'supplier_webhook_secret'], - ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed'] - ); - - $response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [ - 'vid' => 100001, - 'project' => 'B1_old-time.ru', - 'phone' => '79991234567', - 'time' => now()->subDays(2)->getTimestamp(), - ]); - - $response->assertStatus(422)->assertJsonValidationErrors('time'); -}); - -it('rejects timestamp more than 24h in future (Plan 2.6 fix #iii — partition guard)', function (): void { - DB::table('system_settings') - ->updateOrInsert( - ['key' => 'supplier_webhook_secret'], - ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed'] - ); - - $response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [ - 'vid' => 100002, - 'project' => 'B1_future-time.ru', - 'phone' => '79991234567', - 'time' => now()->addDays(2)->getTimestamp(), - ]); - - $response->assertStatus(422)->assertJsonValidationErrors('time'); -}); - -it('accepts timestamp within ±24h window (Plan 2.6 fix #iii — partition guard)', function (): void { - DB::table('system_settings') - ->updateOrInsert( - ['key' => 'supplier_webhook_secret'], - ['value' => str_repeat('a', 64), 'value_type' => 'string', 'description' => 'test seed'] - ); - - $response = $this->postJson('/api/webhook/supplier/'.str_repeat('a', 64), [ - 'vid' => 100003, - 'project' => 'B1_valid-time.ru', - 'phone' => '79991234567', - 'time' => now()->subHours(6)->getTimestamp(), - ]); - - $response->assertStatus(202); -}); -``` - -- [ ] **Step 2: Run tests to verify failure** - -```powershell -./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php --filter="timestamp" -``` - -Expected: 2 failed (-2 days и +2 days сейчас принимаются как valid → assertStatus 422 fails). - -- [ ] **Step 3: Implement validation в `receive`** - -Modify `SupplierWebhookController::receive`: - -```php -public function receive(Request $request, string $secret): JsonResponse -{ - if (! $this->verifySecret($secret)) { - return response()->json(['message' => 'Not found.'], 404); - } - - if (! $this->verifyIpAllowlist($request->ip())) { - return response()->json(['message' => 'Not found.'], 404); - } - - // Plan 2.6 fix #iii: timestamp partition guard. Партиции deals месячные - // (deals_2026_MM); time за пределами текущего месяца → INSERT CRASH - // "no partition of relation deals found for row". Окно ±24h защищает от - // wildly out-of-range значений (старый/будущий дроп от поставщика). - $minTime = now()->subDay()->getTimestamp(); - $maxTime = now()->addDay()->getTimestamp(); - - $validated = $request->validate([ - 'vid' => 'required|integer|min:1', - 'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'], - 'phone' => ['required', 'string', 'regex:/^7\d{10}$/'], - 'time' => ['required', 'integer', "min:{$minTime}", "max:{$maxTime}"], - 'tag' => 'nullable|string|max:255', - 'phones' => 'nullable|array', - 'phones.*' => 'string|regex:/^7\d{10}$/', - ]); - - // ... (остальной код без изменений) -} -``` - -- [ ] **Step 4: Run tests** - -```powershell -./vendor/bin/pest tests/Feature/Http/Webhook/SupplierWebhookTest.php -``` - -Expected: все passed (включая 3 новых). - -- [ ] **Step 5: Larastan + Pint** - -```powershell -./vendor/bin/phpstan analyse --memory-limit=512M app/Http/Controllers/Api/SupplierWebhookController.php -./vendor/bin/pint --test app/Http/Controllers/Api/SupplierWebhookController.php -``` - -Expected: passed / passed. - -- [ ] **Step 6: Commit (atomic)** - -```bash -git add app/app/Http/Controllers/Api/SupplierWebhookController.php app/tests/Feature/Http/Webhook/SupplierWebhookTest.php -git commit -m "$(cat <<'EOF' -fix(http): timestamp validation ±24h для partition guard (Plan 2.6 #iii) - -Закрывает CV.11 audit WARN minor #5 (Carbon::createFromTimestamp(time) без -range guard → INSERT CRASH "no partition of relation deals found for row" -для timestamp вне текущего месячного окна). - -Изменение: SupplierWebhookController::receive — добавлено min/max constraint -на 'time' = [now-24h, now+24h] unix-timestamp. Timestamp вне окна → 422 -ValidationException. - -±24h: покрывает retry-задержки поставщика (network-сбой) + clock-drift серверов; -шире окно (±48h+) = риск partition-промаха на стыке месяцев (нужен Plan 5 -partition cron). - -TDD: +3 теста (-2 days → 422; +2 days → 422; -6h → 202). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: Fix #iv — `crm_supplier_worker` BYPASSRLS-роль (WARN minor #2 + #3) - -**Files:** - -- Modify: `db/00_create_roles.sql` (новая роль) -- Modify: `app/app/Services/LeadRouter.php` (inline-warning lines 25-29) -- Modify: `app/app/Console/Commands/ResetDeliveredTodayCommand.php` (inline-warning lines 16-18) - -**Контекст:** `LeadRouter::matchEligibleProjects` (sharing-flow) и `ResetDeliveredTodayCommand` (cron) делают cross-tenant SELECT/UPDATE без `SET LOCAL app.current_tenant_id`. На dev (`postgres` BYPASSRLS) работает; на prod (`crm_app_user` RLS-enforce) — отвергнутся policy `tenant_isolation`. Plan 2.6 brainstorming → вариант C (BYPASSRLS-роль для queue worker). - -Архитектурное обоснование: queue worker — backend system process, обрабатывает webhooks для разных tenant'ов sharing-flow + global crons. По дизайну видит все tenant'ы. Privilege-boundary совпадает с архитектурной ролью. WHERE(tenant_id=…) фильтры в коде (например `RouteSupplierLeadJob::createDealCopyForProject` line 161-164) сохраняются как defense-in-depth. - -**Без TDD-теста на роль** — integration test требует создания роли в test DB + смены connection (overhead не оправдан); делаем grep-smoke в Step 5. - -- [ ] **Step 1: Add role definition в `db/00_create_roles.sql`** - -Прочитать существующий `db/00_create_roles.sql` для понимания style: - -```powershell -cat db/00_create_roles.sql -``` - -Добавить блок (в конец файла): - -```sql --- ============================================================================= --- crm_supplier_worker — BYPASSRLS-роль для backend queue worker (Plan 2.6 fix #iv) --- ============================================================================= --- Закрывает CV.11 audit WARN minor #2 + #3 (LeadRouter и ResetDeliveredTodayCommand --- под crm_app_user → RLS-policy tenant_isolation отвергает cross-tenant SELECT/UPDATE). --- --- Privilege-boundary by design: --- • Queue worker (php artisan queue:work) = backend system process для cross-tenant --- операций: sharing-webhook routing (RouteSupplierLeadJob), global crons --- (projects:reset-delivered-today, supplier:check-webhook-secret). --- • Web worker = end-user-facing, остаётся под crm_app_user (RLS-enforce). --- WHERE(tenant_id=) фильтры в коде сохраняются как defense-in-depth даже под --- BYPASSRLS-ролью. --- --- Deploy: --- • Создать роль через этот файл при первом deploy (DBA / migrate:fresh). --- • Установить пароль через secrets manager (Yandex KMS / Vault). --- • Queue worker .env (отдельный от web .env): DB_USERNAME=crm_supplier_worker. --- --- Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6. --- Brainstorm decision (Plan 2.6, 10.05.2026 поздняя ночь): вариант C из 3 опций --- (A=elevated DB-connection / B=RLS WITH-CHECK exception / C=BYPASSRLS-роль). --- ============================================================================= -DO $$ -BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'crm_supplier_worker') THEN - -- Пароль ОБЯЗАТЕЛЬНО override через secrets manager перед production deploy. - EXECUTE format('CREATE ROLE crm_supplier_worker WITH LOGIN BYPASSRLS PASSWORD %L', '__SET_VIA_SECRETS_MANAGER__'); - END IF; -END $$; - -GRANT USAGE ON SCHEMA public TO crm_supplier_worker; -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO crm_supplier_worker; -GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO crm_supplier_worker; - --- DEFAULT PRIVILEGES для будущих таблиц / sequences: -ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO crm_supplier_worker; -ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT USAGE, SELECT ON SEQUENCES TO crm_supplier_worker; -``` - -- [ ] **Step 2: Update inline-warning в `LeadRouter.php`** - -Modify lines 25-29 в `app/app/Services/LeadRouter.php`: - -```php - * RLS-quirk: запрос работает поверх N tenant'ов одновременно (sharing-model). - * Не использует SET LOCAL app.current_tenant_id (в sharing-flow tenant ещё не определён — - * запрос подбирает кандидатов из всех tenant'ов параллельно). На production queue worker - * запускается под ролью crm_supplier_worker (BYPASSRLS) — Plan 2.6 fix #iv. На dev - * подключение под postgres (BYPASSRLS implicit). См. db/00_create_roles.sql. -``` - -- [ ] **Step 3: Update inline-warning в `ResetDeliveredTodayCommand.php`** - -Modify lines 14-18 в `app/app/Console/Commands/ResetDeliveredTodayCommand.php`: - -```php -/** - * Сброс projects.delivered_today=0 для всех tenant'ов. - * - * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6.1. - * Расписание: каждый день в 00:00 МСК (timezone Europe/Moscow). - * - * NB: tenant-scoped запрос без RLS — UPDATE сразу на все tenant'ы. На production - * queue worker (через Scheduler) запускается под ролью crm_supplier_worker - * (BYPASSRLS) — Plan 2.6 fix #iv. На dev подключение под postgres (BYPASSRLS - * implicit). См. db/00_create_roles.sql. - */ -``` - -- [ ] **Step 4: Smoke-grep verify (вместо integration-теста)** - -```powershell -grep -c "crm_supplier_worker" db/00_create_roles.sql -grep -c "BYPASSRLS" db/00_create_roles.sql -grep -c "crm_supplier_worker" app/app/Services/LeadRouter.php -grep -c "crm_supplier_worker" app/app/Console/Commands/ResetDeliveredTodayCommand.php -``` - -Expected: каждый grep ≥1 (роль определена + cross-refs из кода работают). - -- [ ] **Step 5: Run full Pest для regression check** - -```powershell -./vendor/bin/pest --parallel -``` - -Expected: все тесты passed (Plan 2.6 fix #i + #ii + #iii добавили ~7-8 новых тестов; baseline после Plan 2.5 — 549/547, ожидается ~556-557). - -- [ ] **Step 6: Larastan + Pint** - -```powershell -./vendor/bin/phpstan analyse --memory-limit=512M -./vendor/bin/pint --test -``` - -Expected: passed / passed. - -- [ ] **Step 7: Commit (atomic)** - -```bash -git add db/00_create_roles.sql app/app/Services/LeadRouter.php app/app/Console/Commands/ResetDeliveredTodayCommand.php -git commit -m "$(cat <<'EOF' -feat(db): crm_supplier_worker BYPASSRLS-роль для queue worker (Plan 2.6 #iv) - -Закрывает CV.11 audit WARN minor #2 + #3 (LeadRouter + ResetDeliveredTodayCommand -под crm_app_user → RLS-policy tenant_isolation отвергает cross-tenant SELECT/UPDATE). - -Архитектурное решение (Plan 2.6 brainstorm 10.05.2026 поздняя ночь, вариант C из 3 -опций): новая PG-роль crm_supplier_worker с BYPASSRLS — privilege-boundary by design. -Queue worker = backend system process для cross-tenant операций (sharing-webhook -routing, global crons); web worker остаётся под crm_app_user (RLS-enforce). - -WHERE(tenant_id=) фильтры в коде сохраняются как defense-in-depth. - -Deploy: - • Роль создаётся через db/00_create_roles.sql при первом deploy. - • Пароль override через secrets manager (Yandex KMS). - • Queue worker .env: DB_USERNAME=crm_supplier_worker (отдельно от web .env). - -Inline-warnings обновлены в LeadRouter.php + ResetDeliveredTodayCommand.php. - -Без TDD-теста на роль (integration-тест требует CREATE ROLE в test DB + -смены connection — overhead не оправдан); smoke-grep проверяет файлы. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 5: Closure — Memory updates + Plan 2.6 status header + push - -**Files:** - -- Modify: `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md` (status header) -- Modify (memory, вне репо): `feedback_environment.md`, `project_supplier_integration.md`, `project_state.md` - -- [ ] **Step 1: Re-run full verification** - -```powershell -./vendor/bin/pest --parallel -./vendor/bin/phpstan analyse --memory-limit=512M -./vendor/bin/pint --test -``` - -Expected: всё green; Pest count = 549 (baseline) + 4 (Task 1 idempotency tests) + 2 (Task 2 IP allowlist tests) + 3 (Task 3 timestamp tests) = ~558 / ~556 passed. - -- [ ] **Step 2: Update memory `feedback_environment.md`** - -Добавить новый quirk-блок: - -```markdown -- **Plan 2.6 fix #iv (10.05.2026 поздняя ночь): crm_supplier_worker BYPASSRLS-роль для queue worker.** Backend queue worker (php artisan queue:work) на production должен подключаться к БД под ролью `crm_supplier_worker` (BYPASSRLS), не под `crm_app_user` (RLS-enforce). Причина: sharing-webhook routing + global crons работают cross-tenant. См. `db/00_create_roles.sql`. Web worker остаётся под `crm_app_user`. Раздельные `.env` для web и queue. -``` - -- [ ] **Step 3: Update memory `project_supplier_integration.md`** - -Добавить секцию «Plan 2.6 cleanup (10.05.2026 поздняя ночь финал) — closure CV.11 operational + RLS остатков» с детализацией: - -- 4 fix'а закрыты (4 atomic commits) -- Pest count после Plan 2.6 -- Что осталось в Plan 3 backlog (BLOCKER #6 + 5 NIT + некоторые WARN minor #1, #4, #6, #7, #8) - -Также обновить таблицу декомпозиции Plans 1-5: добавить строку «2.6 hotfix #i+ii+iii+iv ✅ DONE» по аналогии с Plan 2.5. - -- [ ] **Step 4: Update memory `project_state.md`** - -Header description: HEAD origin/main = новый SHA (после push); Pest count = реальное число. - -- [ ] **Step 5: Update Plan 2.6 file — status header** - -Modify `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md` — добавить blockquote после h1: - -```markdown -> **✅ Plan completed (10.05.2026 поздняя ночь финал).** Все 4 fix'а закрыты атомарными commit'ами. Pest / passed (+9 тестов от Plan 2.5 baseline 549/547). Larastan + Pint clean. Memory updated. CV.11 audit Plan 3 backlog: BLOCKER #6 (RLS на failed_webhook_jobs INSERT NULL tenant) + 5 NIT + 5 minor WARN. -``` - -- [ ] **Step 6: Commit Plan 2.6 status header** - -```bash -git add docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md -git commit -m "$(cat <<'EOF' -docs(plans): close Plan 2.6 — ✅ status header - -Все 4 fix'а закрыты атомарными commits: - • Fix #i (placeholder secret deploy validator) - • Fix #ii (IP allowlist production fail-closed) - • Fix #iii (timestamp partition guard ±24h) - • Fix #iv (crm_supplier_worker BYPASSRLS-роль) - -Pest / passed (+9 тестов от Plan 2.5 baseline 549/547). -Larastan + Pint clean. - -Plan 3 backlog: BLOCKER #6 + 5 NIT + 5 minor WARN. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 7: Push origin main** - -```bash -git push origin main -``` - -Expected: lefthook pre-push (gitleaks-full-history + lychee) PASS, push successful. После Plan 2.6 на origin/main будет 5 новых commits (Tasks 1-5). - ---- - -## Self-review - -**Spec coverage:** - -- ✅ WARN #4 (placeholder secret) — Task 1. -- ✅ WARN #5 (empty IP allowlist) — Task 2. -- ✅ WARN minor #5 (Carbon partition crash) — Task 3. -- ✅ WARN minor #2 + #3 (LeadRouter + ResetCmd RLS под crm_app_user) — Task 4. -- ✅ Memory closure — Task 5. - -**Placeholder scan:** нет TODO/TBD/«implement later». Все шаги имеют конкретный код / команды. - -**Type consistency:** Console signature `supplier:check-webhook-secret` (Task 1) — не дублируется. `verifyIpAllowlist` (Task 2) и `receive` validation (Task 3) — оба в `SupplierWebhookController`, без коллизий имен. `crm_supplier_worker` имя роли — единое во всех Tasks (4 и 5). - -**Что НЕ покрыто (вынесено в Plan 3 / NOTES):** - -- BLOCKER #6 (RLS на failed_webhook_jobs INSERT NULL tenant) — архитектурное решение (отдельная SaaS-таблица или INSERT-policy WITH CHECK true), Plan 3 первая задача. -- WARN minor #1 (SET LOCAL quotes стилистика) — defer. -- WARN minor #4 (COALESCE NOT NULL guard) — defer. -- WARN minor #6 (PhonePrefix re-validation в job) — defer. -- WARN minor #7 (parsePlatform DRY) — defer. -- WARN minor #8 (DuplicateDetector тай-брейкер) — defer. -- 5 NIT — defer. - ---- - -## Execution Handoff - -**Plan saved:** `docs/superpowers/plans/2026-05-10-supplier-plan26-cleanup.md`. - -**Two execution options:** - -1. **Subagent-Driven (recommended)** — fresh subagent per task + 2-stage review между. -2. **Inline Execution** — гонит таски в текущей сессии через `superpowers:executing-plans`. - -**Which approach?** diff --git a/docs/superpowers/plans/2026-05-10-supplier-webhook-routing-plan.md b/docs/superpowers/plans/2026-05-10-supplier-webhook-routing-plan.md deleted file mode 100644 index b6e99b2..0000000 --- a/docs/superpowers/plans/2026-05-10-supplier-webhook-routing-plan.md +++ /dev/null @@ -1,2637 +0,0 @@ -# Supplier Integration — Webhook + Sharing Routing (Plan 2/5) - -> **✅ Plan completed (10.05.2026 поздняя ночь).** Все Tasks 1–9 implementations + CV.1–CV.14 verification gates + Task 11 memory updates выполнены в `main` (`fb55bfd..c1ae195`, 16 commits). Чекбоксы ниже намеренно не tick'нуты — финальный статус задокументирован в `memory/project_supplier_integration.md` и `memory/project_state.md`. Plan 2.5 hotfix (commits `1ba1df8` fix #3 idempotency + `c1ae195` fix #2 concurrency) закрыл 2 из 3 BLOCKER findings CV.11 audit; BLOCKER #6 (RLS на `failed_webhook_jobs` INSERT NULL tenant) + 2 operational WARN (`__SET_ON_DEPLOY__` placeholder secret + пустой IP allowlist) + 8 minor WARN + 5 NIT → Plan 3 backlog. Pest 549/547 (+2 от baseline), Larastan + Pint clean. -> -> **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. -> -> **Verification is mandatory at every gate.** Do not skip verification gates. This plan was authored under directive: "перепроверять на все возможные ошибки, в логике, в коде, в цикличностях, опечатки и тд". - -**Goal:** Принимать единый stream лидов от поставщика crm.bp-gr.ru на платформенный URL и распределять каждый лид по N tenant-проектам Лидерры (sharing-model: 1 лид → N независимых deal-копий). - -**Architecture:** Новый платформенный endpoint `POST /api/webhook/supplier/{secret}` (защита: IP allowlist + system-wide secret token). Контроллер парсит payload, INSERT'ит raw в `supplier_leads`, dispatch'ит `RouteSupplierLeadJob`. Job находит `SupplierProject` через scope `forSignal(platform, signal_type, unique_key)`, определяет регион номера через `PhonePrefixService`, через `LeadRouter` подбирает eligible Лидерра-проекты (FK `supplier_b{1,2,3}_project_id` + active + workdays + region + delivered_today < daily_limit + balance > 0) и для каждого создаёт независимую `deal`-копию с инкрементом счётчиков. Старый per-tenant webhook (`/api/webhook/{token}`) остаётся нетронутым — отдельный legacy-канал, не пересекается с новым. - -**Tech Stack:** Laravel 13.7, PostgreSQL 16 (RLS + advisory lock), Pest 4, Larastan, squawk, pgFormatter (existing toolchain). - -**Spec:** [docs/superpowers/specs/2026-05-10-supplier-integration-design.md](../specs/2026-05-10-supplier-integration-design.md) — §5 (Приём лидов), §6 (Routing). - -**Verification philosophy:** TDD для каждой задачи (failing test first → green → commit). Verification gates после каждой задачи. Финальный «Comprehensive Verification Gate» (CV) в конце плана: Larastan + Pest + squawk + pgFormatter + cspell + markdownlint + migrate:fresh + code-reviewer subagent. - -**Plan 1 learnings применяются с самого старта** (см. [memory/project_supplier_integration.md](../../../../../../../Users/Administrator/.claude/projects/c---------------------crm-------------/memory/project_supplier_integration.md) §«Ключевые learnings»): - -- DDL **только в schema.sql** (single source of truth) + `load_initial_schema` миграция; **НЕ создавать incremental Laravel migrations** (Plan 1 учил: их нужно консолидировать → лишний цикл). -- Convention CHECK-имён: `chk__` (НЕ Laravel default). -- Money — kopecks integer. Здесь не money — но pattern помним. -- Pest model-тесты в `tests/Feature/Models/` (не `tests/Unit/Models/`) — Pest auto-binds `\Tests\TestCase` только в Feature/Browser. -- `SET LOCAL app.current_tenant_id` — string interpolation, **не** `?` placeholder (PG limit). -- `testing_rls_user` нужен для проверки RLS (postgres BYPASSRLS обходит политику). -- `chk_<>_required` для NOT NULL conditions через CHECK при partial NOT NULL. - ---- - -## File Structure - -### Files to create - -``` -app/app/Models/ - SupplierLead.php - -app/database/factories/ - SupplierLeadFactory.php - -app/app/Services/ - PhonePrefixService.php - LeadRouter.php - -app/app/Http/Controllers/Api/ - SupplierWebhookController.php - -app/app/Jobs/ - RouteSupplierLeadJob.php - -app/app/Console/Commands/ - ResetDeliveredTodayCommand.php - -app/tests/Feature/Models/ - SupplierLeadTest.php - -app/tests/Unit/Services/ - PhonePrefixServiceTest.php - -app/tests/Feature/Services/ - LeadRouterTest.php - -app/tests/Feature/Http/Webhook/ - SupplierWebhookTest.php - -app/tests/Feature/Jobs/ - RouteSupplierLeadJobTest.php - -app/tests/Feature/Console/ - ResetDeliveredTodayCommandTest.php - -app/tests/Feature/Integration/ - SupplierLeadFlowTest.php -``` - -### Files to modify - -``` -db/schema.sql v8.17 → v8.18 -db/CHANGELOG_schema.md +entry v8.18 -app/routes/web.php +1 route (line ~127, рядом с legacy) -app/bootstrap/app.php +schedule callback (если используется ->withSchedule()) ИЛИ -app/routes/console.php +schedule (Laravel 11+ default) -``` - -### Files NOT to modify - -``` -app/app/Http/Controllers/Api/WebhookReceiveController.php # legacy per-tenant; trogether -app/app/Jobs/ProcessWebhookJob.php # legacy per-tenant; together -``` - -Эти два файла — legacy per-tenant flow. Они остаются нетронутыми. Новый supplier-flow живёт параллельно. Решение о deprecation — после Plan 5. - -### File responsibilities - -| Файл | Ответственность | -|---|---| -| `db/schema.sql` v8.18 | Создать `supplier_leads` + расширить `projects.delivered_today` + 2 строки в `system_settings` (secret + IP allowlist) | -| `SupplierLead` | Eloquent-модель для raw-payload (SaaS-level, без RLS — supplier_lead появляется до routing) | -| `PhonePrefixService` | phone (E.164 / 11 цифр) → `region_code` (ISO 3166-2:RU) + federal district bit (1..128 для region_mask) | -| `LeadRouter` | Чистая функция: `(SupplierProject, phone, region_bit) → Collection` (eligible) | -| `SupplierWebhookController` | HTTP layer: secret check + IP allowlist + payload validation + INSERT supplier_leads + dispatch | -| `RouteSupplierLeadJob` | Background: парсит payload, ищет supplier_project, вызывает LeadRouter, создаёт deal-копии, апдейтит счётчики, шлёт уведомления | -| `ResetDeliveredTodayCommand` | Cron 00:00 МСК: `UPDATE projects SET delivered_today = 0` | - ---- - -## Task 1: Schema — supplier_leads + delivered_today + system_settings rows - -**Files:** - -- Modify: `db/schema.sql` (bump v8.17 → v8.18; +1 раздел; +1 колонка; +2 seed-строки) -- Modify: `db/CHANGELOG_schema.md` (+1 entry v8.18) - -**Контекст:** Новая SaaS-level таблица `supplier_leads` хранит raw-payload входящих webhook'ов от поставщика **до** routing'а. Не tenant-scoped (на момент INSERT'а tenant ещё не определён — routing в job'е). Колонка `projects.delivered_today` хранит дневной счётчик для проверки квоты в §6 спека. 2 строки в `system_settings` хранят platform-wide secret и IP-allowlist для defense-in-depth. - -- [ ] **Step 1: Failing test — supplier_leads table existence + columns** - -Файл: `app/tests/Feature/Integration/SchemaV8_18Test.php` - -```php -toBeTrue(); - expect(Schema::hasColumns('supplier_leads', [ - 'id', 'supplier_project_id', 'platform', 'raw_payload', 'vid', - 'phone', 'received_at', 'source', 'processed_at', 'deals_created_count', - ]))->toBeTrue(); -}); - -it('supplier_leads has FK to supplier_projects with ON DELETE SET NULL', function (): void { - $row = DB::selectOne(<<<'SQL' - SELECT confdeltype - FROM pg_constraint c - JOIN pg_class t ON t.oid = c.conrelid - WHERE t.relname = 'supplier_leads' AND c.contype = 'f' - SQL); - expect($row)->not->toBeNull(); - expect($row->confdeltype)->toBe('n'); // n = SET NULL -}); - -it('supplier_leads.source has CHECK enum', function (): void { - DB::statement("INSERT INTO supplier_leads (platform, raw_payload, vid, phone, received_at, source) VALUES ('B1', '{}', 1, '79991234567', NOW(), 'webhook')"); - expect(fn () => DB::statement("INSERT INTO supplier_leads (platform, raw_payload, vid, phone, received_at, source) VALUES ('B1', '{}', 2, '79991234567', NOW(), 'invalid_source')")) - ->toThrow(\Illuminate\Database\QueryException::class); -}); - -it('projects.delivered_today exists with default 0', function (): void { - expect(Schema::hasColumn('projects', 'delivered_today'))->toBeTrue(); - $defaults = DB::selectOne("SELECT column_default FROM information_schema.columns WHERE table_name = 'projects' AND column_name = 'delivered_today'"); - expect($defaults->column_default)->toContain('0'); -}); - -it('system_settings seed rows exist for supplier_webhook_secret + supplier_ip_allowlist', function (): void { - $secret = DB::selectOne("SELECT value FROM system_settings WHERE key = 'supplier_webhook_secret'"); - expect($secret)->not->toBeNull(); - expect((string) $secret->value)->toMatch('/^[A-Za-z0-9_\-]{32,}$/'); - - $allowlist = DB::selectOne("SELECT value FROM system_settings WHERE key = 'supplier_ip_allowlist'"); - expect($allowlist)->not->toBeNull(); - expect(json_decode($allowlist->value, true))->toBeArray(); -}); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Integration/SchemaV8_18Test.php --filter='supplier_leads table exists' -``` - -Expected: FAIL with `Failed asserting that false is true` (table not yet created). - -- [ ] **Step 3: Edit `db/schema.sql` — bump version + add table + column + seed** - -Шапка файла: header comment v8.17 → **v8.18**. - -```diff ---- header --- Версия: v8.17 (10.05.2026 — Plan 1/5 Task 2 fix: ...) -+-- Версия: v8.18 (10.05.2026 — Plan 2/5 Task 1: supplier_leads SaaS-level + projects.delivered_today + 2 system_settings rows для supplier-webhook + IP allowlist defense-in-depth) -+-- Базовая версия: v8.17 (10.05.2026 — Plan 1/5 Task 2 fix: FK + CHECK + resolver guard) -``` - -Добавить **в конце раздела 5 (после `failed_webhook_jobs` / `rejected_deals_log`, перед разделом 7 биллинг)** новый блок: - -```sql --- ----------------------------------------------------------------------------- --- supplier_leads — SaaS-level raw-payload входящих webhook'ов (v8.18, Plan 2/5) --- ----------------------------------------------------------------------------- --- Контекст: см. spec §5.1. Поставщик POST'ит на /api/webhook/supplier/{secret} --- (общий URL, не per-tenant). На момент INSERT'а tenant ещё не определён — --- routing происходит в RouteSupplierLeadJob через supplier_project + LeadRouter. --- Поэтому таблица НЕ tenant-scoped, RLS НЕ применяется (как supplier_projects). --- --- supplier_project_id заполняется job'ом ПОСЛЕ парсинга `project` из payload --- (`B1_vashinvestor.ru` → platform=B1, signal_identifier=vashinvestor.ru). --- ON DELETE SET NULL: при удалении supplier_project не каскадим — сохраняем --- raw для аудита. --- --- source: 'webhook' (основной канал §5.1) | 'csv_recovery' (резерв §5.2 — --- Plan 4 CSV reconciliation). На Plan 2 поддерживаем только 'webhook'; --- enum extensibility — для Plan 4. --- --- deals_created_count: сколько deal-копий было создано после routing'а --- (sharing-model: 0..N). Заполняется job'ом в конце обработки. NULL до processed. --- ----------------------------------------------------------------------------- -CREATE TABLE supplier_leads ( - id BIGSERIAL PRIMARY KEY, - supplier_project_id BIGINT REFERENCES supplier_projects(id) ON DELETE SET NULL, - platform VARCHAR(4) NOT NULL, -- B1 / B2 / B3 (parsed from project prefix) - raw_payload JSONB NOT NULL, -- весь payload как есть - vid BIGINT NOT NULL, -- supplier-side lead id (idempotency key in source CRM) - phone VARCHAR(20) NOT NULL, - received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - source VARCHAR(16) NOT NULL DEFAULT 'webhook', - processed_at TIMESTAMPTZ, - deals_created_count INTEGER, -- NULL до обработки; 0..N после - error TEXT, -- сообщение если job упал - - CONSTRAINT chk_supplier_leads_platform - CHECK (platform IN ('B1','B2','B3')), - CONSTRAINT chk_supplier_leads_source - CHECK (source IN ('webhook','csv_recovery')), - CONSTRAINT chk_supplier_leads_deals_count_nonneg - CHECK (deals_created_count IS NULL OR deals_created_count >= 0) -); - -CREATE INDEX idx_supplier_leads_received_at ON supplier_leads(received_at DESC); -CREATE INDEX idx_supplier_leads_supplier_project ON supplier_leads(supplier_project_id) WHERE supplier_project_id IS NOT NULL; --- Idempotency lookup: при повторной доставке того же vid от поставщика — пропускаем. -CREATE UNIQUE INDEX idx_supplier_leads_vid_unique ON supplier_leads(vid); - --- v8.18 (Plan 2/5): defense-in-depth — REVOKE ALL FROM crm_app_user. --- SaaS-level таблица. tenant-приложение читать не должно (raw-payload — наша зона). --- Conditional wrapper см. в supplier_projects (dev=postgres superuser без crm_app_user). --- --- REVOKE ALL ON supplier_leads FROM crm_app_user; -``` - -В разделе 4 (`projects` таблица), **после** `delivered_in_month` добавить: - -```sql - -- РАСШИРЕНИЕ v8.18 (Plan 2/5): дневной счётчик доставленных лидов. - -- Сбрасывается cron'ом ResetDeliveredTodayCommand в 00:00 МСК. - -- Используется в LeadRouter для проверки квоты (delivered_today < effective_daily_limit). - delivered_today INTEGER NOT NULL DEFAULT 0 - CHECK (delivered_today >= 0), -``` - -Найти `delivered_in_month` в DDL projects (строка ~785) и добавить **новую** колонку `delivered_today` сразу после `delivered_in_month`. Так же добавить в одну CHECK constraints не нужно — inline CHECK уже есть. - -В блоке `INSERT INTO system_settings` (раздел 12, строка ~2498) **в конце списка** добавить **2 новых seed-строки**: - -```sql - ('supplier_webhook_secret', -- key - '__SET_ON_DEPLOY__', -- value (placeholder; admin генерит при первом деплое) - 'string', -- type - 'Platform-wide секрет (≥32 chars) для /api/webhook/supplier/{secret}. См. spec §5.1.'), - ('supplier_ip_allowlist', - '[]', -- JSON array, пустой массив = пропускать всех (dev-режим) - 'json', - 'Список IP/CIDR поставщика crm.bp-gr.ru. Пустой массив = пропускать всех (DEV); на prod заполнить.'), -``` - -⚠️ Внимание: проверить точный синтаксис существующих seed-строк в schema.sql:2498 — формат может быть `(key, value, type, description)` OR `(key, type, description, value)`. Адаптировать под реальный. - -- [ ] **Step 4: Add CHANGELOG entry** - -Файл `db/CHANGELOG_schema.md` — append: - -````markdown -## v8.18 от 10.05.2026 (Plan 2/5 Task 1) - -**Цель:** Подготовка слоя данных для supplier-webhook + sharing routing (spec §5–§6). - -**Изменения:** -- ✅ Новая таблица `supplier_leads` (SaaS-level, без RLS) — raw-payload входящих webhook'ов от поставщика, FK на `supplier_projects(id) ON DELETE SET NULL`, 3 CHECK (platform enum / source enum / deals_count nonneg), 3 индекса (idx_received_at DESC + idx_supplier_project partial + UNIQUE на vid для idempotency). -- ✅ `projects.delivered_today INTEGER NOT NULL DEFAULT 0 CHECK (>=0)` — дневной счётчик для проверки квоты, сбрасывается cron'ом в 00:00 МСК. -- ✅ 2 строки в `system_settings`: - - `supplier_webhook_secret` (string, placeholder `__SET_ON_DEPLOY__`) — platform-wide секрет в URL. - - `supplier_ip_allowlist` (json, default `[]`) — IP/CIDR поставщика. - -**Метрики:** 60 → **61 базовых таблиц**, +3 индекса (111 → **114**), 39 RLS (без изменений — supplier_leads SaaS-level). - -**REVOKE:** `supplier_leads` defense-in-depth (закомментирован, conditional wrapper аналогично `supplier_projects`). -```` - -- [ ] **Step 5: Apply schema fresh + verify** - -Run: - -```powershell -cd app -.\artisan migrate:fresh --env=testing -.\vendor\bin\pest tests/Feature/Integration/SchemaV8_18Test.php -``` - -Expected: PASS (5 assertions). - -- [ ] **Step 6: pgFormatter check + lefthook squawk dry-run** - -Run: - -```powershell -cd "c:\моя\проекты\портал crm\Документация" -npm run format:sql:check -.\bin\squawk.exe db/schema.sql -``` - -Expected: оба чисто (нет diff'а в pgFormatter, 0 issues в squawk). - -- [ ] **Step 7: Commit** - -```bash -git add db/schema.sql db/CHANGELOG_schema.md app/tests/Feature/Integration/SchemaV8_18Test.php -git commit -m "$(cat <<'EOF' -feat(db): supplier_leads + projects.delivered_today + 2 system_settings (v8.18) - -Plan 2/5 Task 1 — слой данных для supplier-webhook flow. - -- supplier_leads (SaaS-level, без RLS) — raw payload incoming webhook'ов -- projects.delivered_today — дневной счётчик для проверки daily quota -- system_settings: supplier_webhook_secret + supplier_ip_allowlist - -Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §5-§6 - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 2: SupplierLead model + factory + tests - -**Files:** - -- Create: `app/app/Models/SupplierLead.php` -- Create: `app/database/factories/SupplierLeadFactory.php` -- Create: `app/tests/Feature/Models/SupplierLeadTest.php` - -**Контекст:** Eloquent-модель для `supplier_leads`. SaaS-level (без RLS). Связь `belongsTo(SupplierProject)`. `raw_payload` cast в array. `received_at` / `processed_at` — datetime cast. Используется в `RouteSupplierLeadJob` (Task 5). - -- [ ] **Step 1: Failing test** - -Файл `app/tests/Feature/Models/SupplierLeadTest.php`: - -```php -create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'vashinvestor.ru', - ]); - - $lead = SupplierLead::factory()->create([ - 'supplier_project_id' => $supplierProject->id, - 'platform' => 'B1', - ]); - - expect($lead->id)->toBeInt()->toBeGreaterThan(0); - expect($lead->platform)->toBe('B1'); - expect($lead->raw_payload)->toBeArray(); - expect($lead->received_at)->toBeInstanceOf(\Illuminate\Support\Carbon::class); -}); - -it('belongsTo supplier_project', function (): void { - $supplierProject = SupplierProject::factory()->create(); - $lead = SupplierLead::factory()->create([ - 'supplier_project_id' => $supplierProject->id, - ]); - - expect($lead->supplierProject->id)->toBe($supplierProject->id); -}); - -it('casts raw_payload to array and *_at to Carbon', function (): void { - $lead = SupplierLead::factory()->create([ - 'raw_payload' => ['vid' => 12345, 'project' => 'B1_test.ru'], - 'processed_at' => now(), - ]); - - expect($lead->raw_payload)->toBe(['vid' => 12345, 'project' => 'B1_test.ru']); - expect($lead->processed_at)->toBeInstanceOf(\Illuminate\Support\Carbon::class); -}); - -it('factory respects deals_created_count nullable default', function (): void { - $lead = SupplierLead::factory()->create(); - expect($lead->deals_created_count)->toBeNull(); - expect($lead->processed_at)->toBeNull(); -}); -``` - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Models/SupplierLeadTest.php -``` - -Expected: FAIL (Class not found). - -- [ ] **Step 2: Implement model** - -Файл `app/app/Models/SupplierLead.php`: - -```php - */ - use HasFactory; - - protected $table = 'supplier_leads'; - - public $timestamps = false; - - protected $fillable = [ - 'supplier_project_id', - 'platform', - 'raw_payload', - 'vid', - 'phone', - 'received_at', - 'source', - 'processed_at', - 'deals_created_count', - 'error', - ]; - - protected function casts(): array - { - return [ - 'raw_payload' => 'array', - 'received_at' => 'datetime', - 'processed_at' => 'datetime', - 'vid' => 'integer', - 'deals_created_count' => 'integer', - ]; - } - - /** @return BelongsTo */ - public function supplierProject(): BelongsTo - { - return $this->belongsTo(SupplierProject::class); - } - - protected static function newFactory(): SupplierLeadFactory - { - return SupplierLeadFactory::new(); - } -} -``` - -- [ ] **Step 3: Implement factory** - -Файл `app/database/factories/SupplierLeadFactory.php`: - -```php - - */ -class SupplierLeadFactory extends Factory -{ - protected $model = SupplierLead::class; - - /** @return array */ - public function definition(): array - { - $platform = $this->faker->randomElement(['B1', 'B2', 'B3']); - $vid = $this->faker->unique()->numberBetween(100_000_000, 999_999_999); - $phone = '7'.$this->faker->numerify('##########'); // 11 digits, leading 7 - - return [ - 'supplier_project_id' => null, - 'platform' => $platform, - 'raw_payload' => [ - 'vid' => $vid, - 'project' => $platform.'_test.example.com', - 'tag' => 'test-tag', - 'phone' => $phone, - 'phones' => [$phone], - 'time' => now()->getTimestamp(), - ], - 'vid' => $vid, - 'phone' => $phone, - 'received_at' => now(), - 'source' => 'webhook', - 'processed_at' => null, - 'deals_created_count' => null, - 'error' => null, - ]; - } -} -``` - -- [ ] **Step 4: Update IDE helper** - -Run: - -```powershell -cd app -.\artisan ide-helper:models -W -M -N -``` - -Expected: regenerates `_ide_helper_models.php` with SupplierLead. **Без аргумента** (Plan 1 learning #14). - -- [ ] **Step 5: Run tests** - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Models/SupplierLeadTest.php -``` - -Expected: PASS (4 tests). - -- [ ] **Step 6: Larastan check** - -Run: - -```powershell -cd app; .\vendor\bin\phpstan analyse -``` - -Expected: 0 errors (Plan 1 baseline сохранён). - -- [ ] **Step 7: Commit** - -```bash -git add app/app/Models/SupplierLead.php app/database/factories/SupplierLeadFactory.php app/tests/Feature/Models/SupplierLeadTest.php app/_ide_helper_models.php -git commit -m "$(cat <<'EOF' -feat(models): SupplierLead model + factory (raw-payload incoming webhooks) - -SaaS-level модель для supplier_leads (Plan 2/5 Task 2). -belongsTo(SupplierProject) + array cast на raw_payload + datetime *_at. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 3: PhonePrefixService (MVP — мобильные коды + федеральные округа) - -**Files:** - -- Create: `app/app/Services/PhonePrefixService.php` -- Create: `app/tests/Unit/Services/PhonePrefixServiceTest.php` - -**Контекст:** Spec §6 step 2: «Определяем регион номера по коду телефона (справочник DEF-кодов)». Полный справочник Минсвязи — 40K+ строк, импорт отложен на Plan 5 / future. **MVP-стратегия:** мобильные операторы (9XX) → возвращаем `region_bit = 0xFF` (все 8 округов — лид принимается везде, фактический регион определяется позже на стороне клиента UI). Кодов городов (495/812 и т.п.) — мапим на конкретный округ. Возвращаемый bit складывается с `projects.region_mask` через `&`. - -Архитектура: чистая stateless-функция, без БД. Тесты — Unit (без Pest TestCase / DB). - -- [ ] **Step 1: Failing test** - -Файл `app/tests/Unit/Services/PhonePrefixServiceTest.php`: - -```php -resolveDistrictBit('79991234567'))->toBe(0xFF); // все 8 округов -}); - -it('Moscow code 495 returns Central district bit (1)', function (): void { - $service = new PhonePrefixService(); - - expect($service->resolveDistrictBit('74951234567'))->toBe(1); -}); - -it('Saint-Petersburg code 812 returns Northwest district bit (2)', function (): void { - $service = new PhonePrefixService(); - - expect($service->resolveDistrictBit('78121234567'))->toBe(2); -}); - -it('Yekaterinburg code 343 returns Ural district bit (32)', function (): void { - $service = new PhonePrefixService(); - - expect($service->resolveDistrictBit('73431234567'))->toBe(32); -}); - -it('unknown code returns all-districts mask (defensive default)', function (): void { - $service = new PhonePrefixService(); - - expect($service->resolveDistrictBit('70001234567'))->toBe(0xFF); -}); - -it('rejects invalid phone formats', function (): void { - $service = new PhonePrefixService(); - - expect(fn () => $service->resolveDistrictBit('123'))->toThrow(\InvalidArgumentException::class); - expect(fn () => $service->resolveDistrictBit('+79991234567'))->toThrow(\InvalidArgumentException::class); - expect(fn () => $service->resolveDistrictBit('89991234567'))->toThrow(\InvalidArgumentException::class); -}); - -it('matchesProject — region_mode include + matching bit', function (): void { - $service = new PhonePrefixService(); - - expect($service->phoneMatchesRegions('74951234567', regionMask: 1, regionMode: 'include'))->toBeTrue(); - expect($service->phoneMatchesRegions('74951234567', regionMask: 2, regionMode: 'include'))->toBeFalse(); -}); - -it('matchesProject — region_mode exclude inverts logic', function (): void { - $service = new PhonePrefixService(); - - expect($service->phoneMatchesRegions('74951234567', regionMask: 1, regionMode: 'exclude'))->toBeFalse(); - expect($service->phoneMatchesRegions('74951234567', regionMask: 2, regionMode: 'exclude'))->toBeTrue(); -}); - -it('mobile (all-districts) always passes include any-bit', function (): void { - $service = new PhonePrefixService(); - - expect($service->phoneMatchesRegions('79991234567', regionMask: 1, regionMode: 'include'))->toBeTrue(); - expect($service->phoneMatchesRegions('79991234567', regionMask: 255, regionMode: 'include'))->toBeTrue(); -}); -``` - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Unit/Services/PhonePrefixServiceTest.php -``` - -Expected: FAIL (class not found). - -- [ ] **Step 2: Implement service** - -Файл `app/app/Services/PhonePrefixService.php`: - -```php - - */ - private const CITY_CODE_MAP = [ - // Центральный - '495' => self::DISTRICT_CENTRAL, '498' => self::DISTRICT_CENTRAL, - '499' => self::DISTRICT_CENTRAL, '496' => self::DISTRICT_CENTRAL, - '4922' => self::DISTRICT_CENTRAL, // Владимир (4-digit) - '484' => self::DISTRICT_CENTRAL, // Калуга - // Северо-Западный - '812' => self::DISTRICT_NORTHWEST, - '813' => self::DISTRICT_NORTHWEST, - '814' => self::DISTRICT_NORTHWEST, - '815' => self::DISTRICT_NORTHWEST, // Мурманск - '816' => self::DISTRICT_NORTHWEST, // Новгород - '818' => self::DISTRICT_NORTHWEST, // Архангельск - // Южный - '861' => self::DISTRICT_SOUTH, // Краснодар - '862' => self::DISTRICT_SOUTH, // Сочи - '863' => self::DISTRICT_SOUTH, // Ростов - '865' => self::DISTRICT_SOUTH, // Ставрополь (фактически СКФО, но MVP-приближение) - // Северо-Кавказский - '871' => self::DISTRICT_NORTH_CAUCASUS, // Грозный - '872' => self::DISTRICT_NORTH_CAUCASUS, - '873' => self::DISTRICT_NORTH_CAUCASUS, - // Приволжский - '831' => self::DISTRICT_VOLGA, // Нижний Новгород - '843' => self::DISTRICT_VOLGA, // Казань - '846' => self::DISTRICT_VOLGA, // Самара - '347' => self::DISTRICT_VOLGA, // Уфа - '342' => self::DISTRICT_VOLGA, // Пермь - // Уральский - '343' => self::DISTRICT_URAL, // Екатеринбург - '345' => self::DISTRICT_URAL, // Тюмень - '351' => self::DISTRICT_URAL, // Челябинск - // Сибирский - '383' => self::DISTRICT_SIBERIA, // Новосибирск - '391' => self::DISTRICT_SIBERIA, // Красноярск - '381' => self::DISTRICT_SIBERIA, // Омск - '382' => self::DISTRICT_SIBERIA, // Томск - // Дальневосточный - '423' => self::DISTRICT_FAR_EAST, // Владивосток - '421' => self::DISTRICT_FAR_EAST, // Хабаровск - '411' => self::DISTRICT_FAR_EAST, // Якутск - ]; - - /** - * Возвращает битовое значение федерального округа для phone (E.164 11 цифр, начинается с 7). - * - * @throws InvalidArgumentException если phone не соответствует формату. - */ - public function resolveDistrictBit(string $phone): int - { - if (! preg_match('/^7\d{10}$/', $phone)) { - throw new InvalidArgumentException( - "Phone must be 11 digits starting with 7 (got: {$phone})" - ); - } - - $codeArea = substr($phone, 1, 3); // 3-digit ABC-code (after leading 7) - - // Mobile prefix (9XX) → all districts (мобильник может быть везде) - if ($codeArea[0] === '9') { - return self::ALL_DISTRICTS; - } - - // Try 4-digit prefix (для редких регионов типа 4922 Владимир) - $code4 = substr($phone, 1, 4); - if (isset(self::CITY_CODE_MAP[$code4])) { - return self::CITY_CODE_MAP[$code4]; - } - - return self::CITY_CODE_MAP[$codeArea] ?? self::ALL_DISTRICTS; - } - - /** - * Проверяет соответствие phone настройкам региона проекта. - * - * @param int $regionMask битмаска projects.region_mask (0..255). - * @param string $regionMode 'include' | 'exclude'. - */ - public function phoneMatchesRegions(string $phone, int $regionMask, string $regionMode): bool - { - $phoneBits = $this->resolveDistrictBit($phone); - $intersect = $phoneBits & $regionMask; - - return match ($regionMode) { - 'include' => $intersect !== 0, - 'exclude' => $intersect === 0, - default => throw new InvalidArgumentException("Unknown region_mode: {$regionMode}"), - }; - } -} -``` - -- [ ] **Step 3: Run test** - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Unit/Services/PhonePrefixServiceTest.php -``` - -Expected: PASS (9 tests). - -- [ ] **Step 4: Commit** - -```bash -git add app/app/Services/PhonePrefixService.php app/tests/Unit/Services/PhonePrefixServiceTest.php -git commit -m "$(cat <<'EOF' -feat(services): PhonePrefixService — phone → federal district bit (MVP) - -Маппинг мобильных + 30+ ABC-кодов городов на 8 ФО RF (synchronized -с projects.region_mask битами). Мобильные → all-districts (255). -Полный Минсвязи справочник отложен на Plan 5+. - -Spec: §6 step 2 routing geo-filter. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 4: LeadRouter service — eligible Liderra projects - -**Files:** - -- Create: `app/app/Services/LeadRouter.php` -- Create: `app/tests/Feature/Services/LeadRouterTest.php` - -**Контекст:** Чистая функция: дано (`SupplierProject`, phone) → возвращает `Collection` где каждый проект: - -- ссылается на этот supplier_project через `supplier_b{1,2,3}_project_id` (зависит от supplier_project.platform); -- `is_active = true`; -- сегодняшний день в `delivery_days_mask`; -- регион phone попадает в `region_mask` × `region_mode`; -- `delivered_today < COALESCE(effective_daily_limit_today, daily_limit_target)`; -- `tenant.balance_leads > 0`. - -Сортировка: `created_at ASC` (старые проекты первыми — детерминированный порядок, spec §6 step 4). - -**Не учитывает:** дедупликацию по phone (Биз-19) — это в Job (после создания deal). Job сначала ищет master через `DuplicateDetector`, и если master найден — помечает копию `duplicate_of_id` (без charge — это уже Plan 4 territory). - -⚠️ **RLS-quirk:** LeadRouter обращается к проектам **через несколько tenant'ов** (sharing). Нужно либо (а) обходить RLS через `pg_set_role` на elevated role, либо (б) выполнять запрос вне tenant-context'а (без `SET LOCAL app.current_tenant_id`). На dev (postgres BYPASSRLS) запрос пройдёт. На prod — нужен elevated role. **Для MVP** — выполняем запрос без `SET LOCAL` (нет tenant-context), и полагаемся на: dev=BYPASSRLS, prod=elevated role при выполнении job'а (см. Plan 5+ DevOps). Документируем это в docblock. - -- [ ] **Step 1: Failing test** - -Файл `app/tests/Feature/Services/LeadRouterTest.php`: - -```php -create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'vashinvestor.ru', - ]); - - $tenant1 = Tenant::factory()->create(['balance_leads' => 100]); - $tenant2 = Tenant::factory()->create(['balance_leads' => 100]); - - $project1 = Project::factory()->create([ - 'tenant_id' => $tenant1->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'signal_identifier' => 'vashinvestor.ru', - 'is_active' => true, - 'daily_limit_target' => 10, - 'delivered_today' => 0, - 'delivery_days_mask' => 127, // все 7 дней - 'region_mask' => 255, // все 8 округов - 'region_mode' => 'include', - ]); - - $project2 = Project::factory()->create([ - 'tenant_id' => $tenant2->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'signal_identifier' => 'vashinvestor.ru', - 'is_active' => true, - 'daily_limit_target' => 10, - 'delivered_today' => 0, - 'delivery_days_mask' => 127, - 'region_mask' => 255, - 'region_mode' => 'include', - ]); - - $router = app(LeadRouter::class); - $matched = $router->matchEligibleProjects($supplier, '79991234567'); - - expect($matched)->toHaveCount(2); - expect($matched->pluck('id')->all())->toEqualCanonicalizing([$project1->id, $project2->id]); -}); - -it('skips paused project (is_active=false)', function (): void { - $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']); - $tenant = Tenant::factory()->create(['balance_leads' => 100]); - - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'is_active' => false, // ← paused - ]); - - $router = app(LeadRouter::class); - expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0); -}); - -it('skips project where today is not in delivery_days_mask', function (): void { - // current день недели (ISO 1=Mon..7=Sun); mask без сегодняшнего бита - $todayBit = 1 << (now()->isoWeekday() - 1); - $maskWithoutToday = 127 & ~$todayBit; - - $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']); - $tenant = Tenant::factory()->create(['balance_leads' => 100]); - - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'is_active' => true, - 'delivery_days_mask' => $maskWithoutToday, - ]); - - $router = app(LeadRouter::class); - expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0); -}); - -it('skips project where delivered_today >= effective_daily_limit_today', function (): void { - $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']); - $tenant = Tenant::factory()->create(['balance_leads' => 100]); - - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'is_active' => true, - 'effective_daily_limit_today' => 5, - 'delivered_today' => 5, - ]); - - $router = app(LeadRouter::class); - expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0); -}); - -it('falls back to daily_limit_target when effective_daily_limit_today is null', function (): void { - $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']); - $tenant = Tenant::factory()->create(['balance_leads' => 100]); - - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'is_active' => true, - 'effective_daily_limit_today' => null, - 'daily_limit_target' => 10, - 'delivered_today' => 5, - ]); - - $router = app(LeadRouter::class); - expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(1); -}); - -it('skips project where region_mode=include and region_mask does not include phone district', function (): void { - $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']); - $tenant = Tenant::factory()->create(['balance_leads' => 100]); - - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'is_active' => true, - 'region_mask' => 1, // только Центральный округ - 'region_mode' => 'include', - ]); - - $router = app(LeadRouter::class); - // 78121234567 = СПб (Северо-Западный, бит 2) - expect($router->matchEligibleProjects($supplier, '78121234567'))->toHaveCount(0); -}); - -it('skips project where tenant.balance_leads <= 0', function (): void { - $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']); - $tenant = Tenant::factory()->create(['balance_leads' => 0]); // ← пуст - - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'is_active' => true, - ]); - - $router = app(LeadRouter::class); - expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(0); -}); - -it('routes through correct FK based on platform (B2 → supplier_b2_project_id)', function (): void { - $supplier = SupplierProject::factory()->create(['platform' => 'B2', 'signal_type' => 'site']); - $tenant = Tenant::factory()->create(['balance_leads' => 100]); - - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'supplier_b1_project_id' => null, - 'supplier_b2_project_id' => $supplier->id, // ← через B2 - 'supplier_b3_project_id' => null, - 'signal_type' => 'site', - 'is_active' => true, - ]); - - $router = app(LeadRouter::class); - expect($router->matchEligibleProjects($supplier, '79991234567'))->toHaveCount(1); -}); - -it('orders results by created_at ASC (deterministic, spec §6 step 4)', function (): void { - $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']); - - $projectsCreated = collect(); - for ($i = 0; $i < 3; $i++) { - $tenant = Tenant::factory()->create(['balance_leads' => 100]); - $projectsCreated->push( - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'is_active' => true, - 'created_at' => now()->subDays(3 - $i), - ]) - ); - } - - $router = app(LeadRouter::class); - $matched = $router->matchEligibleProjects($supplier, '79991234567'); - - expect($matched->pluck('id')->all())->toBe($projectsCreated->pluck('id')->all()); -}); -``` - -⚠️ Перед запуском убедиться, что `testing_rls_user` создан (см. Plan 1 learning #10) и в test-bootstrap либо `DatabaseTransactions` rollback'ит, либо явно `DB::table('projects')->truncate()`. - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Services/LeadRouterTest.php -``` - -Expected: FAIL (class not found). - -- [ ] **Step 2: Implement service** - -Файл `app/app/Services/LeadRouter.php`: - -```php - 0, today_bit = 1 << (ISO_DOW - 1). - * 4. Фильтр delivered_today: < COALESCE(effective_daily_limit_today, daily_limit_target). - * 5. Фильтр region: PhonePrefixService::phoneMatchesRegions(). - * 6. Фильтр balance: tenants.balance_leads > 0 (через JOIN). - * 7. Сортировка: created_at ASC (детерминированно — старые первыми). - * - * RLS-quirk: запрос работает поверх N tenant'ов одновременно. Не использует - * SET LOCAL app.current_tenant_id (ему нечего передать — sharing). Полагается на - * dev=postgres BYPASSRLS / prod=elevated role при выполнении из job'а. - * - * Spec: docs/superpowers/specs/2026-05-10-supplier-integration-design.md §6 - */ -class LeadRouter -{ - public function __construct( - private readonly PhonePrefixService $phonePrefix, - ) {} - - /** - * @return Collection - */ - public function matchEligibleProjects(SupplierProject $supplierProject, string $phone): Collection - { - $fkColumn = match ($supplierProject->platform) { - 'B1' => 'supplier_b1_project_id', - 'B2' => 'supplier_b2_project_id', - 'B3' => 'supplier_b3_project_id', - }; - - $todayBit = 1 << (Carbon::now()->isoWeekday() - 1); - - /** @var Collection $candidates */ - $candidates = Project::query() - ->where($fkColumn, $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) { - $q->selectRaw('1') - ->from('tenants') - ->whereColumn('tenants.id', 'projects.tenant_id') - ->where('tenants.balance_leads', '>', 0); - }) - ->orderBy('created_at') - ->orderBy('id') // tiebreaker - ->get(); - - // Region-фильтр в PHP: PostgreSQL bit-арифметика на JSONB неудобна, - // да и MVP-PhonePrefixService — статический мап без БД. - return $candidates->filter( - fn (Project $p): bool => $this->phonePrefix->phoneMatchesRegions( - $phone, - (int) $p->region_mask, - (string) $p->region_mode, - ) - )->values(); - } -} -``` - -- [ ] **Step 3: Run tests** - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Services/LeadRouterTest.php -``` - -Expected: PASS (9 tests). - -- [ ] **Step 4: Larastan check** - -Run: - -```powershell -cd app; .\vendor\bin\phpstan analyse -``` - -Expected: 0 errors. - -- [ ] **Step 5: Commit** - -```bash -git add app/app/Services/LeadRouter.php app/tests/Feature/Services/LeadRouterTest.php -git commit -m "$(cat <<'EOF' -feat(services): LeadRouter — eligible Liderra projects matcher - -Sharing-model routing (spec §6): для входящего лида возвращает Collection -учитывая platform FK + active + workdays + region (PhonePrefixService) + -delivered_today < daily_limit + tenant.balance_leads > 0. Сортировка created_at ASC. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 5: RouteSupplierLeadJob - -**Files:** - -- Create: `app/app/Jobs/RouteSupplierLeadJob.php` -- Create: `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php` - -**Контекст:** Background-job для обработки `supplier_leads`. Шаги: - -1. Загружает `SupplierLead` по id. -2. Парсит `raw_payload['project']` → (platform, identifier). -3. Резолвит `SupplierProject` через `forSignal` (если не нашёлся — лид orphan, помечает error и завершается). -4. Вызывает `LeadRouter::matchEligibleProjects(supplier_project, phone)`. -5. Для каждого matched project: - - Wraps в `DB::transaction` с `SET LOCAL app.current_tenant_id = $project->tenant_id`. - - `Tenant::lockForUpdate()`. - - Создаёт deal-копию (`Deal::create`) с `source_crm_id = vid`. - - Инкрементирует `tenant.balance_leads`-- (existing pattern). - - Инкрементирует `project.delivered_today` + `project.delivered_in_month`. - - Создаёт `BalanceTransaction` + `ActivityLog` + (TBD Plan 4) `LeadCharge`. - - Вызывает `NotificationService::notifyNewLead`. -6. Обновляет `SupplierLead` (`processed_at`, `deals_created_count`). -7. На failed() — записывает в `failed_webhook_jobs` (как ProcessWebhookJob). - -⚠️ **Идемпотентность по vid:** UNIQUE INDEX на `supplier_leads.vid` (Task 1) гарантирует, что повторный webhook с тем же vid провалится на INSERT. Job не должен **дополнительно** проверять vid — это уже сделано на уровне controller'а (UNIQUE violation → 200 OK игнор-ответ). - -⚠️ **Дубль по phone (Биз-19) внутри tenant'а:** для каждого создаваемого deal'а вызвать `DuplicateDetector::findMaster` ПОСЛЕ создания (как в ProcessWebhookJob). Если master найден ≠ self → пометить `duplicate_of_id`, **НЕ списывать** balance, **НЕ инкрементировать** счётчики, **НЕ нотифицировать**. Charge / increment / notify — только для не-дублей. - -- [ ] **Step 1: Failing test** - -Файл `app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php`: - -```php -create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'vashinvestor.ru', - ]); - - $tenants = collect(); - $projects = collect(); - for ($i = 0; $i < 3; $i++) { - $t = Tenant::factory()->create(['balance_leads' => 100]); - $tenants->push($t); - $projects->push(Project::factory()->create([ - 'tenant_id' => $t->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'signal_identifier' => 'vashinvestor.ru', - 'is_active' => true, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - ])); - } - - $vid = 432176649; - $lead = SupplierLead::factory()->create([ - 'platform' => 'B1', - 'vid' => $vid, - 'phone' => '79991234567', - 'raw_payload' => [ - 'vid' => $vid, - 'project' => 'B1_vashinvestor.ru', - 'tag' => 'tag', - 'phone' => '79991234567', - 'phones' => ['79991234567'], - 'time' => now()->getTimestamp(), - ], - ]); - - (new 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), - ); - - $lead->refresh(); - expect($lead->processed_at)->not->toBeNull(); - expect($lead->deals_created_count)->toBe(3); - expect($lead->supplier_project_id)->toBe($supplier->id); - - foreach ($projects as $p) { - $p->refresh(); - expect($p->delivered_today)->toBe(1); - expect($p->delivered_in_month)->toBe(1); - - // По одной deal-копии на каждый tenant - DB::statement("SET LOCAL app.current_tenant_id = '{$p->tenant_id}'"); - $deals = Deal::query() - ->where('tenant_id', $p->tenant_id) - ->where('source_crm_id', $vid) - ->get(); - expect($deals)->toHaveCount(1); - } -}); - -it('decrements balance_leads for each tenant by 1', function (): void { - $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']); - $tenant = Tenant::factory()->create(['balance_leads' => 100]); - Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'is_active' => true, - ]); - - $lead = SupplierLead::factory()->create([ - 'platform' => 'B1', - 'phone' => '79991234567', - 'raw_payload' => ['vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time()], - ]); - - (new 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), - ); - - expect($tenant->fresh()->balance_leads)->toBe(99); -}); - -it('marks duplicate via DuplicateDetector — no charge, no counter increment', function (): void { - $supplier = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site']); - $tenant = Tenant::factory()->create(['balance_leads' => 100]); - $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'is_active' => true, - 'delivered_today' => 0, - ]); - - // Master deal на тот же phone в окне 24ч - DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); - $master = Deal::create([ - 'tenant_id' => $tenant->id, - 'source_crm_id' => 999, - 'project_id' => $project->id, - 'phone' => '79991234567', - 'phones' => ['79991234567'], - 'status' => 'new', - 'received_at' => now()->subHours(2), - ]); - - $lead = SupplierLead::factory()->create([ - 'platform' => 'B1', - 'vid' => 1000, - 'phone' => '79991234567', - 'raw_payload' => ['vid' => 1000, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time()], - ]); - - (new 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), - ); - - expect($tenant->fresh()->balance_leads)->toBe(100); // НЕ списан (дубль) - expect($project->fresh()->delivered_today)->toBe(0); // НЕ инкремент - - $duplicate = Deal::where('source_crm_id', 1000)->first(); - expect($duplicate->duplicate_of_id)->toBe($master->id); -}); - -it('records error if supplier_project cannot be resolved (B1+SMS)', function (): void { - $lead = SupplierLead::factory()->create([ - 'platform' => 'B1', - 'raw_payload' => ['vid' => 1, 'project' => 'B1_TINKOFF', 'phone' => '79991234567', 'time' => time()], - ]); - // B1 + parsed signal_type=sms → throws DomainException - - expect(fn () => (new 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), - ))->toThrow(\DomainException::class); - - // ... but лид сохранён, ошибка пишется через failed-callback (см. Step 2) -}); -``` - -⚠️ Третий тест (B1+SMS) сложен — `parseProjectField` должен возвращать signal_type. Реализация Step 2 ниже. - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Jobs/RouteSupplierLeadJobTest.php -``` - -Expected: FAIL (class not found). - -- [ ] **Step 2: Implement job** - -Файл `app/app/Jobs/RouteSupplierLeadJob.php`: - -```php -. - * 5. Для каждого Project — атомарно: - * - SET LOCAL app.current_tenant_id; - * - lock tenant; - * - создать Deal с source_crm_id=vid; - * - DuplicateDetector::findMaster → если найден master, помечаем duplicate_of_id (без charge/inc/notify); - * - иначе: balance--, delivered_today++, delivered_in_month++, - * BalanceTransaction, ActivityLog, NotificationService::notifyNewLead. - * 6. Обновить SupplierLead.processed_at + deals_created_count. - * - * NB: Биллинг (LeadCharge с tier-snapshot) реализуется в Plan 4 — здесь только - * existing balance_leads decrement (как в ProcessWebhookJob). - */ -class RouteSupplierLeadJob implements ShouldQueue -{ - use FoundationQueueable; - use InteractsWithQueue; - use Queueable; - use SerializesModels; - - public int $tries = 3; - public int $backoff = 60; - public int $timeout = 60; - - public function __construct(public int $supplierLeadId) {} - - public function handle( - LeadRouter $router, - SupplierProjectResolver $resolver, - DuplicateDetector $duplicateDetector, - NotificationService $notifier, - ): void { - $lead = SupplierLead::findOrFail($this->supplierLeadId); - - [$platform, $signalType, $identifier] = $this->parseProjectField($lead->raw_payload['project'] ?? ''); - - $supplier = $resolver->resolveOrStub($platform, $signalType, $identifier); - - $lead->update(['supplier_project_id' => $supplier->id]); - - $matched = $router->matchEligibleProjects($supplier, $lead->phone); - - $createdCount = 0; - foreach ($matched as $project) { - if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier)) { - $createdCount++; - } - } - - $lead->update([ - 'processed_at' => now(), - 'deals_created_count' => $createdCount, - ]); - } - - /** - * Парсит payload-строку `B1_vashinvestor.ru` или `B2_TINKOFF` (SMS) или `B1_79991234567` (call). - * - * @return array{0: string, 1: string, 2: string} [platform, signal_type, identifier] - */ - private function parseProjectField(string $project): array - { - if (! preg_match('/^(B[123])_(.+)$/', $project, $m)) { - throw new RuntimeException("Cannot parse project field: {$project}"); - } - $platform = $m[1]; - $rest = $m[2]; - - // Эвристика signal_type: - // '7' + 10 digits → call - // только домен → site - // иначе → sms (sender или sender+keyword) - if (preg_match('/^7\d{10}$/', $rest)) { - $signalType = 'call'; - } elseif (preg_match('/^[a-z0-9-]+(\.[a-z0-9-]+)+$/i', $rest)) { - $signalType = 'site'; - } else { - $signalType = 'sms'; - } - - return [$platform, $signalType, $rest]; - } - - /** - * Создаёт deal-копию для одного Project, обрабатывает дубль. - * - * @return bool true если создана не-дубль deal (charge применился); - * false если дубль (без charge). - */ - private function createDealCopyForProject( - SupplierLead $lead, - Project $project, - DuplicateDetector $duplicateDetector, - NotificationService $notifier, - ): bool { - return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier): bool { - DB::statement("SET LOCAL app.current_tenant_id = '{$project->tenant_id}'"); - - $tenant = Tenant::query() - ->whereKey($project->tenant_id) - ->lockForUpdate() - ->firstOrFail(); - - $receivedAt = isset($lead->raw_payload['time']) - ? Carbon::createFromTimestamp((int) $lead->raw_payload['time']) - : $lead->received_at; - - $deal = Deal::create([ - 'tenant_id' => $tenant->id, - 'source_crm_id' => $lead->vid, - 'project_id' => $project->id, - 'phone' => $lead->phone, - 'phones' => $lead->raw_payload['phones'] ?? [$lead->phone], - 'status' => 'new', - 'received_at' => $receivedAt, - ]); - - $master = $duplicateDetector->findMaster( - tenantId: $tenant->id, - phone: $lead->phone, - now: $receivedAt, - ); - - if ($master !== null && $master->id !== $deal->id) { - $deal->update(['duplicate_of_id' => $master->id]); - ActivityLog::create([ - 'tenant_id' => $tenant->id, - 'user_id' => null, - 'deal_id' => $deal->id, - 'event' => ActivityLog::EVENT_DEAL_CREATED, - 'context' => [ - 'source' => 'supplier_webhook', - 'duplicate_of' => $master->id, - 'supplier_lead_id' => $lead->id, - ], - 'created_at' => now(), - ]); - return false; - } - - // Не-дубль: charge + increment + activity + notify - $tenant->decrement('balance_leads'); - $tenant->refresh(); - - $project->increment('delivered_today'); - $project->increment('delivered_in_month'); - - BalanceTransaction::create([ - 'tenant_id' => $tenant->id, - 'type' => BalanceTransaction::TYPE_LEAD_CHARGE, - 'amount_leads' => -1, - 'balance_leads_after' => (int) $tenant->balance_leads, - 'related_type' => Deal::class, - 'related_id' => $deal->id, - 'created_at' => now(), - ]); - - ActivityLog::create([ - 'tenant_id' => $tenant->id, - 'user_id' => null, - 'deal_id' => $deal->id, - 'event' => ActivityLog::EVENT_DEAL_CREATED, - 'context' => [ - 'source' => 'supplier_webhook', - 'supplier_lead_id' => $lead->id, - ], - 'created_at' => now(), - ]); - - $deal->setRelation('project', $project); - $notifier->notifyNewLead($tenant, $deal); - - return true; - }); - } - - public function failed(Throwable $e): void - { - DB::table('failed_webhook_jobs')->insert([ - 'tenant_id' => null, // multi-tenant routing — не привязан к одному - 'webhook_log_id' => null, - 'raw_payload' => json_encode([ - 'supplier_lead_id' => $this->supplierLeadId, - ], JSON_UNESCAPED_UNICODE), - 'exception' => $e->getMessage(), - 'retry_count' => $this->tries, - 'failed_at' => now(), - ]); - - SupplierLead::query() - ->whereKey($this->supplierLeadId) - ->update(['error' => $e->getMessage()]); - - Log::error('supplier_lead.routing_failed_permanently', [ - 'supplier_lead_id' => $this->supplierLeadId, - 'exception' => $e->getMessage(), - ]); - } -} -``` - -- [ ] **Step 3: Run test** - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Jobs/RouteSupplierLeadJobTest.php -``` - -Expected: PASS (4 tests). - -- [ ] **Step 4: Larastan check** - -Run: - -```powershell -cd app; .\vendor\bin\phpstan analyse -``` - -Expected: 0 errors. - -- [ ] **Step 5: Commit** - -```bash -git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Jobs/RouteSupplierLeadJobTest.php -git commit -m "$(cat <<'EOF' -feat(jobs): RouteSupplierLeadJob — sharing-model deal-copies + charge per tenant - -Распределяет supplier_lead по eligible Liderra-проектам через LeadRouter. -Для каждого: транзакция с SET LOCAL app.current_tenant_id, lockForUpdate, -DuplicateDetector check, balance_leads--, delivered_today/month++, -BalanceTransaction, ActivityLog, NotificationService::notifyNewLead. - -Spec §5-§6. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 6: SupplierWebhookController - -**Files:** - -- Create: `app/app/Http/Controllers/Api/SupplierWebhookController.php` -- Create: `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php` - -**Контекст:** HTTP-слой. Шаги: - -1. Сравнивает `{secret}` URL-segment с `system_settings.supplier_webhook_secret` через `hash_equals`. Несовпадение → **404** (не палим существование endpoint'а, как HMAC в legacy). -2. Если `system_settings.supplier_ip_allowlist` непустой массив — проверяет `request.ip()` против него (поддержка CIDR через `IpUtils::checkIp`). Несовпадение → **404**. -3. Валидирует payload: `vid:int>0`, `project:string max:255`, `phone:string regex 7XXXXXXXXXX`, `time:int>0`. Опциональные: `tag`, `phones[]`. -4. INSERT в `supplier_leads` (raw_payload + vid + phone + platform parsed). UNIQUE на vid: при дубле возвращаем 200 OK без INSERT (idempotency). -5. Dispatch `RouteSupplierLeadJob`. -6. Возврат 202 с `supplier_lead_id`. - -⚠️ Для Plan 2 secret = `system_settings.supplier_webhook_secret`. Default placeholder `__SET_ON_DEPLOY__` отвергается hash_equals (не равен валидному secret) — так что dev-тесты сами SEED'ят валидный secret. - -- [ ] **Step 1: Failing test** - -Файл `app/tests/Feature/Http/Webhook/SupplierWebhookTest.php`: - -```php -where('key', 'supplier_webhook_secret')->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']); - SystemSetting::query()->where('key', 'supplier_ip_allowlist')->update(['value' => '[]']); -}); - -it('returns 404 for invalid secret', function (): void { - $response = $this->postJson('/api/webhook/supplier/wrong-secret', [ - 'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(), - ]); - $response->assertStatus(404); -}); - -it('returns 404 if IP not in allowlist (when allowlist non-empty)', function (): void { - SystemSetting::query()->where('key', 'supplier_ip_allowlist') - ->update(['value' => '["1.2.3.4", "10.0.0.0/24"]']); - - $response = $this->withServerVariables(['REMOTE_ADDR' => '5.6.7.8']) - ->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(), - ]); - $response->assertStatus(404); -}); - -it('passes IP allowlist when IP matches CIDR', function (): void { - SystemSetting::query()->where('key', 'supplier_ip_allowlist') - ->update(['value' => '["10.0.0.0/24"]']); - Bus::fake(); - - $response = $this->withServerVariables(['REMOTE_ADDR' => '10.0.0.50']) - ->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(), - ]); - $response->assertStatus(202); -}); - -it('inserts supplier_lead row + dispatches RouteSupplierLeadJob', function (): void { - Bus::fake(); - - $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 432176649, - 'project' => 'B1_vashinvestor.ru', - 'tag' => 'Ваш инвестор', - 'phone' => '79991234567', - 'phones' => ['79991234567'], - 'time' => 1703781939, - ]); - - $response->assertStatus(202); - expect(SupplierLead::where('vid', 432176649)->exists())->toBeTrue(); - Bus::assertDispatched(\App\Jobs\RouteSupplierLeadJob::class); -}); - -it('returns 200 OK on duplicate vid (idempotency)', function (): void { - SupplierLead::factory()->create(['vid' => 12345]); - Bus::fake(); - - $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 12345, - 'project' => 'B1_test.ru', - 'phone' => '79991234567', - 'time' => time(), - ]); - - $response->assertStatus(200); - expect(SupplierLead::where('vid', 12345)->count())->toBe(1); - Bus::assertNotDispatched(\App\Jobs\RouteSupplierLeadJob::class); -}); - -it('rejects invalid payload (missing vid) with 422', function (): void { - $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'project' => 'B1_test.ru', 'phone' => '79991234567', 'time' => time(), - ]); - $response->assertStatus(422)->assertJsonValidationErrors('vid'); -}); - -it('rejects invalid phone format (not 7XXXXXXXXXX) with 422', function (): void { - $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 1, 'project' => 'B1_test.ru', 'phone' => '89991234567', 'time' => time(), - ]); - $response->assertStatus(422); -}); - -it('rejects invalid project format (no B[123]_ prefix) with 422', function (): void { - $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => 1, 'project' => 'invalid_format', 'phone' => '79991234567', 'time' => time(), - ]); - $response->assertStatus(422); -}); -``` - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Http/Webhook/SupplierWebhookTest.php -``` - -Expected: FAIL (route not found). - -- [ ] **Step 2: Implement controller** - -Файл `app/app/Http/Controllers/Api/SupplierWebhookController.php`: - -```php -verifySecret($secret)) { - return response()->json(['message' => 'Not found.'], 404); - } - - if (! $this->verifyIpAllowlist($request->ip())) { - return response()->json(['message' => 'Not found.'], 404); - } - - $validated = $request->validate([ - 'vid' => 'required|integer|min:1', - 'project' => ['required', 'string', 'max:255', 'regex:/^B[123]_.+$/'], - 'phone' => ['required', 'string', 'regex:/^7\d{10}$/'], - 'time' => 'required|integer|min:1', - 'tag' => 'nullable|string|max:255', - 'phones' => 'nullable|array', - 'phones.*' => 'string|regex:/^7\d{10}$/', - ]); - - // Idempotency: vid UNIQUE. - $existing = SupplierLead::query()->where('vid', $validated['vid'])->first(); - if ($existing !== null) { - return response()->json([ - 'status' => 'already_processed', - 'supplier_lead_id' => $existing->id, - ], 200); - } - - $platform = $this->parsePlatform($validated['project']); - - $lead = SupplierLead::create([ - 'platform' => $platform, - 'raw_payload' => $validated, - 'vid' => $validated['vid'], - 'phone' => $validated['phone'], - 'received_at' => now(), - 'source' => 'webhook', - ]); - - RouteSupplierLeadJob::dispatch($lead->id); - - return response()->json([ - 'status' => 'accepted', - 'supplier_lead_id' => $lead->id, - ], 202); - } - - private function verifySecret(string $providedSecret): bool - { - $row = DB::table('system_settings')->where('key', 'supplier_webhook_secret')->first(); - if ($row === null) { - return false; - } - $expected = (string) $row->value; - // Placeholder __SET_ON_DEPLOY__ всегда отвергаем (не валидный secret). - if ($expected === '__SET_ON_DEPLOY__' || strlen($expected) < 32) { - return false; - } - - return hash_equals($expected, $providedSecret); - } - - private function verifyIpAllowlist(string $clientIp): bool - { - $row = DB::table('system_settings')->where('key', 'supplier_ip_allowlist')->first(); - if ($row === null) { - return true; // нет настройки = пропускаем (dev) - } - $list = json_decode((string) $row->value, true) ?: []; - if ($list === []) { - return true; // пустой массив = пропускаем (dev) - } - - return IpUtils::checkIp($clientIp, $list); - } - - private function parsePlatform(string $project): string - { - preg_match('/^(B[123])_/', $project, $m); - - return $m[1] ?? 'B1'; - } -} -``` - -- [ ] **Step 3: Run test** - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Http/Webhook/SupplierWebhookTest.php -``` - -Expected: после Task 7 (route) — PASS (8 tests). На этом этапе — все 404 (no route), кроме того теста, что регистрировал маршрут. Возможен скип до Task 7 — но запустить можно. - -- [ ] **Step 4: Larastan** - -Run: - -```powershell -cd app; .\vendor\bin\phpstan analyse -``` - -Expected: 0 errors. - -- [ ] **Step 5: Commit** - -```bash -git add app/app/Http/Controllers/Api/SupplierWebhookController.php app/tests/Feature/Http/Webhook/SupplierWebhookTest.php -git commit -m "$(cat <<'EOF' -feat(http): SupplierWebhookController — platform-wide /api/webhook/supplier/{secret} - -Defense-in-depth: secret (≥32 chars system_setting) + IP allowlist (CIDR). -Несовпадение → 404. UNIQUE vid → 200 OK на дубль (idempotency). - -Spec §5.1. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 7: Register route + run all webhook tests - -**Files:** - -- Modify: `app/routes/web.php` (line ~127) - -- [ ] **Step 1: Edit `app/routes/web.php`** - -После строки `Route::post('/api/webhook/{token}', ...)` (legacy line ~126), **до** SPA-routes (line 137), добавить: - -```php -// Supplier-integration webhook (Plan 2/5, spec §5.1). -// Platform-wide endpoint: единый {secret} в URL для всех лидов от crm.bp-gr.ru. -// Auth: secret (system_settings.supplier_webhook_secret) + IP allowlist -// (system_settings.supplier_ip_allowlist). Не пересекается с legacy /api/webhook/{token}. -Route::post('/api/webhook/supplier/{secret}', 'App\Http\Controllers\Api\SupplierWebhookController@receive') - ->where('secret', '[A-Za-z0-9_\-]+'); -``` - -- [ ] **Step 2: Run webhook tests** - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Http/Webhook/ -``` - -Expected: PASS (8 tests из SupplierWebhookTest). Если есть legacy webhook tests в той же директории — их тоже PASS (regression check). - -- [ ] **Step 3: Run full Pest** - -Run: - -```powershell -cd app; .\vendor\bin\pest -``` - -Expected: ≥510/508 (Plan 1 baseline 500/498 + новые ~10–14 тестов из Plan 2 Tasks 1–6). - -- [ ] **Step 4: Commit** - -```bash -git add app/routes/web.php -git commit -m "$(cat <<'EOF' -feat(routes): register POST /api/webhook/supplier/{secret} - -Spec §5.1 supplier-webhook endpoint. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 8: End-to-end integration test - -**Files:** - -- Create: `app/tests/Feature/Integration/SupplierLeadFlowTest.php` - -**Контекст:** Полный сценарий «webhook → routing → N deals» в одном тесте без `Bus::fake()`. Проверяет, что HTTP+job совместно дают ожидаемый эффект на БД. - -- [ ] **Step 1: Write integration test** - -Файл `app/tests/Feature/Integration/SupplierLeadFlowTest.php`: - -```php -where('key', 'supplier_webhook_secret') - ->update(['value' => 'test-secret-32chars-aaaaaaaaaaaaaa']); -}); - -it('end-to-end: 1 webhook → 3 deal copies for 3 active tenants', function (): void { - // Setup: 1 supplier_project на сайт vashinvestor.ru / B1 - $supplier = SupplierProject::factory()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'vashinvestor.ru', - ]); - - // 3 активных tenant'а с проектом, привязанным к этому supplier_project - $tenants = collect(); - $projects = collect(); - for ($i = 0; $i < 3; $i++) { - $t = Tenant::factory()->create(['balance_leads' => 100]); - $tenants->push($t); - $projects->push(Project::factory()->create([ - 'tenant_id' => $t->id, - 'supplier_b1_project_id' => $supplier->id, - 'signal_type' => 'site', - 'signal_identifier' => 'vashinvestor.ru', - 'is_active' => true, - 'delivered_today' => 0, - 'delivered_in_month' => 0, - 'delivery_days_mask' => 127, - 'region_mask' => 255, - 'region_mode' => 'include', - 'daily_limit_target' => 10, - 'effective_daily_limit_today' => null, - ])); - } - - // 4-й tenant с paused проектом — НЕ должен получить лид - $pausedTenant = Tenant::factory()->create(['balance_leads' => 100]); - Project::factory()->create([ - 'tenant_id' => $pausedTenant->id, - 'supplier_b1_project_id' => $supplier->id, - 'is_active' => false, // ← paused - ]); - - // Act: webhook - $vid = 432176649; - $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => $vid, - 'project' => 'B1_vashinvestor.ru', - 'tag' => 'Ваш инвестор', - 'phone' => '79991234567', - 'phones' => ['79991234567'], - 'time' => 1703781939, - ]); - - $response->assertStatus(202); - - // Assert: 3 deals created (по одной на активный tenant), paused — 0 - foreach ($projects as $i => $project) { - $tenant = $tenants[$i]; - DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); - - $deals = Deal::query()->where('source_crm_id', $vid)->get(); - expect($deals)->toHaveCount(1, "tenant {$tenant->id} expected 1 deal copy"); - - $deal = $deals->first(); - expect($deal->phone)->toBe('79991234567'); - expect($deal->project_id)->toBe($project->id); - expect($deal->duplicate_of_id)->toBeNull(); // не дубль (нет master) - - expect($tenant->fresh()->balance_leads)->toBe(99); // 100-1 - expect($project->fresh()->delivered_today)->toBe(1); - expect($project->fresh()->delivered_in_month)->toBe(1); - } - - // Paused tenant — 0 deals - DB::statement("SET LOCAL app.current_tenant_id = '{$pausedTenant->id}'"); - expect(Deal::query()->where('source_crm_id', $vid)->count())->toBe(0); -}); - -it('end-to-end: lead orphan (no supplier_project / no tenants) — 0 deals, lead stored', function (): void { - $vid = 999_888_777; - - $response = $this->postJson('/api/webhook/supplier/test-secret-32chars-aaaaaaaaaaaaaa', [ - 'vid' => $vid, - 'project' => 'B1_orphan.ru', - 'phone' => '79991234567', - 'time' => time(), - ]); - - $response->assertStatus(202); - - $lead = \App\Models\SupplierLead::where('vid', $vid)->firstOrFail(); - expect($lead->processed_at)->not->toBeNull(); - expect($lead->deals_created_count)->toBe(0); - expect($lead->supplier_project_id)->not->toBeNull(); // resolveOrStub создаёт stub -}); -``` - -- [ ] **Step 2: Run E2E test** - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Integration/SupplierLeadFlowTest.php -``` - -Expected: PASS (2 tests). NB: тесты гоняют job синхронно через Laravel sync queue в test-env (default). - -- [ ] **Step 3: Commit** - -```bash -git add app/tests/Feature/Integration/SupplierLeadFlowTest.php -git commit -m "$(cat <<'EOF' -test(integration): supplier webhook → N deals end-to-end (sharing-model) - -Полный сценарий: 1 webhook на B1_vashinvestor.ru → 3 deal-копии у 3 tenant'ов -+ счётчики + balance. Paused tenant пропущен. Orphan supplier_project — stub. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 9: ResetDeliveredTodayCommand + cron schedule - -**Files:** - -- Create: `app/app/Console/Commands/ResetDeliveredTodayCommand.php` -- Create: `app/tests/Feature/Console/ResetDeliveredTodayCommandTest.php` -- Modify: `app/bootstrap/app.php` OR `app/routes/console.php` (зависит от существующего convention) - -**Контекст:** Spec §6.1 — cron в 00:00 МСК сбрасывает `delivered_today=0`. Простой UPDATE. - -- [ ] **Step 1: Failing test** - -Файл `app/tests/Feature/Console/ResetDeliveredTodayCommandTest.php`: - -```php -create(); - $t2 = Tenant::factory()->create(); - - Project::factory()->create(['tenant_id' => $t1->id, 'delivered_today' => 5]); - Project::factory()->create(['tenant_id' => $t1->id, 'delivered_today' => 3]); - Project::factory()->create(['tenant_id' => $t2->id, 'delivered_today' => 10]); - - $this->artisan('projects:reset-delivered-today')->assertSuccessful(); - - $allZero = DB::selectOne('SELECT COUNT(*) FILTER (WHERE delivered_today = 0) AS c, COUNT(*) AS total FROM projects'); - expect($allZero->c)->toBe($allZero->total); -}); - -it('does not touch delivered_in_month', function (): void { - DB::statement("SELECT set_config('app.current_tenant_id', '0', true)"); - $t = Tenant::factory()->create(); - Project::factory()->create([ - 'tenant_id' => $t->id, - 'delivered_today' => 5, - 'delivered_in_month' => 50, - ]); - - $this->artisan('projects:reset-delivered-today')->assertSuccessful(); - - $row = DB::selectOne('SELECT delivered_today, delivered_in_month FROM projects LIMIT 1'); - expect($row->delivered_today)->toBe(0); - expect($row->delivered_in_month)->toBe(50); -}); -``` - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Console/ResetDeliveredTodayCommandTest.php -``` - -Expected: FAIL (command not found). - -- [ ] **Step 2: Implement command** - -Файл `app/app/Console/Commands/ResetDeliveredTodayCommand.php`: - -```php - 0'); - - $this->info("Reset delivered_today on {$affected} project(s)."); - - return self::SUCCESS; - } -} -``` - -- [ ] **Step 3: Register schedule** - -⚠️ **Сначала проверить convention.** Laravel 11+ default — schedule в `bootstrap/app.php` через `->withSchedule(function (Schedule $schedule) { ... })`. Если в `app/bootstrap/app.php` уже есть `withSchedule` — добавить туда. Иначе — в `app/routes/console.php` через facade `Schedule::command(...)`. - -Run: - -```powershell -cd app; grep -n "withSchedule\|Schedule::" bootstrap/app.php routes/console.php -``` - -Если в `bootstrap/app.php` — паттерн: - -```php -->withSchedule(function (\Illuminate\Console\Scheduling\Schedule $schedule): void { - $schedule->command('projects:reset-delivered-today') - ->dailyAt('00:00') - ->timezone('Europe/Moscow') - ->withoutOverlapping(); -}) -``` - -Если в `routes/console.php` — паттерн: - -```php -use Illuminate\Support\Facades\Schedule; - -Schedule::command('projects:reset-delivered-today') - ->dailyAt('00:00') - ->timezone('Europe/Moscow') - ->withoutOverlapping(); -``` - -- [ ] **Step 4: Verify command shows in schedule:list** - -Run: - -```powershell -cd app; .\artisan schedule:list -``` - -Expected: видим `projects:reset-delivered-today` в расписании daily 00:00 (Europe/Moscow). - -- [ ] **Step 5: Run tests** - -Run: - -```powershell -cd app; .\vendor\bin\pest tests/Feature/Console/ResetDeliveredTodayCommandTest.php -``` - -Expected: PASS (2 tests). - -- [ ] **Step 6: Commit** - -```bash -git add app/app/Console/Commands/ResetDeliveredTodayCommand.php app/tests/Feature/Console/ResetDeliveredTodayCommandTest.php app/bootstrap/app.php app/routes/console.php -git commit -m "$(cat <<'EOF' -feat(commands): projects:reset-delivered-today (00:00 МСК cron) - -Spec §6.1: ежедневный сброс projects.delivered_today=0. -delivered_in_month НЕ трогаем (это месячный счётчик, Plan 4 cron). - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - ---- - -## Task 10: Comprehensive Verification Gate (CV) - -**Files:** none (verification commands). - -Запустить все проверки последовательно. Все должны быть green. - -- [ ] **CV.1: pgFormatter — schema.sql** - -Run: - -```powershell -cd "c:\моя\проекты\портал crm\Документация"; npm run format:sql:check -``` - -Expected: 0 diff. - -- [ ] **CV.2: squawk — schema.sql** - -Run: - -```powershell -cd "c:\моя\проекты\портал crm\Документация"; .\bin\squawk.exe db/schema.sql -``` - -Expected: 0 issues. - -- [ ] **CV.3: cspell + markdownlint — все .md изменения** - -Run: - -```powershell -cd "c:\моя\проекты\портал crm\Документация"; npm run lint:md; npm run spell -``` - -Expected: 0 issues. Если cspell ловит technical terms — добавить в `app/cspell-words.txt` (project convention). - -- [ ] **CV.4: lychee — links** - -Run: - -```powershell -cd "c:\моя\проекты\портал crm\Документация"; npm run links -``` - -Expected: 0 broken links. - -- [ ] **CV.5: Larastan — PHP static analysis** - -Run: - -```powershell -cd app; .\vendor\bin\phpstan analyse -``` - -Expected: 0 errors (Plan 1 baseline сохранён). - -- [ ] **CV.6: Pint — PHP formatting** - -Run: - -```powershell -cd app; .\vendor\bin\pint --test -``` - -Expected: 0 changes. - -- [ ] **CV.7: Pest full suite** - -Run: - -```powershell -cd app; .\vendor\bin\pest --parallel=4 -``` - -Expected: 510+ passed (Plan 1 baseline 500/498 + Plan 2 ~14 новых: 5 schema + 4 SupplierLead + 9 PhonePrefix + 9 LeadRouter + 4 RouteJob + 8 controller + 2 E2E + 2 reset cmd ≈ 43 новых, → ~543/541). - -⚠️ Если падают transient connection failures (Plan 1 learning #16) на параллели — retry; не блокер. - -- [ ] **CV.8: migrate:fresh — testing DB** - -Run: - -```powershell -cd app -$env:DB_DATABASE = 'liderra_testing' -.\artisan migrate:fresh --env=testing -``` - -Expected: успешно (0 ошибок). Schema v8.18 разворачивается за <2 сек. - -- [ ] **CV.9: ide-helper:models — re-generate** - -Run: - -```powershell -cd app; .\artisan ide-helper:models -W -M -N -``` - -Expected: `_ide_helper_models.php` обновлён, все модели включая `SupplierLead`. - -- [ ] **CV.10: gitleaks — secrets scan** - -Run: - -```powershell -cd "c:\моя\проекты\портал crm\Документация"; .\bin\gitleaks.exe detect --no-git -``` - -Expected: 0 leaks. (Тестовый secret `test-secret-32chars-aaaaaaaaaaaaaa` — допустим в test-files; если gitleaks ругается — добавить в allowlist.) - -- [ ] **CV.11: Code-reviewer subagent (parallel)** - -Запустить через `Agent` tool с двумя subagent'ами: - -``` -Subagent 1 (general-purpose): - Audit Plan 2 implementation (commits since 001d781) for: - - Logic errors / off-by-one / typos in DDL и PHP - - Race conditions в RouteSupplierLeadJob (lockForUpdate sufficient?) - - RLS bypass concerns (LeadRouter без SET LOCAL — корректно для sharing?) - - SQL injection / XSS surfaces в SupplierWebhookController - - Idempotency edge cases (UNIQUE vid, retry behavior) - Report: BLOCKER/WARN/NIT (как Plan 1). - -Subagent 2 (Explore): - Cross-check spec §5–§6 vs implementation. Перечислить конкретные строки spec'а - и какой файл/функция их реализует. Найти gaps. -``` - -Expected: либо clean, либо findings → закрыть как Plan 1 closure (BLOCKER → fix перед merge, WARN → judge case-by-case, NIT → defer to Plan 3 NOTES). - -- [ ] **CV.12: route:list verification** - -Run: - -```powershell -cd app; .\artisan route:list --path=webhook -``` - -Expected: 2 webhook routes: - -- POST `/api/webhook/{token}` (legacy) -- POST `/api/webhook/supplier/{secret}` (new) - -- [ ] **CV.13: schedule:list verification** - -Run: - -```powershell -cd app; .\artisan schedule:list -``` - -Expected: видим `projects:reset-delivered-today` daily 00:00 Europe/Moscow. - -- [ ] **CV.14: dependency cycle check** - -Run: - -```powershell -cd app; .\vendor\bin\phpstan analyse --error-format=table | Select-String -Pattern "circular" -``` - -Expected: 0 циклов. - ---- - -## Task 11: Update memory + final commit + push - -- [ ] **Step 1: Update `memory/project_supplier_integration.md`** - -Изменить статус Plan 2: `pending` → `✅ DONE`. Добавить раздел «Plan 2 итоги» с метриками (commits, tests added, schema bump). Если по результатам code-review subagent'а в CV.11 нашлись WARN/NIT для Plan 3 NOTES — записать в `## Что в плане 3 ... использовать` секцию. - -- [ ] **Step 2: Update `memory/project_state.md`** - -Schema v8.17 → **v8.18**. Pest count → **543/541**. HEAD origin/main → новый SHA после FF-merge. Plan 2 fully merged. - -- [ ] **Step 3: Run lefthook pre-commit + pre-push (через actual commit)** - -```bash -git add memory/*.md -git commit -m "$(cat <<'EOF' -chore(memory): close Plan 2/5 (Webhook + Sharing Routing) - -Schema v8.18, Pest 543/541, +43 новых теста, 11 commits. -HEAD origin/main = . - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -- [ ] **Step 4: Push to remote** - -⚠️ Если работа велась в feature-branch — FF-merge в main как Plan 1. Если в main — push сразу. - -```bash -git push origin main -``` - -Expected: pre-push lefthook (gitleaks-full-history + lychee) PASS, push successful. - -- [ ] **Step 5: Verify GitHub state** - -Run: - -```bash -git ls-remote origin HEAD -``` - -Expected: SHA совпадает с локальным `git rev-parse HEAD`. - ---- - -## Self-review checklist - -Spec coverage (галки только если соответствующая Task реализует): - -- [x] §5.1 webhook receive (IP allowlist + secret + payload parse) — Task 6 -- [x] §5.1 INSERT supplier_leads + RouteLead dispatch — Task 1, 5, 6 -- [x] §5.1 secret защита (≥32 chars + hash_equals) — Task 6 -- [x] §5.1 IP allowlist (CIDR через IpUtils) — Task 6 -- [x] §5.1 невалид → 404 (не палим endpoint) — Task 6 -- [x] §5.2 CSV reconciliation — **DEFERRED** в Plan 4 (нужны Playwright/HTTP-клиент infrastructure из Plan 3) -- [x] §6 step 1 SupplierProject lookup — Task 5 (resolveOrStub) -- [x] §6 step 2 регион определяем по DEF-кодам — Task 3 (PhonePrefixService MVP) -- [x] §6 step 3 фильтры (active/workdays/quota/region) — Task 4 (LeadRouter) -- [x] §6 step 4 сортировка created_at ASC — Task 4 -- [x] §6 step 5 deal copy + counter inc + balance dec + notify — Task 5 -- [x] §6 step 6 sharing N deals — Task 5 -- [x] §6 step 7 пропускаем превышенных — Task 4 -- [x] §6.1 cron 00:00 reset delivered_today — Task 9 -- [ ] §6 step 5 «Списываем по текущей ступени pricing_tiers» — **DEFERRED в Plan 4** (Plan 2 использует existing balance_leads decrement) -- [x] Биз-19 дедуп по phone (24ч окно) — Task 5 - -Placeholder scan: TODO/TBD/«implement later» — нет (только в spec §10 Open Questions, исторические). - -Type consistency: `SupplierLead`, `SupplierProject::scopeForSignal`, `LeadRouter::matchEligibleProjects`, `RouteSupplierLeadJob::handle(LeadRouter, ...)` — все типы согласованы. - ---- - -## Execution Handoff - -Plan complete and saved to `docs/superpowers/plans/2026-05-10-supplier-webhook-routing-plan.md`. - -**Two execution options:** - -1. **Subagent-Driven (recommended)** — fresh subagent per task + 2-stage review между. -2. **Inline Execution** — гонит таски в текущей сессии через `superpowers:executing-plans`. - -**Which approach?** diff --git a/docs/superpowers/plans/2026-05-11-plan4-billing-csv-admin-plan.md b/docs/superpowers/plans/2026-05-11-plan4-billing-csv-admin-plan.md deleted file mode 100644 index 8945635..0000000 --- a/docs/superpowers/plans/2026-05-11-plan4-billing-csv-admin-plan.md +++ /dev/null @@ -1,4634 +0,0 @@ -# Plan 4 (Billing + CSV Reconcile + Admin) 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:** Активировать ступенчатый биллинг клиентов Лидерры (pricing_tiers / lead_charges никем не читались/писались), закрыть TODO «Биллинг per Plan 4» в [RouteSupplierLeadJob.php:48](../../app/app/Jobs/RouteSupplierLeadJob.php#L48); реализовать резервный CSV-канал приёма лидов через `/admin/report/index?type=49` поставщика; собрать Admin UI (2 SaaS-admin страницы + 1 tab в существующем BillingView). - -**Architecture:** 12 Tasks в 3 фазах. Фаза I — Billing core (Tasks 1–4): schema delta v8.18→v8.19, PricingTierResolver (pure), LedgerService с dual-balance prepaid-first логикой, integration в RouteSupplierLeadJob. Фаза II — Operations (Tasks 5–8): monthly reset cron, auto-pause flow с email rate-limit, CSV reconcile через расширение SupplierPortalClient + новый CsvReconcileJob hourly. Фаза III — UI (Tasks 9–12): pricing-tiers editor, supplier-prices editor, tenant ChargesTab + CSV export, финальная verification. - -**Tech Stack:** PHP 8.3, Laravel 13.7, Pest 4, PostgreSQL 16 (`pgsql_supplier` BYPASSRLS connection из Plan 3 `7899071`), Redis 7 (Memurai на dev), Vue 3 + Vuetify 3, Vitest 4, Histoire 1.0-beta, OpenSpout (CSV streaming), bcmath (денежные сравнения), Unisender Go (email алерты). - -**Parent spec:** [2026-05-11-plan4-billing-csv-admin-design.md](../specs/2026-05-11-plan4-billing-csv-admin-design.md) (commit `901cf98`). -**Inherits from:** Plan 1 Foundation `001d781` + Plan 2 Webhook+Routing `d5aa972` + Plan 2.5 hotfix `c1ae195`+`1ba1df8` + Plan 2.6 cleanup `7899071` + Plan 3 Supplier Sync `734b0ab`. - ---- - -## Карта файлов - -| Файл | Действие | Назначение | Task | -|---|---|---|---| -| [db/schema.sql](../../db/schema.sql) | Modify | +1 таблица supplier_csv_reconcile_log, +3 колонки, +3 индекса, +2 CHECK, bump v8.18→v8.19 | 1 | -| [db/CHANGELOG_schema.md](../../db/CHANGELOG_schema.md) | Modify | Запись v8.19 | 1 | -| [db/02_grants.sql](../../db/02_grants.sql) | Modify | +GRANT для supplier_csv_reconcile_log | 1 | -| `app/database/seeders/PricingTierSeeder.php` | Create | 7 дефолтных ступеней (effective_from='1970-01-01') | 1 | -| `app/database/seeders/DatabaseSeeder.php` | Modify | Подключить PricingTierSeeder | 1 | -| `app/app/Models/Tenant.php` | Modify | +`delivered_in_month` в `$fillable` + `casts` | 1 | -| `app/app/Models/LeadCharge.php` | Modify | +`charge_source` в `$fillable` + cast | 1 | -| `app/app/Models/SupplierLead.php` | Modify | +`recovered_from_csv_at` в `$fillable` + cast | 1 | -| `app/database/factories/LeadChargeFactory.php` | Modify | +`charge_source='rub'` дефолт | 1 | -| `app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php` | Create | Тесты: 4 новых колонки/таблица существуют, CHECK работает, idempotent fresh | 1 | -| `app/app/Services/Billing/PricingTierResolver.php` | Create | Pure resolver: «в какую ступень попадает N-й лид» | 2 | -| `app/tests/Unit/Billing/PricingTierResolverTest.php` | Create | 7 unit-тестов на math distribution | 2 | -| `app/app/Repositories/PricingTierRepository.php` | Create | DB-обёртка для `current()` ступеней | 2 | -| `app/tests/Feature/Billing/PricingTierRepositoryTest.php` | Create | 4 integration-теста (active/scheduled/empty) | 2 | -| `app/app/Exceptions/Billing/InsufficientBalanceException.php` | Create | DTO-exception с priceKopecks/balanceRub/balanceLeads | 3 | -| `app/app/Services/Billing/ChargeResult.php` | Create | Read-only DTO (source, tier, priceKopecks) | 3 | -| `app/app/Services/Billing/LedgerService.php` | Create | chargeForDelivery: dual-balance flow + lead_charges/supplier_lead_costs/balance_transactions INSERT | 3 | -| `app/tests/Feature/Billing/LedgerServiceTest.php` | Create | 6 integration-тестов на charge-flow | 3 | -| [app/app/Jobs/RouteSupplierLeadJob.php](../../app/app/Jobs/RouteSupplierLeadJob.php) | Modify | Заменить строки 265-279 на LedgerService::chargeForDelivery + try/catch InsufficientBalance | 4 | -| `app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php` | Create | 4 E2E теста sharing-flow с биллингом | 4 | -| `app/app/Console/Commands/ResetMonthlyCountersCommand.php` | Create | `projects:reset-monthly` для tenants + projects | 5 | -| `app/routes/console.php` | Modify | +Schedule monthlyOn(1, '00:00') Europe/Moscow | 5 | -| `app/tests/Feature/Console/ResetMonthlyCountersCommandTest.php` | Create | 4 теста idempotency + schedule введён | 5 | -| `app/app/Mail/ZeroBalancePausedMail.php` | Create | Mailable для алерта о паузе проекта | 6 | -| `app/resources/views/emails/zero_balance_paused.blade.php` | Create | Blade-шаблон email на русском | 6 | -| `app/app/Services/NotificationService.php` | Modify | +`notifyZeroBalance(Tenant, Project)` метод | 6 | -| [app/app/Jobs/RouteSupplierLeadJob.php](../../app/app/Jobs/RouteSupplierLeadJob.php) | Modify | +`handleInsufficientBalance` private method | 6 | -| `app/tests/Feature/Supplier/AutoPauseFlowTest.php` | Create | 5 тестов: pause + email + rate-limit + isolation | 6 | -| `app/app/Services/Supplier/SupplierCsvParser.php` | Create | Streaming-generator CSV parser | 7 | -| `app/tests/Unit/Supplier/SupplierCsvParserTest.php` | Create | 5 unit-тестов (empty/1row/1000rows/malformed/BOM) | 7 | -| [app/app/Services/Supplier/SupplierPortalClient.php](../../app/app/Services/Supplier/SupplierPortalClient.php) | Modify | +`downloadLeadsCsv` метод | 7 | -| `app/tests/Unit/Supplier/SupplierPortalClientCsvTest.php` | Create | 3 теста через Http::fake | 7 | -| `app/app/Jobs/Supplier/CsvReconcileJob.php` | Create | Hourly CSV reconcile + drift alert | 8 | -| `app/app/Mail/CsvDriftAlertMail.php` | Create | Mailable для алерта drift > 5% | 8 | -| `app/resources/views/emails/csv_drift_alert.blade.php` | Create | Blade-шаблон email на русском | 8 | -| `app/routes/console.php` | Modify | +Schedule::job(new CsvReconcileJob)->hourly() | 8 | -| `app/tests/Feature/Supplier/CsvReconcileJobTest.php` | Create | 6 integration-тестов + drift threshold | 8 | -| `app/app/Http/Controllers/Api/AdminPricingTiersController.php` | Create | GET/POST/DELETE pricing-tiers CRUD | 9 | -| [app/routes/web.php](../../app/routes/web.php) | Modify | +Route prefix `/api/admin/pricing-tiers` | 9 | -| `app/tests/Feature/Admin/AdminPricingTiersControllerTest.php` | Create | 8 тестов (index/store/validate/delete) | 9 | -| `app/resources/js/views/admin/AdminPricingTiersView.vue` | Create | Vue 3 страница: 7-tier editor | 9 | -| `app/resources/js/views/admin/AdminPricingTiersView.story.vue` | Create | 4 Histoire variants | 9 | -| `app/resources/js/router/index.ts` | Modify | +route `/admin/pricing-tiers` | 9 | -| `app/tests/Vitest/views/admin/AdminPricingTiersView.spec.ts` | Create | 5 Vitest-тестов | 9 | -| `app/app/Http/Controllers/Api/AdminSuppliersController.php` | Create | GET/PATCH suppliers (B1/B2/B3) | 10 | -| [app/routes/web.php](../../app/routes/web.php) | Modify | +Route `/api/admin/suppliers` | 10 | -| `app/tests/Feature/Admin/AdminSuppliersControllerTest.php` | Create | 4 теста | 10 | -| `app/resources/js/views/admin/AdminSupplierPricesView.vue` | Create | Vue 3 страница: B1/B2/B3 editor | 10 | -| `app/resources/js/views/admin/AdminSupplierPricesView.story.vue` | Create | 2 Histoire variants | 10 | -| `app/resources/js/router/index.ts` | Modify | +route `/admin/supplier-prices` | 10 | -| `app/tests/Vitest/views/admin/AdminSupplierPricesView.spec.ts` | Create | 3 Vitest-теста | 10 | -| `app/app/Http/Controllers/Api/TenantChargesController.php` | Create | GET (paginated) + POST export CSV | 11 | -| [app/routes/web.php](../../app/routes/web.php) | Modify | +Route `/api/billing/charges` | 11 | -| `app/tests/Feature/Billing/TenantChargesControllerTest.php` | Create | 6 тестов (RLS isolation + pagination + filter + export) | 11 | -| `app/resources/js/views/billing/ChargesTab.vue` | Create | Vue компонент для tab «Списания» | 11 | -| `app/resources/js/views/billing/ChargesTab.story.vue` | Create | 3 Histoire variants | 11 | -| `app/resources/js/views/BillingView.vue` | Modify | Добавить ChargesTab внутри `` | 11 | -| `app/tests/Vitest/views/billing/ChargesTab.spec.ts` | Create | 4 Vitest-теста | 11 | -| All test runs / CV.1–14 verification | Verify | Финальная проверка перед FF-merge | 12 | -| [CLAUDE.md](../../CLAUDE.md) | Modify | §0 schema → v8.19; §6 фаза → Plan 4 closure (через claude-md-management) | 12 | -| [docs/Открытые_вопросы_v8_3.md](../../Открытые_вопросы_v8_3.md) | Modify | +7 новых `Биз-*` (см. §7.6 spec'а) | 12 | - ---- - -## Task 1: Schema delta v8.18 → v8.19 + models/factory/seeder updates - -**Files:** - -- Modify: [db/schema.sql](../../db/schema.sql) (4 правки: tenants column, lead_charges column+check, supplier_leads column, supplier_csv_reconcile_log table) -- Modify: [db/CHANGELOG_schema.md](../../db/CHANGELOG_schema.md) -- Modify: [db/02_grants.sql](../../db/02_grants.sql) -- Modify: `app/app/Models/Tenant.php` -- Modify: `app/app/Models/LeadCharge.php` -- Modify: `app/app/Models/SupplierLead.php` -- Modify: `app/database/factories/LeadChargeFactory.php` -- Create: `app/database/seeders/PricingTierSeeder.php` -- Modify: `app/database/seeders/DatabaseSeeder.php` -- Create: `app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php` - -- [ ] **Step 1: Написать failing schema-delta test** - -```php -= 0', function () { - expect(Schema::hasColumn('tenants', 'delivered_in_month'))->toBeTrue(); - - DB::table('tenants')->where('id', '<', 0)->update(['delivered_in_month' => 5]); // no-op - expect(fn () => DB::statement( - "INSERT INTO tenants (subdomain, organization_name, contact_email, webhook_token, delivered_in_month) ". - "VALUES ('t-neg-test', 'X', 'x@x', 'wtok-neg-test-99999999', -1)" - ))->toThrow(\Illuminate\Database\QueryException::class); -}); - -it('lead_charges table has charge_source column with CHECK on prepaid=zero-price', function () { - expect(Schema::hasColumn('lead_charges', 'charge_source'))->toBeTrue(); - - // Дефолтное значение 'rub' — проверяем через DEFAULT-инсерт. - $tenant = \App\Models\Tenant::factory()->create(); - $deal = \App\Models\Deal::factory()->create(['tenant_id' => $tenant->id]); - - // 'prepaid' + price > 0 → CHECK fails - expect(fn () => DB::table('lead_charges')->insert([ - 'tenant_id' => $tenant->id, - 'deal_id' => $deal->id, - 'deal_received_at' => $deal->received_at, - 'tier_no' => 1, - 'price_per_lead_kopecks' => 50000, - 'charge_source' => 'prepaid', - 'charged_at' => now(), - 'created_at' => now(), - ]))->toThrow(\Illuminate\Database\QueryException::class); -}); - -it('supplier_leads table has recovered_from_csv_at column', function () { - expect(Schema::hasColumn('supplier_leads', 'recovered_from_csv_at'))->toBeTrue(); -}); - -it('supplier_csv_reconcile_log table exists with required columns and status CHECK', function () { - expect(Schema::hasTable('supplier_csv_reconcile_log'))->toBeTrue(); - expect(Schema::hasColumns('supplier_csv_reconcile_log', [ - 'id', 'started_at', 'finished_at', 'window_start', 'window_end', - 'total_csv_rows', 'matched_count', 'recovered_count', 'drift_ratio', - 'status', 'error_message', 'alert_email_sent_at', 'created_at', - ]))->toBeTrue(); - - // CHECK на status - expect(fn () => DB::table('supplier_csv_reconcile_log')->insert([ - 'started_at' => now(), - 'window_start' => now()->subDay(), - 'window_end' => now(), - 'status' => 'unknown_status', - ]))->toThrow(\Illuminate\Database\QueryException::class); -}); - -it('migrate:fresh is idempotent — re-run produces same metrics', function () { - $beforeTables = count(DB::select("SELECT tablename FROM pg_tables WHERE schemaname='public'")); - - \Illuminate\Support\Facades\Artisan::call('migrate:fresh', ['--database' => 'pgsql', '--force' => true]); - - $afterTables = count(DB::select("SELECT tablename FROM pg_tables WHERE schemaname='public'")); - expect($afterTables)->toBe($beforeTables); -}); -``` - -- [ ] **Step 2: Запустить тест — должен FAIL (`supplier_csv_reconcile_log` отсутствует, колонок нет)** - -```bash -cd app && ./vendor/bin/pest --filter=SchemaDeltaTest tests/Feature/Plan4/Schema/SchemaDeltaTest.php -``` - -Expected output: 4 FAIL ("table/column does not exist"), 1 может PASS (migrate:fresh idempotent). - -- [ ] **Step 3: Применить schema-патчи** - -Открыть [db/schema.sql](../../db/schema.sql) и внести 4 правки: - -**3a. Bump version header** (строка ~7): - -```diff -- -- Базовая версия: v8.18 (10.05.2026 — Plan 2/5 webhook+routing: supplier_leads + projects.delivered_today + 2 system_settings seed) -+ -- Базовая версия: v8.19 (2026-05-11 — Plan 4 billing+csv+admin: tenants.delivered_in_month, lead_charges.charge_source + CHECK, supplier_leads.recovered_from_csv_at, supplier_csv_reconcile_log) -``` - -**3b. tenants.delivered_in_month** — добавить после строки 644 (`desired_daily_numbers`): - -```sql - -- v8.19 (Plan 4): месячный счётчик доставленных лидов per-tenant. - -- Сбрасывается ResetMonthlyCountersCommand 1-го числа в 00:00 МСК (Europe/Moscow). - -- Используется PricingTierResolver на горячем пути RouteSupplierLeadJob - -- для O(1) lookup'а текущей ступени тарифа. - delivered_in_month INT NOT NULL DEFAULT 0 - CHECK (delivered_in_month >= 0), -``` - -**3c. lead_charges.charge_source + CHECK** — внутри `CREATE TABLE lead_charges` (~строка 1001), добавить после `price_per_lead_kopecks` (~строка 1007) и перед `charged_at`: - -```sql - charge_source VARCHAR(8) NOT NULL DEFAULT 'rub' - CHECK (charge_source IN ('prepaid','rub')), -``` - -И добавить дополнительный CHECK после CREATE TABLE block перед `CREATE INDEX`: - -```sql -ALTER TABLE lead_charges - ADD CONSTRAINT chk_lead_charges_prepaid_zero_price - CHECK (charge_source = 'rub' OR price_per_lead_kopecks = 0); -``` - -**3d. supplier_leads.recovered_from_csv_at** — внутри `CREATE TABLE supplier_leads` (Plan 2, ~строка 1840+, нужно грепнуть точное место), добавить после `processed_at`: - -```bash -grep -n "CREATE TABLE supplier_leads" db/schema.sql -grep -n "processed_at" db/schema.sql | head -5 -``` - -Добавить колонку: - -```sql - -- v8.19 (Plan 4 CSV reconcile): NULL для лидов из webhook (основной канал). - -- Заполняется CsvReconcileJob при восстановлении лида, пропущенного webhook'ом. - recovered_from_csv_at TIMESTAMPTZ, -``` - -И partial index после CREATE TABLE block: - -```sql -CREATE INDEX supplier_leads_recovered_from_csv_partial - ON supplier_leads(recovered_from_csv_at) - WHERE recovered_from_csv_at IS NOT NULL; -``` - -**3e. supplier_csv_reconcile_log** — новая секция после блока `CREATE TABLE supplier_sync_log` (Plan 3, ~строка 1140; грепнуть точное место): - -```bash -grep -n "CREATE TABLE supplier_sync_log\b" db/schema.sql -``` - -Добавить ПОСЛЕ закрывающей `);` и его индексов: - -```sql - --- ----------------------------------------------------------------------------- --- supplier_csv_reconcile_log — журнал hourly CSV reconciliation (v8.19, Plan 4) --- ----------------------------------------------------------------------------- --- SaaS-level (не tenant-scoped), без RLS. Аналог supplier_sync_log. --- CsvReconcileJob записывает 1 строку на hourly run: started_at, окно, --- метрики, drift_ratio. drift > 5% → email алерт; alert_email_sent_at timestamp. --- Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.3 --- ----------------------------------------------------------------------------- -CREATE TABLE supplier_csv_reconcile_log ( - id BIGSERIAL PRIMARY KEY, - started_at TIMESTAMPTZ NOT NULL, - finished_at TIMESTAMPTZ, - window_start TIMESTAMPTZ NOT NULL, - window_end TIMESTAMPTZ NOT NULL, - total_csv_rows INTEGER, - matched_count INTEGER, - recovered_count INTEGER, - drift_ratio NUMERIC(5,4), - status VARCHAR(16) NOT NULL DEFAULT 'running' - CHECK (status IN ('running','ok','drift_alert','failed')), - error_message TEXT, - alert_email_sent_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX supplier_csv_reconcile_log_started_at_index - ON supplier_csv_reconcile_log(started_at DESC); -CREATE INDEX supplier_csv_reconcile_log_status_index - ON supplier_csv_reconcile_log(status) - WHERE status IN ('drift_alert','failed'); - --- GRANT-policy в db/02_grants.sql (для prod). Dev: postgres superuser. -``` - -- [ ] **Step 4: Обновить db/CHANGELOG_schema.md** - -Добавить запись наверх (после v8.18 entry): - -```markdown -## v8.19 (2026-05-11) — Plan 4 Billing + CSV Reconcile + Admin - -**Изменения:** -- `tenants` + колонка `delivered_in_month INTEGER NOT NULL DEFAULT 0 CHECK >= 0` (per-tenant счётчик для tier-lookup). -- `lead_charges` + колонка `charge_source VARCHAR(8) DEFAULT 'rub' CHECK IN ('prepaid','rub')` + CHECK `chk_lead_charges_prepaid_zero_price` (prepaid → price=0). -- `supplier_leads` + колонка `recovered_from_csv_at TIMESTAMPTZ` + partial index. -- Новая таблица `supplier_csv_reconcile_log` (SaaS-level, без RLS) + 2 индекса. -- 0 RLS-политик изменено. - -**Метрики:** 61 → 62 базовых таблиц / 114 → 117 индексов / 39 RLS-политик (без изменений). - -**Spec:** [docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md](../docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md). -``` - -- [ ] **Step 5: Обновить db/02_grants.sql** - -Добавить новую секцию для `supplier_csv_reconcile_log`: - -```sql --- ============================================================================= --- v8.19 (Plan 4): supplier_csv_reconcile_log — SaaS-уровневый журнал CSV recon. --- Используется CsvReconcileJob под crm_supplier_worker (BYPASSRLS). --- ============================================================================= - -GRANT SELECT, INSERT, UPDATE ON TABLE supplier_csv_reconcile_log TO crm_supplier_worker; -GRANT USAGE, SELECT ON SEQUENCE supplier_csv_reconcile_log_id_seq TO crm_supplier_worker; -``` - -- [ ] **Step 6: Обновить Eloquent-модели** - -Открыть `app/app/Models/Tenant.php`, добавить в `$fillable`: - -```php -'delivered_in_month', -``` - -В `casts()` метод (или `protected $casts` массив) — добавить: - -```php -'delivered_in_month' => 'integer', -``` - -Открыть `app/app/Models/LeadCharge.php`, добавить в `$fillable` (после `tier_no`): - -```php -'charge_source', -``` - -В `casts()`: - -```php -'charge_source' => 'string', -``` - -Открыть `app/app/Models/SupplierLead.php`, добавить в `$fillable`: - -```php -'recovered_from_csv_at', -``` - -В `casts()`: - -```php -'recovered_from_csv_at' => 'datetime', -``` - -- [ ] **Step 7: Обновить LeadChargeFactory** - -```php -// app/database/factories/LeadChargeFactory.php — заменить definition() -public function definition(): array -{ - return [ - 'tenant_id' => Tenant::factory(), - 'deal_id' => fake()->numberBetween(1, 99999), - 'deal_received_at' => now(), - 'tier_no' => fake()->numberBetween(1, 7), - 'price_per_lead_kopecks' => fake()->numberBetween(2000, 6000), - 'charge_source' => 'rub', - 'charged_at' => now(), - 'created_at' => now(), - ]; -} - -/** - * State для prepaid-списания (price=0). - */ -public function prepaid(): self -{ - return $this->state(fn () => [ - 'charge_source' => 'prepaid', - 'price_per_lead_kopecks' => 0, - ]); -} -``` - -- [ ] **Step 8: Создать PricingTierSeeder** - -```php - 1, 'leads_in_tier' => 100, 'price_per_lead_kopecks' => 50000], - ['tier_no' => 2, 'leads_in_tier' => 200, 'price_per_lead_kopecks' => 45000], - ['tier_no' => 3, 'leads_in_tier' => 400, 'price_per_lead_kopecks' => 40000], - ['tier_no' => 4, 'leads_in_tier' => 800, 'price_per_lead_kopecks' => 35000], - ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_per_lead_kopecks' => 30000], - ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_per_lead_kopecks' => 27000], - ['tier_no' => 7, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 25000], - ]; - - foreach ($tiers as $tier) { - PricingTier::updateOrCreate( - ['tier_no' => $tier['tier_no'], 'effective_from' => '1970-01-01'], - array_merge($tier, ['is_active' => true, 'effective_from' => '1970-01-01']), - ); - } - } -} -``` - -- [ ] **Step 9: Подключить seeder в DatabaseSeeder** - -Открыть `app/database/seeders/DatabaseSeeder.php`, в методе `run()` добавить: - -```php -$this->call(PricingTierSeeder::class); -``` - -- [ ] **Step 10: Прогнать migrate:fresh + seed на testing DB** - -```bash -cd app && DB_DATABASE=liderra_testing php artisan migrate:fresh --force --seed -``` - -Expected: 0 errors, «Database seeders ran successfully». - -Verify pricing_tiers seed: - -```bash -cd app && DB_DATABASE=liderra_testing php artisan tinker --execute="echo \App\Models\PricingTier::count();" -``` - -Expected output: `7`. - -- [ ] **Step 11: Прогнать failing schema-delta test — должен PASS** - -```bash -cd app && ./vendor/bin/pest --filter=SchemaDeltaTest tests/Feature/Plan4/Schema/SchemaDeltaTest.php -``` - -Expected: 5 PASS, 0 FAIL. - -- [ ] **Step 12: Полный Pest прогон — убедиться, что добавление колонок ничего не сломало** - -```bash -cd app && ./vendor/bin/pest --parallel -``` - -Expected: все pre-existing тесты + 5 новых SchemaDeltaTest = PASS. Зафиксировать exact число в commit message. - -- [ ] **Step 13: Запустить статанализ и pint** - -```bash -cd app && composer pint && composer stan -``` - -Expected: pint clean, stan 0 errors above baseline. - -- [ ] **Step 14: Commit** - -```bash -git add db/schema.sql db/CHANGELOG_schema.md db/02_grants.sql \ - app/app/Models/Tenant.php app/app/Models/LeadCharge.php app/app/Models/SupplierLead.php \ - app/database/factories/LeadChargeFactory.php \ - app/database/seeders/PricingTierSeeder.php app/database/seeders/DatabaseSeeder.php \ - app/tests/Feature/Plan4/Schema/SchemaDeltaTest.php -git commit -m "feat(db): Plan 4 Task 1 — schema delta v8.18 → v8.19 + models/seeder" -``` - ---- - -## Task 2: PricingTierResolver (pure unit) + PricingTierRepository - -**Files:** - -- Create: `app/app/Services/Billing/PricingTierResolver.php` -- Create: `app/tests/Unit/Billing/PricingTierResolverTest.php` -- Create: `app/app/Repositories/PricingTierRepository.php` -- Create: `app/tests/Feature/Billing/PricingTierRepositoryTest.php` - -- [ ] **Step 1: Написать failing unit-test для PricingTierResolver** - -```php -resolver = new PricingTierResolver(); - - // 7-tier сетка in-memory (без БД) - $this->tiers = new Collection([ - new PricingTier(['tier_no' => 1, 'leads_in_tier' => 100, 'price_per_lead_kopecks' => 50000]), - new PricingTier(['tier_no' => 2, 'leads_in_tier' => 200, 'price_per_lead_kopecks' => 45000]), - new PricingTier(['tier_no' => 3, 'leads_in_tier' => 400, 'price_per_lead_kopecks' => 40000]), - new PricingTier(['tier_no' => 4, 'leads_in_tier' => 800, 'price_per_lead_kopecks' => 35000]), - new PricingTier(['tier_no' => 5, 'leads_in_tier' => 1500, 'price_per_lead_kopecks' => 30000]), - new PricingTier(['tier_no' => 6, 'leads_in_tier' => 3000, 'price_per_lead_kopecks' => 27000]), - new PricingTier(['tier_no' => 7, 'leads_in_tier' => null, 'price_per_lead_kopecks' => 25000]), - ]); -}); - -it('returns tier 1 for the 1st lead', function () { - $tier = $this->resolver->resolveForCount($this->tiers, 1); - expect($tier->tier_no)->toBe(1); -}); - -it('returns tier 1 at upper bound (100th lead)', function () { - $tier = $this->resolver->resolveForCount($this->tiers, 100); - expect($tier->tier_no)->toBe(1); -}); - -it('crosses to tier 2 at 101st lead', function () { - $tier = $this->resolver->resolveForCount($this->tiers, 101); - expect($tier->tier_no)->toBe(2); -}); - -it('returns tier 6 at cumulative sum of tiers 1-6 (5000th lead)', function () { - // 100 + 200 + 400 + 800 + 1500 + 3000 = 6000; last in tier 6 = 6000th lead - $tier = $this->resolver->resolveForCount($this->tiers, 6000); - expect($tier->tier_no)->toBe(6); -}); - -it('returns tier 7 (unlimited) for 6001st lead', function () { - $tier = $this->resolver->resolveForCount($this->tiers, 6001); - expect($tier->tier_no)->toBe(7); -}); - -it('returns tier 7 for 1_000_000th lead (unlimited)', function () { - $tier = $this->resolver->resolveForCount($this->tiers, 1_000_000); - expect($tier->tier_no)->toBe(7); -}); - -it('throws RuntimeException on empty tiers collection', function () { - expect(fn () => $this->resolver->resolveForCount(new Collection(), 1)) - ->toThrow(\RuntimeException::class, 'No active pricing tiers'); -}); -``` - -- [ ] **Step 2: Запустить test — FAIL (класс не существует)** - -```bash -cd app && ./vendor/bin/pest tests/Unit/Billing/PricingTierResolverTest.php -``` - -Expected: FAIL "Class PricingTierResolver does not exist". - -- [ ] **Step 3: Реализовать PricingTierResolver** - -```php - $tiers активные ступени (не обязательно отсортированы) - * @param int $leadOrdinal номер лида в текущем месяце (1-based) - */ - public function resolveForCount(Collection $tiers, int $leadOrdinal): PricingTier - { - if ($tiers->isEmpty()) { - throw new RuntimeException('No active pricing tiers — cannot resolve'); - } - - /** @var Collection $sorted */ - $sorted = $tiers->sortBy('tier_no')->values(); - - $cumulative = 0; - foreach ($sorted as $tier) { - // tier 7 (или любой с leads_in_tier=NULL) — «всё свыше» - if ($tier->leads_in_tier === null) { - return $tier; - } - - $cumulative += (int) $tier->leads_in_tier; - if ($leadOrdinal <= $cumulative) { - return $tier; - } - } - - // Если ни одна ступень не покрыла (leadOrdinal > сумма всех leads_in_tier - // И ни у одной не было NULL) — возвращаем последнюю как fallback. - return $sorted->last(); - } -} -``` - -- [ ] **Step 4: Запустить test — PASS** - -```bash -cd app && ./vendor/bin/pest tests/Unit/Billing/PricingTierResolverTest.php -``` - -Expected: 7 PASS. - -- [ ] **Step 5: Написать failing test для PricingTierRepository** - -```php -repo = new PricingTierRepository(); -}); - -it('returns active tiers ordered by tier_no', function () { - PricingTier::factory()->create(['tier_no' => 2, 'effective_from' => '2024-01-01', 'is_active' => true]); - PricingTier::factory()->create(['tier_no' => 1, 'effective_from' => '2024-01-01', 'is_active' => true]); - - $tiers = $this->repo->activeAt(Carbon::parse('2024-06-01')); - - expect($tiers->pluck('tier_no')->all())->toBe([1, 2]); -}); - -it('returns max effective_from <= today (newer overrides older)', function () { - PricingTier::factory()->create(['tier_no' => 1, 'effective_from' => '2024-01-01', 'price_per_lead_kopecks' => 50000]); - PricingTier::factory()->create(['tier_no' => 1, 'effective_from' => '2024-06-01', 'price_per_lead_kopecks' => 30000]); - - $tiers = $this->repo->activeAt(Carbon::parse('2024-07-01')); - - expect($tiers->first()->price_per_lead_kopecks)->toBe(30000); -}); - -it('ignores future effective_from', function () { - PricingTier::factory()->create(['tier_no' => 1, 'effective_from' => '2024-01-01', 'price_per_lead_kopecks' => 50000]); - PricingTier::factory()->create(['tier_no' => 1, 'effective_from' => '2099-01-01', 'price_per_lead_kopecks' => 99999]); - - $tiers = $this->repo->activeAt(Carbon::parse('2024-06-01')); - - expect($tiers->first()->price_per_lead_kopecks)->toBe(50000); -}); - -it('returns empty collection when no active tiers exist', function () { - $tiers = $this->repo->activeAt(Carbon::parse('2024-06-01')); - - expect($tiers)->toBeEmpty(); -}); -``` - -- [ ] **Step 6: Запустить — FAIL** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Billing/PricingTierRepositoryTest.php -``` - -Expected: FAIL "Class PricingTierRepository does not exist". - -- [ ] **Step 7: Реализовать PricingTierRepository** - -```php - - */ - public function activeAt(CarbonInterface $at): Collection - { - // Для каждого tier_no берём строку с MAX(effective_from) <= $at - // (учёт сценария «новая сетка перекрывает старую»). - return PricingTier::query() - ->where('is_active', true) - ->where('effective_from', '<=', $at->toDateString()) - ->orderBy('tier_no') - ->orderBy('effective_from', 'desc') - ->get() - ->groupBy('tier_no') - ->map(fn (Collection $group) => $group->first()) - ->values() - ->sortBy('tier_no') - ->values(); - } -} -``` - -- [ ] **Step 8: Запустить test — PASS** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Billing/PricingTierRepositoryTest.php -``` - -Expected: 4 PASS. - -- [ ] **Step 9: Pint + Larastan + полный Pest** - -```bash -cd app && composer pint && composer stan && ./vendor/bin/pest --parallel -``` - -Expected: pint clean, stan 0 above baseline, все тесты PASS. - -- [ ] **Step 10: Commit** - -```bash -git add app/app/Services/Billing/PricingTierResolver.php \ - app/app/Repositories/PricingTierRepository.php \ - app/tests/Unit/Billing/PricingTierResolverTest.php \ - app/tests/Feature/Billing/PricingTierRepositoryTest.php -git commit -m "feat(billing): Plan 4 Task 2 — PricingTierResolver + Repository (pure resolver + DB-обёртка)" -``` - ---- - -## Task 3: LedgerService::chargeForDelivery + ChargeResult + InsufficientBalanceException - -**Files:** - -- Create: `app/app/Exceptions/Billing/InsufficientBalanceException.php` -- Create: `app/app/Services/Billing/ChargeResult.php` -- Create: `app/app/Services/Billing/LedgerService.php` -- Create: `app/tests/Feature/Billing/LedgerServiceTest.php` - -- [ ] **Step 1: Создать InsufficientBalanceException** - -```php -= 1), ни рублей под текущую tier-цену - * (balance_rub * 100 >= priceKopecks). - * - * Ловится в RouteSupplierLeadJob::createDealCopyForProject — инициирует - * auto-pause flow (см. spec §4.2). - */ -final class InsufficientBalanceException extends RuntimeException -{ - public function __construct( - public readonly int $priceKopecks, - public readonly string $balanceRub, // строка для bcmath compatibility - public readonly int $balanceLeads, - ?\Throwable $previous = null, - ) { - parent::__construct( - sprintf( - 'Insufficient balance: price_kopecks=%d, balance_rub=%s, balance_leads=%d', - $priceKopecks, $balanceRub, $balanceLeads, - ), - previous: $previous, - ); - } -} -``` - -- [ ] **Step 2: Создать ChargeResult DTO** - -```php -run(); - - $this->ledger = app(LedgerService::class); -}); - -function makeTenantWith(int $balanceLeads, string $balanceRub, int $deliveredInMonth = 0): Tenant -{ - return Tenant::factory()->create([ - 'balance_leads' => $balanceLeads, - 'balance_rub' => $balanceRub, - 'delivered_in_month' => $deliveredInMonth, - ]); -} - -function makeDealForTenant(Tenant $tenant): Deal -{ - return Deal::factory()->create([ - 'tenant_id' => $tenant->id, - 'received_at' => now(), - ]); -} - -it('charges prepaid when balance_leads >= 1 (price snapshot = 0, tier_no snapshot from delivered_in_month + 1)', function () { - $tenant = makeTenantWith(balanceLeads: 5, balanceRub: '0.00', deliveredInMonth: 0); - $deal = makeDealForTenant($tenant); - - $result = DB::transaction(function () use ($tenant, $deal) { - DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); - $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail(); - return $this->ledger->chargeForDelivery($locked, $deal); - }); - - expect($result->source)->toBe('prepaid'); - expect($result->priceKopecks)->toBe(0); - expect($result->tier->tier_no)->toBe(1); // 0+1 = 1st lead → tier 1 - - $tenant->refresh(); - expect((int) $tenant->balance_leads)->toBe(4); - expect((string) $tenant->balance_rub)->toBe('0.00'); - expect($tenant->delivered_in_month)->toBe(1); - - $charge = LeadCharge::first(); - expect($charge->charge_source)->toBe('prepaid'); - expect($charge->price_per_lead_kopecks)->toBe(0); - expect($charge->tier_no)->toBe(1); -}); - -it('charges rub when balance_leads = 0 and balance_rub >= price', function () { - $tenant = makeTenantWith(balanceLeads: 0, balanceRub: '1000.00', deliveredInMonth: 0); - $deal = makeDealForTenant($tenant); - - $result = DB::transaction(function () use ($tenant, $deal) { - DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); - $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail(); - return $this->ledger->chargeForDelivery($locked, $deal); - }); - - expect($result->source)->toBe('rub'); - expect($result->priceKopecks)->toBe(50000); // tier 1 price - - $tenant->refresh(); - expect((string) $tenant->balance_rub)->toBe('500.00'); // 1000 - 500 - expect((int) $tenant->balance_leads)->toBe(0); - expect($tenant->delivered_in_month)->toBe(1); - - $charge = LeadCharge::first(); - expect($charge->charge_source)->toBe('rub'); - expect($charge->price_per_lead_kopecks)->toBe(50000); -}); - -it('throws InsufficientBalanceException when both sources empty', function () { - $tenant = makeTenantWith(balanceLeads: 0, balanceRub: '400.00', deliveredInMonth: 0); - $deal = makeDealForTenant($tenant); - - expect(function () use ($tenant, $deal) { - DB::transaction(function () use ($tenant, $deal) { - DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); - $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail(); - $this->ledger->chargeForDelivery($locked, $deal); - }); - })->toThrow(InsufficientBalanceException::class); - - expect(LeadCharge::count())->toBe(0); - $tenant->refresh(); - expect((int) $tenant->balance_leads)->toBe(0); - expect((string) $tenant->balance_rub)->toBe('400.00'); - expect($tenant->delivered_in_month)->toBe(0); -}); - -it('charges rub at exact balance == price boundary', function () { - $tenant = makeTenantWith(balanceLeads: 0, balanceRub: '500.00', deliveredInMonth: 0); - $deal = makeDealForTenant($tenant); - - $result = DB::transaction(function () use ($tenant, $deal) { - DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); - $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail(); - return $this->ledger->chargeForDelivery($locked, $deal); - }); - - expect($result->source)->toBe('rub'); - - $tenant->refresh(); - expect((string) $tenant->balance_rub)->toBe('0.00'); -}); - -it('crosses tier boundary: delivered_in_month=99 → tier 1; delivered_in_month=100 → tier 2', function () { - $tenantA = makeTenantWith(balanceLeads: 0, balanceRub: '10000.00', deliveredInMonth: 99); - $tenantB = makeTenantWith(balanceLeads: 0, balanceRub: '10000.00', deliveredInMonth: 100); - - $dealA = makeDealForTenant($tenantA); - $dealB = makeDealForTenant($tenantB); - - $resultA = DB::transaction(function () use ($tenantA, $dealA) { - DB::statement("SET LOCAL app.current_tenant_id = '{$tenantA->id}'"); - $locked = Tenant::whereKey($tenantA->id)->lockForUpdate()->firstOrFail(); - return $this->ledger->chargeForDelivery($locked, $dealA); - }); - $resultB = DB::transaction(function () use ($tenantB, $dealB) { - DB::statement("SET LOCAL app.current_tenant_id = '{$tenantB->id}'"); - $locked = Tenant::whereKey($tenantB->id)->lockForUpdate()->firstOrFail(); - return $this->ledger->chargeForDelivery($locked, $dealB); - }); - - expect($resultA->tier->tier_no)->toBe(1); // 99+1 = 100, всё ещё в tier 1 - expect($resultB->tier->tier_no)->toBe(2); // 100+1 = 101, перешли в tier 2 -}); - -it('writes supplier_lead_costs (gap-fix: Plan 2/3 не писали в sharing-flow)', function () { - $tenant = makeTenantWith(balanceLeads: 5, balanceRub: '0.00'); - $supplier = Supplier::where('code', 'b1')->first(); - $supplierProject = SupplierProject::factory()->create(['platform' => 'B1', 'supplier_id' => $supplier->id]); - $deal = Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()]); - $lead = SupplierLead::factory()->create(['supplier_project_id' => $supplierProject->id]); - - // Деталь: chargeForDelivery должен резолвить supplier_id из $lead->supplierProject->supplier_id - // через explicit binding в LedgerService (мы передадим $lead с deal вместе). - // Этот тест проверяет supplier_lead_costs INSERT после charge'а. - - DB::transaction(function () use ($tenant, $deal, $lead) { - DB::statement("SET LOCAL app.current_tenant_id = '{$tenant->id}'"); - $locked = Tenant::whereKey($tenant->id)->lockForUpdate()->firstOrFail(); - $this->ledger->chargeForDelivery($locked, $deal, $lead); - }); - - $cost = DB::table('supplier_lead_costs')->where('deal_id', $deal->id)->first(); - expect($cost)->not->toBeNull(); - expect((int) $cost->supplier_id)->toBe($supplier->id); - expect((string) $cost->cost_rub)->toBe($supplier->cost_rub); -}); -``` - -- [ ] **Step 4: Запустить test — FAIL** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Billing/LedgerServiceTest.php -``` - -Expected: 6 FAIL ("Class LedgerService does not exist" / DI resolve failure). - -- [ ] **Step 5: Реализовать LedgerService** - -```php -tiers->activeAt(Carbon::now('Europe/Moscow')); - $tier = $this->resolver->resolveForCount($activeTiers, ($lockedTenant->delivered_in_month ?? 0) + 1); - $priceKopecks = (int) $tier->price_per_lead_kopecks; - - // 2. Decide chargeSource (bcmath — НЕ PHP float) - $source = $this->decideSource($lockedTenant, $priceKopecks); - - // 3. Apply - if ($source === 'prepaid') { - $lockedTenant->decrement('balance_leads', 1); - } else { - $amountRub = bcdiv((string) $priceKopecks, '100', 2); - $lockedTenant->decrement('balance_rub', $amountRub); - } - $lockedTenant->increment('delivered_in_month', 1); - $lockedTenant->refresh(); - - // 4. INSERT lead_charges (always) - LeadCharge::create([ - 'tenant_id' => $lockedTenant->id, - 'deal_id' => $deal->id, - 'deal_received_at' => $deal->received_at, - 'tier_no' => $tier->tier_no, - 'price_per_lead_kopecks' => $source === 'prepaid' ? 0 : $priceKopecks, - 'charge_source' => $source, - 'charged_at' => now(), - 'created_at' => now(), - ]); - - // 5. INSERT balance_transactions (универсальный ledger) - BalanceTransaction::create([ - 'tenant_id' => $lockedTenant->id, - 'type' => BalanceTransaction::TYPE_LEAD_CHARGE, - 'amount_leads' => $source === 'prepaid' ? -1 : 0, - 'amount_rub' => $source === 'rub' ? '-'.bcdiv((string) $priceKopecks, '100', 2) : '0.00', - 'balance_leads_after' => (int) $lockedTenant->balance_leads, - 'balance_rub_after' => (string) $lockedTenant->balance_rub, - 'related_type' => Deal::class, - 'related_id' => $deal->id, - 'created_at' => now(), - ]); - - // 6. INSERT supplier_lead_costs (gap-fix Plan 2/3 sharing-flow) - if ($lead !== null) { - $supplierId = $this->resolveSupplierId($lead); - if ($supplierId !== null) { - /** @var Supplier $supplier */ - $supplier = Supplier::findOrFail($supplierId); - DB::table('supplier_lead_costs')->insert([ - 'deal_id' => $deal->id, - 'received_at' => $deal->received_at, - 'supplier_id' => $supplierId, - 'cost_rub' => $supplier->cost_rub, - 'created_at' => now(), - ]); - } - } - - return new ChargeResult($source, $tier, $source === 'prepaid' ? 0 : $priceKopecks); - } - - private function decideSource(Tenant $tenant, int $priceKopecks): string - { - if ((int) $tenant->balance_leads >= 1) { - return 'prepaid'; - } - - // bcmath: balance_rub (DECIMAL string) * 100 ≥ priceKopecks → можем списать rub - $balanceKopecks = bcmul((string) $tenant->balance_rub, '100', 0); - if (bccomp($balanceKopecks, (string) $priceKopecks, 0) >= 0) { - return 'rub'; - } - - throw new InsufficientBalanceException( - priceKopecks: $priceKopecks, - balanceRub: (string) $tenant->balance_rub, - balanceLeads: (int) $tenant->balance_leads, - ); - } - - /** - * supplier_id из $lead->supplier_project->supplier_id (FK); fallback — - * по platform из raw_payload через suppliers.code. - */ - private function resolveSupplierId(SupplierLead $lead): ?int - { - if ($lead->supplier_project_id !== null) { - $sp = DB::table('supplier_projects')->where('id', $lead->supplier_project_id)->first(); - if ($sp !== null && $sp->supplier_id !== null) { - return (int) $sp->supplier_id; - } - } - // Fallback: парсим platform из raw_payload['project'] (B1_xxx → 'b1') - $project = (string) ($lead->raw_payload['project'] ?? ''); - if (preg_match('/^(B[123])_/', $project, $m) === 1) { - $code = strtolower($m[1]); - $supplier = Supplier::where('code', $code)->first(); - return $supplier?->id; - } - return null; - } -} -``` - -- [ ] **Step 6: Запустить test — PASS** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Billing/LedgerServiceTest.php -``` - -Expected: 6 PASS. - -- [ ] **Step 7: Pint + Larastan + полный Pest** - -```bash -cd app && composer pint && composer stan && ./vendor/bin/pest --parallel -``` - -Expected: pint clean, stan 0 above baseline, все тесты PASS. - -- [ ] **Step 8: Commit** - -```bash -git add app/app/Exceptions/Billing/ app/app/Services/Billing/ app/tests/Feature/Billing/LedgerServiceTest.php -git commit -m "feat(billing): Plan 4 Task 3 — LedgerService::chargeForDelivery (dual-balance + lead_charges/supplier_lead_costs INSERT)" -``` - ---- - -## Task 4: Integration LedgerService в RouteSupplierLeadJob - -**Files:** - -- Modify: [app/app/Jobs/RouteSupplierLeadJob.php](../../app/app/Jobs/RouteSupplierLeadJob.php) (заменить строки 265-279 + добавить try/catch для InsufficientBalance) -- Create: `app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php` - -- [ ] **Step 1: Написать failing E2E тест** - -```php -run(); -}); - -function prepareSharingFlow(int $tenantsCount, array $balances): array -{ - /** @var array $tenants */ - $tenants = []; - /** @var array $projects */ - $projects = []; - - $supplier = Supplier::where('code', 'b1')->first(); - $supplierProject = SupplierProject::factory()->create([ - 'platform' => 'B1', - 'signal_type' => 'site', - 'unique_key' => 'example.com', - 'supplier_id' => $supplier->id, - ]); - - for ($i = 0; $i < $tenantsCount; $i++) { - $tenant = Tenant::factory()->create($balances[$i]); - $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'signal_type' => 'site', - 'signal_identifier' => 'example.com', - 'supplier_b1_project_id' => $supplierProject->id, - 'is_active' => true, - 'daily_limit_target' => 10, - 'effective_daily_limit_today' => 10, - 'delivered_today' => 0, - 'delivery_days_mask' => 127, // все дни - 'region_mask' => 255, - ]); - $tenants[] = $tenant; - $projects[] = $project; - } - - $lead = SupplierLead::factory()->create([ - 'vid' => 'test-vid-'.uniqid(), - 'phone' => '79991234567', - 'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234567', 'time' => time()], - 'supplier_project_id' => $supplierProject->id, - 'received_at' => now(), - ]); - - return ['tenants' => $tenants, 'projects' => $projects, 'lead' => $lead, 'supplier' => $supplier]; -} - -it('charges prepaid for tenant with balance_leads > 0', function () { - $ctx = prepareSharingFlow(1, [['balance_leads' => 5, 'balance_rub' => '0.00', 'delivered_in_month' => 0]]); - - (new RouteSupplierLeadJob($ctx['lead']->id))->handle( - app(\App\Services\LeadRouter::class), - app(\App\Services\SupplierProjects\SupplierProjectResolver::class), - app(\App\Services\DuplicateDetector::class), - app(\App\Services\NotificationService::class), - ); - - $tenant = $ctx['tenants'][0]->fresh(); - expect((int) $tenant->balance_leads)->toBe(4); - expect($tenant->delivered_in_month)->toBe(1); - - $charge = LeadCharge::first(); - expect($charge)->not->toBeNull(); - expect($charge->charge_source)->toBe('prepaid'); - expect($charge->price_per_lead_kopecks)->toBe(0); -}); - -it('charges rub for tenant with balance_leads=0 and balance_rub >= price', function () { - $ctx = prepareSharingFlow(1, [['balance_leads' => 0, 'balance_rub' => '1000.00', 'delivered_in_month' => 0]]); - - (new RouteSupplierLeadJob($ctx['lead']->id))->handle( - app(\App\Services\LeadRouter::class), - app(\App\Services\SupplierProjects\SupplierProjectResolver::class), - app(\App\Services\DuplicateDetector::class), - app(\App\Services\NotificationService::class), - ); - - $tenant = $ctx['tenants'][0]->fresh(); - expect((string) $tenant->balance_rub)->toBe('500.00'); // 1000 - 500 (tier 1 = 50000 коп) - expect($tenant->delivered_in_month)->toBe(1); - - $charge = LeadCharge::first(); - expect($charge->charge_source)->toBe('rub'); - expect($charge->price_per_lead_kopecks)->toBe(50000); -}); - -it('writes supplier_lead_costs for each delivered deal copy (gap-fix)', function () { - $ctx = prepareSharingFlow(2, [ - ['balance_leads' => 5, 'balance_rub' => '0.00', 'delivered_in_month' => 0], - ['balance_leads' => 0, 'balance_rub' => '1000.00', 'delivered_in_month' => 0], - ]); - - (new RouteSupplierLeadJob($ctx['lead']->id))->handle( - app(\App\Services\LeadRouter::class), - app(\App\Services\SupplierProjects\SupplierProjectResolver::class), - app(\App\Services\DuplicateDetector::class), - app(\App\Services\NotificationService::class), - ); - - $costs = DB::table('supplier_lead_costs')->get(); - expect($costs)->toHaveCount(2); - foreach ($costs as $cost) { - expect((int) $cost->supplier_id)->toBe($ctx['supplier']->id); - expect((string) $cost->cost_rub)->toBe($ctx['supplier']->cost_rub); - } -}); - -it('retry idempotency: повторный run job’а не дублирует lead_charges', function () { - $ctx = prepareSharingFlow(1, [['balance_leads' => 5, 'balance_rub' => '0.00']]); - $job = new RouteSupplierLeadJob($ctx['lead']->id); - - $job->handle( - app(\App\Services\LeadRouter::class), - app(\App\Services\SupplierProjects\SupplierProjectResolver::class), - app(\App\Services\DuplicateDetector::class), - app(\App\Services\NotificationService::class), - ); - $job->handle( // повторный run — processed_at guard должен сработать - app(\App\Services\LeadRouter::class), - app(\App\Services\SupplierProjects\SupplierProjectResolver::class), - app(\App\Services\DuplicateDetector::class), - app(\App\Services\NotificationService::class), - ); - - expect(LeadCharge::count())->toBe(1); - expect(Deal::count())->toBe(1); - expect((int) $ctx['tenants'][0]->fresh()->balance_leads)->toBe(4); -}); -``` - -- [ ] **Step 2: Запустить тест — FAIL (LedgerService не инжектирован в Job)** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php -``` - -Expected: 4 FAIL — старый код пишет только balance_leads -1, не использует tier-lookup, lead_charges остаётся пустым. - -- [ ] **Step 3: Модифицировать RouteSupplierLeadJob: добавить инжектируемый LedgerService** - -Открыть [app/app/Jobs/RouteSupplierLeadJob.php](../../app/app/Jobs/RouteSupplierLeadJob.php), внести 3 правки: - -**3a. Добавить use-импорты после существующих** (после строки 15): - -```php -use App\Exceptions\Billing\InsufficientBalanceException; -use App\Services\Billing\LedgerService; -``` - -**3b. Расширить сигнатуру `handle()`** — добавить параметр `LedgerService $ledger` (строка ~85): - -```php -public function handle( - LeadRouter $router, - SupplierProjectResolver $resolver, - DuplicateDetector $duplicateDetector, - NotificationService $notifier, - LedgerService $ledger, -): void { -``` - -**3c. Передать `$ledger` в `createDealCopyForProject`** (строка ~111-114): - -```php -foreach ($matched as $project) { - try { - if ($this->createDealCopyForProject($lead, $project, $duplicateDetector, $notifier, $ledger)) { - $createdCount++; - } - } catch (Throwable $e) { - // ... существующий код - } -} -``` - -**3d. Расширить сигнатуру `createDealCopyForProject`** (строка ~178): - -```php -private function createDealCopyForProject( - SupplierLead $lead, - Project $project, - DuplicateDetector $duplicateDetector, - NotificationService $notifier, - LedgerService $ledger, -): bool { -``` - -**3e. Заменить блок строк 265-279 на LedgerService::chargeForDelivery + try/catch**: - -Найти строки: - -```php -$tenant->decrement('balance_leads'); -$tenant->refresh(); - -$project->increment('delivered_today'); -$project->increment('delivered_in_month'); - -BalanceTransaction::create([ - 'tenant_id' => $tenant->id, - 'type' => BalanceTransaction::TYPE_LEAD_CHARGE, - 'amount_leads' => -1, - 'balance_leads_after' => (int) $tenant->balance_leads, - 'related_type' => Deal::class, - 'related_id' => $deal->id, - 'created_at' => now(), -]); -``` - -Заменить на: - -```php -try { - $ledger->chargeForDelivery($tenant, $deal, $lead); -} catch (InsufficientBalanceException $e) { - Log::warning('billing.insufficient_balance.deal_rolled_back', [ - 'tenant_id' => $tenant->id, - 'project_id' => $project->id, - 'supplier_lead_id' => $lead->id, - 'price_kopecks' => $e->priceKopecks, - 'balance_rub' => $e->balanceRub, - 'balance_leads' => $e->balanceLeads, - ]); - throw $e; // bubble — DB::transaction rollback + поднимется в createDealCopyForProject уровень -} - -$project->increment('delivered_today'); -$project->increment('delivered_in_month'); -``` - -Также удалить `use App\Models\BalanceTransaction;` если он стал ненужным (LedgerService теперь пишет BalanceTransaction внутри). - -**Внимание:** `InsufficientBalanceException` пробрасывается из `DB::transaction` → ловится **outside** Closure'а, в `createDealCopyForProject` уровне. Сама структура transaction уже есть в строке 184 (`return DB::transaction(function () use (...) {...});`). Обернём вызов транзакции в try/catch на уровне `createDealCopyForProject` (это понадобится в Task 6 для auto-pause flow; пока — просто rethrow в parent handle()). - -В Task 4 НЕ добавляем auto-pause логику — только rethrow `InsufficientBalanceException` наверх. Auto-pause = Task 6. - -- [ ] **Step 4: Запустить тест — PASS (4 теста должны быть зелёными)** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php -``` - -Expected: 4 PASS. - -- [ ] **Step 5: Pint + Larastan + полный Pest** - -```bash -cd app && composer pint && composer stan && ./vendor/bin/pest --parallel -``` - -Expected: pint clean, stan 0 above baseline. Если есть Larastan warnings про `BalanceTransaction` import — удалить. - -Если падают существующие тесты RouteSupplierLeadJobTest, RouteSupplierLeadJobShareTest и т.п. (Plans 2/2.5) — нужно их подправить: они должны заранее засеять pricing_tiers (через `PricingTierSeeder` в `beforeEach`) и снабдить tenant'ы `balance_leads >= matchCount` ИЛИ `balance_rub` под tier-цену. - -```bash -# Грепнуть, какие тесты используют RouteSupplierLeadJob и могут сломаться: -grep -rln "RouteSupplierLeadJob" app/tests/ -``` - -Для каждого failing test — добавить в `beforeEach`: - -```php -(new \Database\Seeders\PricingTierSeeder())->run(); -``` - -И в fixture-tenant'е установить достаточный balance_leads. - -- [ ] **Step 6: Commit** - -```bash -git add app/app/Jobs/RouteSupplierLeadJob.php app/tests/Feature/Supplier/RouteSupplierLeadJobBillingTest.php -# + любые существующие test'ы, потребовавшие правок balance_leads / seeder в beforeEach: -# git add app/tests/Feature/Supplier/.php -git commit -m "feat(supplier): Plan 4 Task 4 — integrate LedgerService в RouteSupplierLeadJob (charge + supplier_lead_costs gap-fix)" -``` - ---- - -## Task 5: ResetMonthlyCountersCommand + Schedule entry - -**Files:** - -- Create: `app/app/Console/Commands/ResetMonthlyCountersCommand.php` -- Modify: [app/routes/console.php](../../app/routes/console.php) -- Create: `app/tests/Feature/Console/ResetMonthlyCountersCommandTest.php` - -- [ ] **Step 1: Написать failing тест** - -```php -create(['delivered_in_month' => 100]); - $tenantB = Tenant::factory()->create(['delivered_in_month' => 5]); - $tenantC = Tenant::factory()->create(['delivered_in_month' => 0]); // already 0 - - Project::factory()->create(['tenant_id' => $tenantA->id, 'delivered_in_month' => 50]); - Project::factory()->create(['tenant_id' => $tenantA->id, 'delivered_in_month' => 50]); - Project::factory()->create(['tenant_id' => $tenantB->id, 'delivered_in_month' => 5]); - - Artisan::call('projects:reset-monthly'); - - expect($tenantA->fresh()->delivered_in_month)->toBe(0); - expect($tenantB->fresh()->delivered_in_month)->toBe(0); - expect($tenantC->fresh()->delivered_in_month)->toBe(0); - - expect(Project::sum('delivered_in_month'))->toBe(0); -}); - -it('is idempotent — second run reports 0 affected', function () { - $tenant = Tenant::factory()->create(['delivered_in_month' => 10]); - Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_in_month' => 10]); - - Artisan::call('projects:reset-monthly'); - $secondOutput = Artisan::call('projects:reset-monthly'); - - expect($secondOutput)->toBe(0); // SUCCESS exit code - expect(\Artisan::output())->toContain('0 tenants'); - expect(\Artisan::output())->toContain('0 projects'); -}); - -it('Schedule entry registered for monthly on 1st 00:00 Europe/Moscow', function () { - /** @var \Illuminate\Console\Scheduling\Schedule $schedule */ - $schedule = app(\Illuminate\Console\Scheduling\Schedule::class); - - $found = collect($schedule->events())->first( - fn ($event) => str_contains($event->command ?? '', 'projects:reset-monthly') - ); - - expect($found)->not->toBeNull(); - expect($found->expression)->toBe('0 0 1 * *'); // 00:00 1-го числа каждого месяца - expect($found->timezone)->toBe('Europe/Moscow'); -}); - -it('uses pgsql_supplier BYPASSRLS connection (touches all tenants without SET LOCAL)', function () { - Tenant::factory()->count(3)->create(['delivered_in_month' => 7]); - - // Без SET LOCAL app.current_tenant_id reset должен затронуть всех 3 tenant'ов. - // Это тест на использование pgsql_supplier (BYPASSRLS), не default pgsql. - Artisan::call('projects:reset-monthly'); - - expect(Tenant::where('delivered_in_month', '>', 0)->count())->toBe(0); -}); -``` - -- [ ] **Step 2: Запустить — FAIL** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Console/ResetMonthlyCountersCommandTest.php -``` - -Expected: 4 FAIL ("Command 'projects:reset-monthly' is not defined"). - -- [ ] **Step 3: Реализовать ResetMonthlyCountersCommand** - -```php -transaction(function () { - $tenants = DB::connection('pgsql_supplier') - ->update('UPDATE tenants SET delivered_in_month = 0 WHERE delivered_in_month <> 0'); - $projects = DB::connection('pgsql_supplier') - ->update('UPDATE projects SET delivered_in_month = 0 WHERE delivered_in_month <> 0'); - - $this->info("Monthly reset: {$tenants} tenants, {$projects} projects."); - }); - - return self::SUCCESS; - } -} -``` - -- [ ] **Step 4: Добавить Schedule entry в routes/console.php** - -Открыть [app/routes/console.php](../../app/routes/console.php), после существующего `Schedule::command('projects:reset-delivered-today')` (строка ~21-23) добавить: - -```php -// Plan 4: monthly reset 1-го числа в 00:00 МСК для tier-lookup в LedgerService. -Schedule::command('projects:reset-monthly') - ->monthlyOn(1, '00:00') - ->timezone('Europe/Moscow'); -``` - -- [ ] **Step 5: Запустить — PASS** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Console/ResetMonthlyCountersCommandTest.php -``` - -Expected: 4 PASS. - -- [ ] **Step 6: Pint + Larastan + полный Pest** - -```bash -cd app && composer pint && composer stan && ./vendor/bin/pest --parallel -``` - -Expected: clean. - -- [ ] **Step 7: Commit** - -```bash -git add app/app/Console/Commands/ResetMonthlyCountersCommand.php \ - app/routes/console.php \ - app/tests/Feature/Console/ResetMonthlyCountersCommandTest.php -git commit -m "feat(commands): Plan 4 Task 5 — ResetMonthlyCountersCommand + Schedule monthlyOn(1, 00:00) МСК" -``` - ---- - -## Task 6: Auto-pause flow + ZeroBalancePausedMail + rate-limit - -**Files:** - -- Create: `app/app/Mail/ZeroBalancePausedMail.php` -- Create: `app/resources/views/emails/zero_balance_paused.blade.php` -- Modify: `app/app/Services/NotificationService.php` -- Modify: [app/app/Jobs/RouteSupplierLeadJob.php](../../app/app/Jobs/RouteSupplierLeadJob.php) (+`handleInsufficientBalance` private method + try/catch в createDealCopyForProject) -- Create: `app/tests/Feature/Supplier/AutoPauseFlowTest.php` - -- [ ] **Step 1: Написать failing test для auto-pause flow** - -```php -flush(); - (new \Database\Seeders\PricingTierSeeder())->run(); -}); - -function makeFlowWithBalance(array $balance): array -{ - $supplier = Supplier::where('code', 'b1')->first(); - $supplierProject = SupplierProject::factory()->create([ - 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com', - 'supplier_id' => $supplier->id, - ]); - $tenant = Tenant::factory()->create($balance); - $project = Project::factory()->create([ - 'tenant_id' => $tenant->id, - 'signal_type' => 'site', 'signal_identifier' => 'example.com', - 'supplier_b1_project_id' => $supplierProject->id, - 'is_active' => true, 'daily_limit_target' => 10, - 'effective_daily_limit_today' => 10, 'delivered_today' => 0, - 'delivery_days_mask' => 127, 'region_mask' => 255, - ]); - $lead = SupplierLead::factory()->create([ - 'vid' => 'vid-pause-'.uniqid(), - 'phone' => '79991234567', - 'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234567', 'time' => time()], - 'supplier_project_id' => $supplierProject->id, - 'received_at' => now(), - ]); - return compact('tenant', 'project', 'lead'); -} - -it('pauses project (is_active=false) when both balances empty', function () { - $ctx = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']); - - (new RouteSupplierLeadJob($ctx['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), - ); - - expect($ctx['project']->fresh()->is_active)->toBeFalse(); -}); - -it('sends ZeroBalancePausedMail на email tenant’а', function () { - $ctx = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']); - - (new RouteSupplierLeadJob($ctx['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), - ); - - Mail::assertSent(ZeroBalancePausedMail::class, function ($mail) use ($ctx) { - return $mail->hasTo($ctx['tenant']->contact_email); - }); -}); - -it('respects rate-limit 1 hour per tenant: 2 consecutive calls → 1 email only', function () { - $ctx1 = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']); - $tenantId = $ctx1['tenant']->id; - - (new RouteSupplierLeadJob($ctx1['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), - ); - - // Создаём второй lead для того же tenant'а (но другой project / vid) - $ctx2lead = SupplierLead::factory()->create([ - 'vid' => 'vid-pause-2-'.uniqid(), - 'phone' => '79991234568', - 'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234568', 'time' => time()], - 'supplier_project_id' => $ctx1['lead']->supplier_project_id, - 'received_at' => now(), - ]); - - (new RouteSupplierLeadJob($ctx2lead->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), - ); - - Mail::assertSent(ZeroBalancePausedMail::class, 1); -}); - -it('sends 2nd email after 65 minutes (rate-limit expired)', function () { - $ctx = makeFlowWithBalance(['balance_leads' => 0, 'balance_rub' => '100.00']); - - (new RouteSupplierLeadJob($ctx['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), - ); - - Mail::assertSent(ZeroBalancePausedMail::class, 1); - - \Illuminate\Support\Carbon::setTestNow(now()->addMinutes(65)); - Cache::store('redis')->flush(); // имитируем TTL expiry; в реальности Redis сам забудет через 1ч - - $lead2 = SupplierLead::factory()->create([ - 'vid' => 'vid-pause-future-'.uniqid(), - 'phone' => '79991234569', - 'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234569', 'time' => time()], - 'supplier_project_id' => $ctx['lead']->supplier_project_id, - 'received_at' => now(), - ]); - - (new RouteSupplierLeadJob($lead2->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), - ); - - Mail::assertSent(ZeroBalancePausedMail::class, 2); -}); - -it('sharing-flow isolation: tenant A on zero paused, tenant B with balance receives deal', function () { - $supplier = Supplier::where('code', 'b1')->first(); - $supplierProject = SupplierProject::factory()->create([ - 'platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'example.com', - 'supplier_id' => $supplier->id, - ]); - $tenantA = Tenant::factory()->create(['balance_leads' => 0, 'balance_rub' => '0.00']); - $tenantB = Tenant::factory()->create(['balance_leads' => 5, 'balance_rub' => '0.00']); - $projectA = Project::factory()->create([ - 'tenant_id' => $tenantA->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com', - 'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true, - 'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, - 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, - ]); - $projectB = Project::factory()->create([ - 'tenant_id' => $tenantB->id, 'signal_type' => 'site', 'signal_identifier' => 'example.com', - 'supplier_b1_project_id' => $supplierProject->id, 'is_active' => true, - 'daily_limit_target' => 10, 'effective_daily_limit_today' => 10, - 'delivered_today' => 0, 'delivery_days_mask' => 127, 'region_mask' => 255, - ]); - $lead = SupplierLead::factory()->create([ - 'vid' => 'vid-shared-'.uniqid(), - 'phone' => '79991234567', - 'raw_payload' => ['project' => 'B1_example.com', 'phone' => '79991234567', 'time' => time()], - 'supplier_project_id' => $supplierProject->id, - 'received_at' => now(), - ]); - - (new 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), - ); - - expect($projectA->fresh()->is_active)->toBeFalse(); - expect($projectB->fresh()->is_active)->toBeTrue(); - expect((int) $tenantB->fresh()->balance_leads)->toBe(4); -}); -``` - -- [ ] **Step 2: Запустить — FAIL (ZeroBalancePausedMail класс не существует, нет handleInsufficientBalance)** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Supplier/AutoPauseFlowTest.php -``` - -Expected: 5 FAIL. - -- [ ] **Step 3: Создать ZeroBalancePausedMail mailable** - -```php -project->name}» приостановлен — недостаточно средств", - to: [$this->tenant->contact_email], - ); - } - - public function content(): Content - { - return new Content(view: 'emails.zero_balance_paused'); - } -} -``` - -- [ ] **Step 4: Создать Blade-шаблон** - -```html -{{-- app/resources/views/emails/zero_balance_paused.blade.php --}} - - -Проект приостановлен - -

Здравствуйте!

-

Проект «{{ $project->name }}» приостановлен — недостаточно средств для приёма следующего лида.

-
    -
  • Баланс в лидах: {{ $tenant->balance_leads }}
  • -
  • Баланс в рублях: {{ number_format((float) $tenant->balance_rub, 2, ',', ' ') }} ₽
  • -
  • Цена за следующий лид: {{ number_format($requiredPriceKopecks / 100, 2, ',', ' ') }} ₽
  • -
-

Пополните баланс на странице «Баланс и тарифы», чтобы возобновить приём лидов.

-

С уважением, команда Лидерра.

- - -``` - -- [ ] **Step 5: Добавить `notifyZeroBalance` в NotificationService** - -Открыть `app/app/Services/NotificationService.php`. После метода `notifyNewLead` (грепнуть имя метода для exact position) добавить: - -```php -use App\Mail\ZeroBalancePausedMail; -use Illuminate\Support\Facades\Mail; - -// ... - -public function notifyZeroBalance(Tenant $tenant, Project $project, int $requiredPriceKopecks): void -{ - Mail::to($tenant->contact_email)->send( - new ZeroBalancePausedMail($tenant, $project, $requiredPriceKopecks) - ); -} -``` - -- [ ] **Step 6: Добавить `handleInsufficientBalance` в RouteSupplierLeadJob** - -Открыть [app/app/Jobs/RouteSupplierLeadJob.php](../../app/app/Jobs/RouteSupplierLeadJob.php): - -**6a. Добавить use-импорты** (после Task 4 импортов): - -```php -use Illuminate\Support\Facades\Cache; -``` - -**6b. Заменить try/catch в createDealCopyForProject (Task 4) на полный flow:** - -Найти структуру `try { ... DB::transaction(function (...) { ... }); } catch (InsufficientBalanceException $e) { ... }` (Task 4 ввёл это). Сейчас Task 6 расширяет catch: - -```php -try { - return DB::transaction(function () use ($lead, $project, $duplicateDetector, $notifier, $ledger): bool { - // ... весь существующий код createDealCopyForProject (Tenant lock, Project lock, Deal create, - // DuplicateDetector, $ledger->chargeForDelivery, increment delivered_today/in_month, - // ActivityLog, notifyNewLead) ... - }); -} catch (InsufficientBalanceException $e) { - // Транзакция rolled back — Deal не создан, balance не тронут. - $this->handleInsufficientBalance($lead, $project, $e); - return false; -} -``` - -**6c. Добавить приватный метод `handleInsufficientBalance`** в конец класса (перед `failed()`): - -```php -private function handleInsufficientBalance( - SupplierLead $lead, - Project $project, - InsufficientBalanceException $e, -): void { - // 1) UPDATE projects.is_active=false через pgsql_supplier (BYPASSRLS — паттерн ResetCmd) - DB::connection(self::DB_CONNECTION) - ->update('UPDATE projects SET is_active = false WHERE id = ?', [$project->id]); - - // 2) Email-алерт с rate-limit 1/час/tenant через Redis SETNX. - $cacheKey = "billing:zero_balance_alert:{$project->tenant_id}"; - if (Cache::store('redis')->add($cacheKey, true, now()->addHour())) { - $project->loadMissing('tenant'); - app(NotificationService::class)->notifyZeroBalance( - $project->tenant, $project, $e->priceKopecks - ); - } - - Log::warning('billing.project_paused_insufficient_balance', [ - 'tenant_id' => $project->tenant_id, - 'project_id' => $project->id, - 'supplier_lead_id' => $lead->id, - 'price_kopecks' => $e->priceKopecks, - 'balance_rub' => $e->balanceRub, - 'balance_leads' => $e->balanceLeads, - ]); -} -``` - -- [ ] **Step 7: Запустить test — PASS** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Supplier/AutoPauseFlowTest.php -``` - -Expected: 5 PASS. - -- [ ] **Step 8: Pint + Larastan + полный Pest** - -```bash -cd app && composer pint && composer stan && ./vendor/bin/pest --parallel -``` - -- [ ] **Step 9: Commit** - -```bash -git add app/app/Mail/ZeroBalancePausedMail.php \ - app/resources/views/emails/zero_balance_paused.blade.php \ - app/app/Services/NotificationService.php \ - app/app/Jobs/RouteSupplierLeadJob.php \ - app/tests/Feature/Supplier/AutoPauseFlowTest.php -git commit -m "feat(billing): Plan 4 Task 6 — auto-pause flow + ZeroBalancePausedMail + 1/hour rate-limit" -``` - ---- - -## Task 7: SupplierCsvParser + SupplierPortalClient::downloadLeadsCsv - -**Files:** - -- Create: `app/app/Services/Supplier/SupplierCsvParser.php` -- Create: `app/tests/Unit/Supplier/SupplierCsvParserTest.php` -- Modify: [app/app/Services/Supplier/SupplierPortalClient.php](../../app/app/Services/Supplier/SupplierPortalClient.php) -- Create: `app/tests/Unit/Supplier/SupplierPortalClientCsvTest.php` - -- [ ] **Step 1: Написать failing unit-test для SupplierCsvParser** - -```php -parser = new SupplierCsvParser(); -}); - -it('parses empty CSV → yields nothing', function () { - $rows = iterator_to_array($this->parser->parse('')); - expect($rows)->toBeEmpty(); -}); - -it('parses 1 row → yields 1 struct with vid/project/phone/time', function () { - $csv = "vid;project;tag;phone;phones;time\n" - . "1234;B1_example.com;;79991234567;79991234567;1715432400\n"; - - $rows = iterator_to_array($this->parser->parse($csv)); - - expect($rows)->toHaveCount(1); - expect($rows[0])->toMatchArray([ - 'vid' => '1234', - 'project' => 'B1_example.com', - 'phone' => '79991234567', - 'time' => 1715432400, - ]); -}); - -it('parses 1000 rows without OOM (streaming generator)', function () { - $lines = ["vid;project;tag;phone;phones;time"]; - for ($i = 1; $i <= 1000; $i++) { - $lines[] = "{$i};B1_test.com;;7999000{$i};7999000{$i};1715432400"; - } - $csv = implode("\n", $lines)."\n"; - - $count = 0; - foreach ($this->parser->parse($csv) as $_) { - $count++; - } - - expect($count)->toBe(1000); -}); - -it('skips malformed rows with missing columns + logs warning', function () { - \Illuminate\Support\Facades\Log::spy(); - - $csv = "vid;project;tag;phone;phones;time\n" - . "1234;B1_example.com;;79991234567;79991234567;1715432400\n" - . "broken-row-only-one-column\n" - . "5678;B1_another.com;;79991234567;79991234567;1715432500\n"; - - $rows = iterator_to_array($this->parser->parse($csv)); - - expect($rows)->toHaveCount(2); - expect($rows[0]['vid'])->toBe('1234'); - expect($rows[1]['vid'])->toBe('5678'); - - \Illuminate\Support\Facades\Log::shouldHaveReceived('warning') - ->with('supplier_csv_parser.malformed_row', \Mockery::any()) - ->once(); -}); - -it('handles BOM + CRLF line endings', function () { - $bom = "\xEF\xBB\xBF"; - $csv = $bom . "vid;project;tag;phone;phones;time\r\n" - . "1234;B1_example.com;;79991234567;79991234567;1715432400\r\n"; - - $rows = iterator_to_array($this->parser->parse($csv)); - - expect($rows)->toHaveCount(1); - expect($rows[0]['vid'])->toBe('1234'); -}); -``` - -- [ ] **Step 2: Запустить — FAIL** - -```bash -cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierCsvParserTest.php -``` - -Expected: 5 FAIL ("Class SupplierCsvParser does not exist"). - -- [ ] **Step 3: Реализовать SupplierCsvParser** - -```php - - */ - public function parse(string $rawCsv): iterable - { - if ($rawCsv === '') { - return; - } - - // Убираем BOM (UTF-8 BOM = EF BB BF) - if (str_starts_with($rawCsv, "\xEF\xBB\xBF")) { - $rawCsv = substr($rawCsv, 3); - } - - // Нормализуем CRLF → LF - $rawCsv = str_replace("\r\n", "\n", $rawCsv); - - $lines = explode("\n", $rawCsv); - $headerSkipped = false; - $lineNo = 0; - - foreach ($lines as $line) { - $lineNo++; - if ($line === '') { - continue; - } - if (! $headerSkipped) { - $headerSkipped = true; - continue; - } - - $cols = str_getcsv($line, separator: ';'); - if (count($cols) < self::EXPECTED_COLUMNS) { - Log::warning('supplier_csv_parser.malformed_row', [ - 'line_no' => $lineNo, - 'columns_found' => count($cols), - 'expected' => self::EXPECTED_COLUMNS, - 'sample' => substr($line, 0, 100), - ]); - continue; - } - - yield [ - 'vid' => (string) $cols[0], - 'project' => (string) $cols[1], - 'phone' => (string) $cols[3], - 'time' => (int) $cols[5], - ]; - } - } -} -``` - -- [ ] **Step 4: Запустить — PASS** - -```bash -cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierCsvParserTest.php -``` - -Expected: 5 PASS. - -- [ ] **Step 5: Написать failing test для downloadLeadsCsv** - -```php -put('supplier:session', [ - 'phpsessid' => 'test-session', 'csrf' => 'test-csrf-token', - ], now()->addHour()); - - config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']); -}); - -it('GET /admin/report/index?type=49 returns CSV body on 200', function () { - Http::fake([ - 'crm.bp-gr.ru/admin/report/index*' => Http::response( - "vid;project;tag;phone;phones;time\n1234;B1_example.com;;79991234567;79991234567;1715432400\n", - 200, - ['Content-Type' => 'text/csv'], - ), - ]); - - $client = new SupplierPortalClient(app(HttpFactory::class)); - $body = $client->downloadLeadsCsv( - \Illuminate\Support\Carbon::parse('2024-05-11 00:00:00'), - \Illuminate\Support\Carbon::parse('2024-05-12 00:00:00'), - ); - - expect($body)->toContain('1234;B1_example.com'); -}); - -it('401 → triggers session refresh → retry → 200', function () { - Http::fakeSequence('crm.bp-gr.ru/admin/report/index*') - ->push('Unauthorized', 401) - ->push("vid;...\n", 200); - - // RefreshSupplierSessionJob — мокаем через app->bind() - app()->bind(\App\Jobs\Supplier\RefreshSupplierSessionJob::class, function () { - return new class { - public function handle(): void { - Cache::store('redis')->put('supplier:session', [ - 'phpsessid' => 'refreshed', 'csrf' => 'refreshed-csrf', - ], now()->addHour()); - } - }; - }); - - $client = new SupplierPortalClient(app(HttpFactory::class)); - $body = $client->downloadLeadsCsv( - \Illuminate\Support\Carbon::parse('2024-05-11'), - \Illuminate\Support\Carbon::parse('2024-05-12'), - ); - - expect($body)->toContain('vid'); -}); - -it('500 → SupplierTransientException', function () { - Http::fake(['crm.bp-gr.ru/*' => Http::response('Internal Server Error', 500)]); - - $client = new SupplierPortalClient(app(HttpFactory::class)); - - expect(fn () => $client->downloadLeadsCsv( - \Illuminate\Support\Carbon::parse('2024-05-11'), - \Illuminate\Support\Carbon::parse('2024-05-12'), - ))->toThrow(SupplierTransientException::class); -}); -``` - -- [ ] **Step 6: Запустить — FAIL ("method does not exist")** - -```bash -cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientCsvTest.php -``` - -- [ ] **Step 7: Добавить downloadLeadsCsv в SupplierPortalClient** - -Открыть [app/app/Services/Supplier/SupplierPortalClient.php](../../app/app/Services/Supplier/SupplierPortalClient.php), добавить use: - -```php -use Carbon\CarbonInterface; -``` - -После метода `deleteProject` (~строка 65) добавить: - -```php -/** - * GET /admin/report/index?type=49 — CSV-экспорт лидов за окно [from, to]. - * Auth/retry семантика наследуется от request() (PHPSESSID + X-CSRF-Token + - * 401 → RefreshSession + 5xx → SupplierTransientException + 4xx → SupplierClientException). - * - * Возвращает raw CSV-body (UTF-8 + BOM, CRLF). Парсинг — снаружи через - * SupplierCsvParser (streaming через generator). - * - * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.1 - */ -public function downloadLeadsCsv(CarbonInterface $from, CarbonInterface $to): string -{ - $response = $this->request('GET', '/admin/report/index', [ - 'type' => 49, - 'from' => $from->format('Y-m-d H:i:s'), - 'to' => $to->format('Y-m-d H:i:s'), - ]); - - return $response->body(); -} -``` - -- [ ] **Step 8: Запустить test — PASS** - -```bash -cd app && ./vendor/bin/pest tests/Unit/Supplier/SupplierPortalClientCsvTest.php -``` - -Expected: 3 PASS. - -- [ ] **Step 9: Pint + Larastan + полный Pest** - -```bash -cd app && composer pint && composer stan && ./vendor/bin/pest --parallel -``` - -- [ ] **Step 10: Commit** - -```bash -git add app/app/Services/Supplier/SupplierCsvParser.php \ - app/app/Services/Supplier/SupplierPortalClient.php \ - app/tests/Unit/Supplier/SupplierCsvParserTest.php \ - app/tests/Unit/Supplier/SupplierPortalClientCsvTest.php -git commit -m "feat(supplier): Plan 4 Task 7 — SupplierCsvParser (streaming) + SupplierPortalClient::downloadLeadsCsv" -``` - ---- - -## Task 8: CsvReconcileJob + CsvDriftAlertMail + Schedule entry - -**Files:** - -- Create: `app/app/Jobs/Supplier/CsvReconcileJob.php` -- Create: `app/app/Mail/CsvDriftAlertMail.php` -- Create: `app/resources/views/emails/csv_drift_alert.blade.php` -- Modify: [app/routes/console.php](../../app/routes/console.php) -- Modify: `app/config/services.php` (если ключ `supplier.alert_email` не существует — добавим) -- Create: `app/tests/Feature/Supplier/CsvReconcileJobTest.php` - -- [ ] **Step 1: Проверить ключ `services.supplier.alert_email` существует** - -```bash -grep -n "alert_email\|supplier" app/config/services.php -``` - -Если нет — добавить блок: - -```php -// app/config/services.php — добавить в return array -'supplier' => [ - 'alert_email' => env('SUPPLIER_ALERT_EMAIL', 'ops@liderra.ru'), - 'portal_url' => env('SUPPLIER_PORTAL_URL', 'https://crm.bp-gr.ru'), - // ... possibly other keys из Plan 3 -], -``` - -- [ ] **Step 2: Написать failing integration-test для CsvReconcileJob** - -```php -flush(); - Cache::store('redis')->put('supplier:session', [ - 'phpsessid' => 'test', 'csrf' => 'test', - ], now()->addHour()); - config(['services.supplier.portal_url' => 'https://crm.bp-gr.ru']); - config(['services.supplier.alert_email' => 'ops@liderra.ru']); -}); - -function csvBody(array $rows): string -{ - $out = "vid;project;tag;phone;phones;time\n"; - foreach ($rows as $r) { - $out .= "{$r['vid']};{$r['project']};;{$r['phone']};{$r['phone']};{$r['time']}\n"; - } - return $out; -} - -it('matches existing leads, no missing — status=ok, no alert', function () { - $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'a.com']); - $now = time(); - - for ($i = 0; $i < 10; $i++) { - SupplierLead::factory()->create([ - 'vid' => "vid-{$i}", - 'phone' => "799900000{$i}", - 'supplier_project_id' => $sp->id, - 'received_at' => now()->subHour(), - ]); - } - - $rows = []; - for ($i = 0; $i < 10; $i++) { - $rows[] = ['vid' => "vid-{$i}", 'project' => 'B1_a.com', 'phone' => "799900000{$i}", 'time' => $now - 3600]; - } - Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]); - - app(CsvReconcileJob::class)->handle( - app(\App\Services\Supplier\SupplierPortalClient::class), - app(\App\Services\Supplier\SupplierCsvParser::class), - app(\Illuminate\Contracts\Mail\Mailer::class), - ); - - $log = DB::table('supplier_csv_reconcile_log')->first(); - expect($log->status)->toBe('ok'); - expect((int) $log->total_csv_rows)->toBe(10); - expect((int) $log->matched_count)->toBe(10); - expect((int) $log->recovered_count)->toBe(0); - - Mail::assertNothingSent(); - Bus::assertNothingDispatched(); -}); - -it('drift 10% (1 missing of 10) → alert email + 1 RouteJob dispatched', function () { - $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'a.com']); - $now = time(); - - for ($i = 0; $i < 9; $i++) { // 9 existing - SupplierLead::factory()->create([ - 'vid' => "vid-{$i}", 'phone' => "799900000{$i}", - 'supplier_project_id' => $sp->id, 'received_at' => now()->subHour(), - ]); - } - - $rows = []; - for ($i = 0; $i < 10; $i++) { // 10 в CSV — 1 missing (vid-9) - $rows[] = ['vid' => "vid-{$i}", 'project' => 'B1_a.com', 'phone' => "799900000{$i}", 'time' => $now - 3600]; - } - Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]); - - app(CsvReconcileJob::class)->handle( - app(\App\Services\Supplier\SupplierPortalClient::class), - app(\App\Services\Supplier\SupplierCsvParser::class), - app(\Illuminate\Contracts\Mail\Mailer::class), - ); - - $log = DB::table('supplier_csv_reconcile_log')->first(); - expect($log->status)->toBe('drift_alert'); - expect((float) $log->drift_ratio)->toBeGreaterThan(0.05); - expect((int) $log->recovered_count)->toBe(1); - - Mail::assertSent(CsvDriftAlertMail::class, 1); - Bus::assertDispatched(RouteSupplierLeadJob::class, 1); -}); - -it('drift 1% (1 missing of 100) → status=ok, no alert', function () { - $sp = SupplierProject::factory()->create(['platform' => 'B1', 'signal_type' => 'site', 'unique_key' => 'a.com']); - $now = time(); - - for ($i = 0; $i < 99; $i++) { - SupplierLead::factory()->create([ - 'vid' => "vid-{$i}", 'phone' => "799900{$i}", - 'supplier_project_id' => $sp->id, 'received_at' => now()->subHour(), - ]); - } - - $rows = []; - for ($i = 0; $i < 100; $i++) { - $rows[] = ['vid' => "vid-{$i}", 'project' => 'B1_a.com', 'phone' => "799900{$i}", 'time' => $now - 3600]; - } - Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response(csvBody($rows), 200)]); - - app(CsvReconcileJob::class)->handle( - app(\App\Services\Supplier\SupplierPortalClient::class), - app(\App\Services\Supplier\SupplierCsvParser::class), - app(\Illuminate\Contracts\Mail\Mailer::class), - ); - - $log = DB::table('supplier_csv_reconcile_log')->first(); - expect($log->status)->toBe('ok'); - expect((int) $log->recovered_count)->toBe(1); - Mail::assertNothingSent(); -}); - -it('empty CSV → status=ok, drift=0, no alert', function () { - Http::fake(['crm.bp-gr.ru/admin/report/index*' => Http::response("vid;project;tag;phone;phones;time\n", 200)]); - - app(CsvReconcileJob::class)->handle( - app(\App\Services\Supplier\SupplierPortalClient::class), - app(\App\Services\Supplier\SupplierCsvParser::class), - app(\Illuminate\Contracts\Mail\Mailer::class), - ); - - $log = DB::table('supplier_csv_reconcile_log')->first(); - expect($log->status)->toBe('ok'); - expect((int) $log->total_csv_rows)->toBe(0); -}); - -it('SupplierTransientException → status=failed, error_message recorded', function () { - Http::fake(['crm.bp-gr.ru/*' => Http::response('Server Error', 500)]); - - expect(fn () => - app(CsvReconcileJob::class)->handle( - app(\App\Services\Supplier\SupplierPortalClient::class), - app(\App\Services\Supplier\SupplierCsvParser::class), - app(\Illuminate\Contracts\Mail\Mailer::class), - ) - )->toThrow(\App\Exceptions\Supplier\SupplierTransientException::class); - - $log = DB::table('supplier_csv_reconcile_log')->first(); - expect($log->status)->toBe('failed'); - expect($log->error_message)->toContain('500'); -}); - -it('Schedule entry: hourly cron registered', function () { - /** @var \Illuminate\Console\Scheduling\Schedule $schedule */ - $schedule = app(\Illuminate\Console\Scheduling\Schedule::class); - - $events = $schedule->events(); - $hasCsv = collect($events)->contains(fn ($event) => - str_contains((string) $event->description, 'CsvReconcileJob') - || str_contains((string) $event->description, 'csv-reconcile') - ); - expect($hasCsv)->toBeTrue(); -}); -``` - -- [ ] **Step 3: Запустить — FAIL** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvReconcileJobTest.php -``` - -Expected: 6 FAIL. - -- [ ] **Step 4: Создать CsvDriftAlertMail** - -```php - 5%. - * - * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §5.6 - */ -final class CsvDriftAlertMail extends Mailable -{ - use Queueable; - use SerializesModels; - - public function __construct( - public readonly int $reconcileLogId, - public readonly int $totalCsvRows, - public readonly int $missingCount, - public readonly int $recoveredCount, - public readonly float $driftRatio, - public readonly CarbonInterface $windowStart, - public readonly CarbonInterface $windowEnd, - ) {} - - public function envelope(): Envelope - { - $pct = number_format($this->driftRatio * 100, 2, ',', ' '); - $window = $this->windowStart->format('Y-m-d H:i').' — '.$this->windowEnd->format('Y-m-d H:i'); - return new Envelope( - subject: "Лидерра ↔ Поставщик: расхождение CSV > 5% за {$window} ({$pct}%)", - ); - } - - public function content(): Content - { - return new Content(view: 'emails.csv_drift_alert'); - } -} -``` - -- [ ] **Step 5: Создать Blade-шаблон** - -```html -{{-- app/resources/views/emails/csv_drift_alert.blade.php --}} - - -CSV drift alert - -

Расхождение CSV-сверки с базой Лидерры

-

Окно: {{ $windowStart->format('Y-m-d H:i') }} — {{ $windowEnd->format('Y-m-d H:i') }}

-
    -
  • Всего строк в CSV: {{ $totalCsvRows }}
  • -
  • Пропущено webhook'ом (recovered): {{ $missingCount }}
  • -
  • Восстановлено в БД: {{ $recoveredCount }}
  • -
  • Drift ratio: {{ number_format($driftRatio * 100, 2, ',', ' ') }}% (порог 5%)
  • -
-

Подробности — в таблице supplier_csv_reconcile_log.id = {{ $reconcileLogId }}.

- - -``` - -- [ ] **Step 6: Создать CsvReconcileJob** - -```php - row]. - * 5. SELECT existing vid'ы из supplier_leads (BYPASSRLS). - * 6. Diff = missing. - * 7. Для каждой missing — INSERT supplier_leads (recovered_from_csv_at) + dispatch RouteJob. - * 8. UPDATE log с метриками + status. - * 9. drift > 5% → CsvDriftAlertMail + alert_email_sent_at. - * 10. На exception — status='failed', throw. - */ -final class CsvReconcileJob implements ShouldQueue -{ - use FoundationQueueable; - use InteractsWithQueue; - use Queueable; - use SerializesModels; - - public int $tries = 1; - public int $timeout = 300; - - private const DB_CONNECTION = 'pgsql_supplier'; - private const DRIFT_THRESHOLD = 0.05; - private const WINDOW_HOURS = 25; - private const LOCK_NAME = 'supplier:csv_reconcile'; - private const LOCK_TTL_SECONDS = 600; - - public function handle( - SupplierPortalClient $portal, - SupplierCsvParser $parser, - Mailer $mailer, - ): void { - $lock = Cache::store('redis')->lock(self::LOCK_NAME, self::LOCK_TTL_SECONDS); - if (! $lock->get()) { - Log::info('csv_reconcile.skipped_overlap'); - return; - } - - $windowEnd = Carbon::now(); - $windowStart = (clone $windowEnd)->subHours(self::WINDOW_HOURS); - - $logId = DB::connection(self::DB_CONNECTION) - ->table('supplier_csv_reconcile_log') - ->insertGetId([ - 'started_at' => now(), 'window_start' => $windowStart, 'window_end' => $windowEnd, - 'status' => 'running', 'created_at' => now(), - ]); - - try { - $csv = $portal->downloadLeadsCsv($windowStart, $windowEnd); - - /** @var array> $csvByVid */ - $csvByVid = []; - foreach ($parser->parse($csv) as $row) { - $csvByVid[$row['vid']] = $row; - } - $totalCsvRows = count($csvByVid); - - $existing = DB::connection(self::DB_CONNECTION) - ->table('supplier_leads') - ->where('received_at', '>=', $windowStart) - ->where('received_at', '<', $windowEnd->copy()->addHour()) - ->pluck('vid') - ->all(); - - $existingMap = array_flip($existing); - $missing = array_diff_key($csvByVid, $existingMap); - - $recoveredCount = 0; - foreach ($missing as $vid => $row) { - try { - $lead = SupplierLead::create([ - 'vid' => $vid, - 'phone' => (string) $row['phone'], - 'raw_payload' => $row, - 'received_at' => Carbon::createFromTimestamp((int) $row['time']), - 'recovered_from_csv_at' => now(), - 'supplier_project_id' => null, // ResolverStub зарезолвит при RouteJob run - ]); - RouteSupplierLeadJob::dispatch($lead->id); - $recoveredCount++; - } catch (\Illuminate\Database\QueryException $e) { - if (str_contains($e->getMessage(), 'unique')) { - Log::info('csv_reconcile.duplicate_vid_skipped', ['vid' => $vid]); - continue; - } - throw $e; - } - } - - $matchedCount = $totalCsvRows - count($missing); - $driftRatio = $totalCsvRows > 0 ? count($missing) / $totalCsvRows : 0.0; - $status = $driftRatio > self::DRIFT_THRESHOLD ? 'drift_alert' : 'ok'; - - $update = [ - 'finished_at' => now(), 'total_csv_rows' => $totalCsvRows, - 'matched_count' => $matchedCount, 'recovered_count' => $recoveredCount, - 'drift_ratio' => $driftRatio, 'status' => $status, - ]; - - if ($status === 'drift_alert') { - $mailer->to((string) config('services.supplier.alert_email')) - ->send(new CsvDriftAlertMail( - reconcileLogId: $logId, - totalCsvRows: $totalCsvRows, - missingCount: count($missing), - recoveredCount: $recoveredCount, - driftRatio: $driftRatio, - windowStart: $windowStart, - windowEnd: $windowEnd, - )); - $update['alert_email_sent_at'] = now(); - } - - DB::connection(self::DB_CONNECTION) - ->table('supplier_csv_reconcile_log')->where('id', $logId)->update($update); - - } catch (Throwable $e) { - DB::connection(self::DB_CONNECTION) - ->table('supplier_csv_reconcile_log')->where('id', $logId)->update([ - 'finished_at' => now(), - 'status' => 'failed', - 'error_message' => substr($e->getMessage(), 0, 1000), - ]); - throw $e; - } finally { - $lock->release(); - } - } -} -``` - -- [ ] **Step 7: Добавить Schedule entry в routes/console.php** - -После Plan 3 entries (последняя строка `Schedule::command('supplier:retry-failed')->hourly()`): - -```php -// Plan 4 Task 8: hourly CSV reconciliation (резерв-канал приёма лидов). -Schedule::job(new \App\Jobs\Supplier\CsvReconcileJob)->hourly(); -``` - -И в use-блоке вверху файла: - -```php -use App\Jobs\Supplier\CsvReconcileJob; -``` - -- [ ] **Step 8: Запустить — PASS** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Supplier/CsvReconcileJobTest.php -``` - -Expected: 6 PASS. - -- [ ] **Step 9: Pint + Larastan + полный Pest** - -```bash -cd app && composer pint && composer stan && ./vendor/bin/pest --parallel -``` - -- [ ] **Step 10: Commit** - -```bash -git add app/app/Jobs/Supplier/CsvReconcileJob.php \ - app/app/Mail/CsvDriftAlertMail.php \ - app/resources/views/emails/csv_drift_alert.blade.php \ - app/routes/console.php \ - app/config/services.php \ - app/tests/Feature/Supplier/CsvReconcileJobTest.php -git commit -m "feat(supplier): Plan 4 Task 8 — CsvReconcileJob hourly + drift>5% email + supplier_csv_reconcile_log" -``` - ---- - -## Task 9: AdminPricingTiersController + Vue view + Histoire - -**Files:** - -- Create: `app/app/Http/Controllers/Api/AdminPricingTiersController.php` -- Modify: [app/routes/web.php](../../app/routes/web.php) (+ Route::prefix `/api/admin/pricing-tiers`) -- Create: `app/tests/Feature/Admin/AdminPricingTiersControllerTest.php` -- Create: `app/resources/js/views/admin/AdminPricingTiersView.vue` -- Create: `app/resources/js/views/admin/AdminPricingTiersView.story.vue` -- Modify: `app/resources/js/router/index.ts` -- Create: `app/tests/Vitest/views/admin/AdminPricingTiersView.spec.ts` - -- [ ] **Step 1: Написать failing backend test** - -```php -run(); -}); - -it('GET /api/admin/pricing-tiers returns active + scheduled sets', function () { - $response = $this->getJson('/api/admin/pricing-tiers'); - $response->assertOk(); - expect($response->json('data.active'))->toHaveCount(7); - expect($response->json('data.scheduled'))->toBeArray(); -}); - -it('POST creates 7 new tiers with auto effective_from = 1st of next month', function () { - $payload = [ - 'tiers' => [ - ['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '600.00'], - ['tier_no' => 2, 'leads_in_tier' => 150, 'price_rub' => '550.00'], - ['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'], - ['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'], - ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'], - ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'], - ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '300.00'], - ], - ]; - $response = $this->postJson('/api/admin/pricing-tiers', $payload); - $response->assertCreated(); - - $expectedDate = now()->startOfMonth()->addMonth()->toDateString(); - $newTiers = PricingTier::where('effective_from', $expectedDate)->get(); - expect($newTiers)->toHaveCount(7); - expect($newTiers->where('tier_no', 1)->first()->price_per_lead_kopecks)->toBe(60000); - expect($newTiers->where('tier_no', 7)->first()->leads_in_tier)->toBeNull(); -}); - -it('POST validates: exactly 7 rows required', function () { - $payload = ['tiers' => [ - ['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '600.00'], - ]]; - $response = $this->postJson('/api/admin/pricing-tiers', $payload); - $response->assertStatus(422); - $response->assertJsonValidationErrorFor('tiers'); -}); - -it('POST validates: tier_no must be unique 1..7', function () { - $payload = ['tiers' => [ - ['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '600.00'], - ['tier_no' => 1, 'leads_in_tier' => 150, 'price_rub' => '550.00'], // дубль - ['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'], - ['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'], - ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'], - ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'], - ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '300.00'], - ]]; - $this->postJson('/api/admin/pricing-tiers', $payload)->assertStatus(422); -}); - -it('POST validates: tier 7 leads_in_tier must be null', function () { - $payload = ['tiers' => [ - ['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '600.00'], - ['tier_no' => 2, 'leads_in_tier' => 150, 'price_rub' => '550.00'], - ['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'], - ['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'], - ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'], - ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'], - ['tier_no' => 7, 'leads_in_tier' => 99999, 'price_rub' => '300.00'], // должен быть NULL - ]]; - $this->postJson('/api/admin/pricing-tiers', $payload)->assertStatus(422); -}); - -it('POST validates: price_rub >= 0', function () { - $payload = ['tiers' => [ - ['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '-1.00'], - ['tier_no' => 2, 'leads_in_tier' => 150, 'price_rub' => '550.00'], - ['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'], - ['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'], - ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'], - ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'], - ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '300.00'], - ]]; - $this->postJson('/api/admin/pricing-tiers', $payload)->assertStatus(422); -}); - -it('DELETE /scheduled/{effective_from} removes future tiers only', function () { - $futureDate = now()->addMonth()->startOfMonth()->toDateString(); - PricingTier::factory()->count(7)->sequence(fn ($s) => ['tier_no' => $s->index + 1]) - ->create(['effective_from' => $futureDate, 'is_active' => true]); - - $this->deleteJson("/api/admin/pricing-tiers/scheduled/{$futureDate}") - ->assertOk(); - - expect(PricingTier::where('effective_from', $futureDate)->count())->toBe(0); - // Старый (seed) активный набор не тронут - expect(PricingTier::where('effective_from', '1970-01-01')->count())->toBe(7); -}); - -it('writes audit-trail row in saas_admin_audit_log on POST', function () { - $payload = ['tiers' => [ - ['tier_no' => 1, 'leads_in_tier' => 50, 'price_rub' => '600.00'], - ['tier_no' => 2, 'leads_in_tier' => 150, 'price_rub' => '550.00'], - ['tier_no' => 3, 'leads_in_tier' => 300, 'price_rub' => '500.00'], - ['tier_no' => 4, 'leads_in_tier' => 700, 'price_rub' => '450.00'], - ['tier_no' => 5, 'leads_in_tier' => 1500, 'price_rub' => '400.00'], - ['tier_no' => 6, 'leads_in_tier' => 3000, 'price_rub' => '350.00'], - ['tier_no' => 7, 'leads_in_tier' => null, 'price_rub' => '300.00'], - ]]; - $this->postJson('/api/admin/pricing-tiers', $payload)->assertCreated(); - - $log = \Illuminate\Support\Facades\DB::table('saas_admin_audit_log') - ->where('action', 'pricing_tiers.create_scheduled')->first(); - expect($log)->not->toBeNull(); -}); -``` - -- [ ] **Step 2: Запустить — FAIL** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Admin/AdminPricingTiersControllerTest.php -``` - -Expected: 8 FAIL ("route not found"). - -- [ ] **Step 3: Реализовать AdminPricingTiersController** - -```php -toDateString(); - - $active = PricingTier::query()->where('is_active', true) - ->where('effective_from', '<=', $today) - ->orderBy('tier_no')->orderBy('effective_from', 'desc') - ->get() - ->groupBy('tier_no') - ->map(fn ($g) => $g->first()) - ->values(); - - $scheduled = PricingTier::query()->where('is_active', true) - ->where('effective_from', '>', $today) - ->orderBy('effective_from')->orderBy('tier_no') - ->get() - ->groupBy('effective_from'); - - return response()->json([ - 'data' => [ - 'active' => $active, - 'scheduled' => $scheduled, - ], - ]); - } - - public function store(Request $request): JsonResponse - { - $request->validate([ - 'tiers' => ['required', 'array', 'size:7'], - 'tiers.*.tier_no' => ['required', 'integer', 'between:1,7'], - 'tiers.*.leads_in_tier' => ['nullable', 'integer', 'min:1'], - 'tiers.*.price_rub' => ['required', 'numeric', 'min:0'], - ]); - - $tiers = $request->input('tiers'); - - // Unique tier_no 1..7 - $tierNos = array_column($tiers, 'tier_no'); - if (count(array_unique($tierNos)) !== 7) { - abort(422, json_encode(['message' => 'tier_no must be unique 1..7'])); - } - if (array_diff([1, 2, 3, 4, 5, 6, 7], $tierNos) !== []) { - abort(422, json_encode(['message' => 'all 7 tier_no values required'])); - } - - // Tier 7 must have leads_in_tier=null - $tier7 = collect($tiers)->firstWhere('tier_no', 7); - if ($tier7['leads_in_tier'] !== null) { - abort(422, json_encode(['message' => 'tier_no=7 leads_in_tier must be null'])); - } - - // Tiers 1..6 must have leads_in_tier > 0 - foreach ($tiers as $tier) { - if ($tier['tier_no'] !== 7 && ($tier['leads_in_tier'] === null || $tier['leads_in_tier'] < 1)) { - abort(422, json_encode(['message' => "tier_no={$tier['tier_no']} leads_in_tier must be >= 1"])); - } - } - - $effectiveFrom = Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString(); - - DB::transaction(function () use ($tiers, $effectiveFrom) { - foreach ($tiers as $tier) { - PricingTier::create([ - 'tier_no' => $tier['tier_no'], - 'leads_in_tier' => $tier['leads_in_tier'], - 'price_per_lead_kopecks' => (int) round(((float) $tier['price_rub']) * 100), - 'is_active' => true, - 'effective_from' => $effectiveFrom, - ]); - } - - DB::table('saas_admin_audit_log')->insert([ - 'admin_user_id' => null, // SSO ⏸ Б-1 - 'action' => 'pricing_tiers.create_scheduled', - 'target_type' => 'pricing_tiers', - 'target_id' => null, - 'payload' => json_encode(['effective_from' => $effectiveFrom, 'tiers' => $tiers]), - 'created_at' => now(), - ]); - }); - - return response()->json(['effective_from' => $effectiveFrom], Response::HTTP_CREATED); - } - - public function deleteScheduled(string $effectiveFrom): JsonResponse - { - if (! preg_match('/^\d{4}-\d{2}-\d{2}$/', $effectiveFrom)) { - abort(400, 'invalid date format'); - } - - $todayMsk = Carbon::now('Europe/Moscow')->toDateString(); - if ($effectiveFrom <= $todayMsk) { - abort(409, 'cannot delete past or active set'); - } - - DB::transaction(function () use ($effectiveFrom) { - $deleted = PricingTier::where('effective_from', $effectiveFrom)->delete(); - - DB::table('saas_admin_audit_log')->insert([ - 'admin_user_id' => null, - 'action' => 'pricing_tiers.delete_scheduled', - 'target_type' => 'pricing_tiers', - 'target_id' => null, - 'payload' => json_encode(['effective_from' => $effectiveFrom, 'rows_deleted' => $deleted]), - 'created_at' => now(), - ]); - }); - - return response()->json(['ok' => true]); - } -} -``` - -- [ ] **Step 4: Добавить маршруты в routes/web.php** - -В [app/routes/web.php](../../app/routes/web.php), после строк AdminSystemSettings (~99): - -```php -// Plan 4: SaaS-admin pricing-tiers editor. -Route::prefix('/api/admin/pricing-tiers')->group(function () { - Route::get('/', 'App\Http\Controllers\Api\AdminPricingTiersController@index'); - Route::post('/', 'App\Http\Controllers\Api\AdminPricingTiersController@store'); - Route::delete('/scheduled/{effective_from}', - 'App\Http\Controllers\Api\AdminPricingTiersController@deleteScheduled') - ->where('effective_from', '\d{4}-\d{2}-\d{2}'); -}); -``` - -- [ ] **Step 5: Запустить backend test — PASS** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Admin/AdminPricingTiersControllerTest.php -``` - -Expected: 8 PASS. - -- [ ] **Step 6: Создать Vue компонент AdminPricingTiersView.vue** - -```vue - - - - - - -``` - -- [ ] **Step 7: Создать Histoire story** - -```vue - - - - -``` - -- [ ] **Step 8: Добавить route в router/index.ts** - -В `app/resources/js/router/index.ts` найти секцию admin-маршрутов и добавить: - -```ts -{ - path: '/admin/pricing-tiers', - name: 'admin-pricing-tiers', - component: () => import('@/views/admin/AdminPricingTiersView.vue'), - meta: { layout: 'app', requiresAdmin: true }, -}, -``` - -- [ ] **Step 9: Создать Vitest test** - -```ts -// app/tests/Vitest/views/admin/AdminPricingTiersView.spec.ts -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { createVuetify } from 'vuetify'; -import * as components from 'vuetify/components'; -import * as directives from 'vuetify/directives'; -import axios from 'axios'; -import AdminPricingTiersView from '@/views/admin/AdminPricingTiersView.vue'; - -vi.mock('axios'); - -const vuetify = createVuetify({ components, directives }); - -const mockTiers = [ - { tier_no: 1, leads_in_tier: 100, price_per_lead_kopecks: 50000, effective_from: '1970-01-01' }, - { tier_no: 2, leads_in_tier: 200, price_per_lead_kopecks: 45000, effective_from: '1970-01-01' }, - { tier_no: 3, leads_in_tier: 400, price_per_lead_kopecks: 40000, effective_from: '1970-01-01' }, - { tier_no: 4, leads_in_tier: 800, price_per_lead_kopecks: 35000, effective_from: '1970-01-01' }, - { tier_no: 5, leads_in_tier: 1500, price_per_lead_kopecks: 30000, effective_from: '1970-01-01' }, - { tier_no: 6, leads_in_tier: 3000, price_per_lead_kopecks: 27000, effective_from: '1970-01-01' }, - { tier_no: 7, leads_in_tier: null, price_per_lead_kopecks: 25000, effective_from: '1970-01-01' }, -]; - -describe('AdminPricingTiersView', () => { - beforeEach(() => { - (axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } }); - (axios.post as any).mockResolvedValue({ data: { effective_from: '2026-06-01' } }); - (axios.delete as any).mockResolvedValue({ data: { ok: true } }); - }); - - it('renders 7 tier rows from /api/admin/pricing-tiers', async () => { - const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } }); - await new Promise(r => setTimeout(r, 50)); - expect(wrapper.text()).toContain('500.00'); - expect(wrapper.text()).toContain('250.00'); - }); - - it('shows "все свыше" for tier 7 with leads_in_tier=null', async () => { - const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } }); - await new Promise(r => setTimeout(r, 50)); - expect(wrapper.text()).toContain('все свыше'); - }); - - it('opens editor dialog on button click', async () => { - const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } }); - await new Promise(r => setTimeout(r, 50)); - expect(wrapper.vm.editorOpen).toBe(false); - wrapper.vm.editorOpen = true; - await wrapper.vm.$nextTick(); - expect(wrapper.vm.editorOpen).toBe(true); - }); - - it('submits POST with editor.value payload', async () => { - const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } }); - await new Promise(r => setTimeout(r, 50)); - wrapper.vm.editorOpen = true; - await wrapper.vm.submit(); - expect(axios.post).toHaveBeenCalledWith('/api/admin/pricing-tiers', expect.objectContaining({ - tiers: expect.arrayContaining([ - expect.objectContaining({ tier_no: 7, leads_in_tier: null }), - ]), - })); - }); - - it('confirmDelete triggers DELETE to /scheduled/{date}', async () => { - window.confirm = vi.fn(() => true); - const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } }); - await new Promise(r => setTimeout(r, 50)); - await wrapper.vm.confirmDelete('2026-06-01'); - expect(axios.delete).toHaveBeenCalledWith('/api/admin/pricing-tiers/scheduled/2026-06-01'); - }); -}); -``` - -- [ ] **Step 10: Запустить Vitest — PASS** - -```bash -cd app && npm run test:vue -- AdminPricingTiersView -``` - -Expected: 5 PASS. - -- [ ] **Step 11: Pint + Larastan + полный Pest + Histoire build smoke** - -```bash -cd app && composer pint && composer stan && ./vendor/bin/pest --parallel && npm run lint:vue && npm run type-check -``` - -```bash -cd app && npm run story:build 2>&1 | tail -20 -``` - -Expected: Histoire builds without errors; AdminPricingTiersView.story registered. - -- [ ] **Step 12: Commit** - -```bash -git add app/app/Http/Controllers/Api/AdminPricingTiersController.php \ - app/routes/web.php \ - app/tests/Feature/Admin/AdminPricingTiersControllerTest.php \ - app/resources/js/views/admin/AdminPricingTiersView.vue \ - app/resources/js/views/admin/AdminPricingTiersView.story.vue \ - app/resources/js/router/index.ts \ - app/tests/Vitest/views/admin/AdminPricingTiersView.spec.ts -git commit -m "feat(admin): Plan 4 Task 9 — AdminPricingTiersController + AdminPricingTiersView (CRUD 7-tier + audit)" -``` - ---- - -## Task 10: AdminSuppliersController + Vue view + Histoire - -**Files:** - -- Create: `app/app/Http/Controllers/Api/AdminSuppliersController.php` -- Modify: [app/routes/web.php](../../app/routes/web.php) -- Create: `app/tests/Feature/Admin/AdminSuppliersControllerTest.php` -- Create: `app/resources/js/views/admin/AdminSupplierPricesView.vue` -- Create: `app/resources/js/views/admin/AdminSupplierPricesView.story.vue` -- Modify: `app/resources/js/router/index.ts` -- Create: `app/tests/Vitest/views/admin/AdminSupplierPricesView.spec.ts` - -- [ ] **Step 1: Написать failing backend test** - -```php -getJson('/api/admin/suppliers'); - $response->assertOk(); - $data = $response->json('data'); - expect($data)->toHaveCount(3); - expect(collect($data)->pluck('code')->all())->toContain('b1', 'b2', 'b3'); -}); - -it('PATCH updates cost_rub for supplier', function () { - $b1 = Supplier::where('code', 'b1')->first(); - $oldCost = (string) $b1->cost_rub; - - $this->patchJson("/api/admin/suppliers/{$b1->id}", ['cost_rub' => '1.50']) - ->assertOk(); - - expect((string) $b1->fresh()->cost_rub)->toBe('1.50'); - expect((string) $b1->fresh()->cost_rub)->not->toBe($oldCost); -}); - -it('PATCH validates cost_rub >= 0', function () { - $b1 = Supplier::where('code', 'b1')->first(); - - $this->patchJson("/api/admin/suppliers/{$b1->id}", ['cost_rub' => '-1.00']) - ->assertStatus(422); -}); - -it('PATCH writes saas_admin_audit_log row', function () { - $b1 = Supplier::where('code', 'b1')->first(); - - $this->patchJson("/api/admin/suppliers/{$b1->id}", ['cost_rub' => '2.00']) - ->assertOk(); - - $log = \Illuminate\Support\Facades\DB::table('saas_admin_audit_log') - ->where('action', 'suppliers.update')->first(); - expect($log)->not->toBeNull(); -}); -``` - -- [ ] **Step 2: Запустить — FAIL** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Admin/AdminSuppliersControllerTest.php -``` - -- [ ] **Step 3: Реализовать AdminSuppliersController** - -```php -json([ - 'data' => Supplier::query()->orderBy('sort_order')->get(), - ]); - } - - public function update(Request $request, int $id): JsonResponse - { - $request->validate([ - 'cost_rub' => ['sometimes', 'numeric', 'min:0'], - 'quality_score' => ['sometimes', 'numeric', 'between:0,9.99'], - 'is_active' => ['sometimes', 'boolean'], - ]); - - $supplier = Supplier::findOrFail($id); - $changes = $request->only(['cost_rub', 'quality_score', 'is_active']); - - DB::transaction(function () use ($supplier, $changes) { - $before = $supplier->only(array_keys($changes)); - $supplier->update($changes); - - DB::table('saas_admin_audit_log')->insert([ - 'admin_user_id' => null, // ⏸ Б-1 - 'action' => 'suppliers.update', - 'target_type' => 'suppliers', - 'target_id' => $supplier->id, - 'payload' => json_encode(['before' => $before, 'after' => $changes]), - 'created_at' => now(), - ]); - }); - - return response()->json(['data' => $supplier->fresh()]); - } -} -``` - -- [ ] **Step 4: Добавить routes в web.php** - -```php -// app/routes/web.php — после Plan 4 Task 9 pricing-tiers routes -Route::get('/api/admin/suppliers', 'App\Http\Controllers\Api\AdminSuppliersController@index'); -Route::patch('/api/admin/suppliers/{id}', 'App\Http\Controllers\Api\AdminSuppliersController@update') - ->where('id', '[0-9]+'); -``` - -- [ ] **Step 5: Запустить — PASS** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Admin/AdminSuppliersControllerTest.php -``` - -Expected: 4 PASS. - -- [ ] **Step 6: Создать Vue компонент AdminSupplierPricesView.vue** - -```vue - - - - - - -``` - -- [ ] **Step 7: Histoire story** - -```vue - - - - -``` - -- [ ] **Step 8: Route в router/index.ts** - -```ts -{ - path: '/admin/supplier-prices', - name: 'admin-supplier-prices', - component: () => import('@/views/admin/AdminSupplierPricesView.vue'), - meta: { layout: 'app', requiresAdmin: true }, -}, -``` - -- [ ] **Step 9: Vitest тесты** - -```ts -// app/tests/Vitest/views/admin/AdminSupplierPricesView.spec.ts -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { createVuetify } from 'vuetify'; -import * as components from 'vuetify/components'; -import * as directives from 'vuetify/directives'; -import axios from 'axios'; -import AdminSupplierPricesView from '@/views/admin/AdminSupplierPricesView.vue'; - -vi.mock('axios'); -const vuetify = createVuetify({ components, directives }); - -const mockSuppliers = [ - { id: 1, code: 'b1', name: 'B1 — Сайты и Звонки', cost_rub: '1.00', quality_score: '1.00', is_active: true }, - { id: 2, code: 'b2', name: 'B2 — SMS', cost_rub: '1.50', quality_score: '1.00', is_active: true }, - { id: 3, code: 'b3', name: 'B3 — SMS', cost_rub: '1.20', quality_score: '0.95', is_active: true }, -]; - -describe('AdminSupplierPricesView', () => { - beforeEach(() => { - (axios.get as any).mockResolvedValue({ data: { data: mockSuppliers } }); - (axios.patch as any).mockResolvedValue({ data: { data: mockSuppliers[0] } }); - }); - - it('renders 3 supplier rows', async () => { - const w = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } }); - await new Promise(r => setTimeout(r, 50)); - expect(w.text()).toContain('b1'); - expect(w.text()).toContain('b2'); - expect(w.text()).toContain('b3'); - }); - - it('save() fires PATCH with cost_rub/quality_score/is_active', async () => { - const w = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } }); - await new Promise(r => setTimeout(r, 50)); - await w.vm.save({ id: 1, code: 'b1', name: '', cost_rub: '2.00', quality_score: '1.00', is_active: true }); - expect(axios.patch).toHaveBeenCalledWith('/api/admin/suppliers/1', { - cost_rub: '2.00', quality_score: '1.00', is_active: true, - }); - }); - - it('renders quality_score, cost_rub as editable text-fields', async () => { - const w = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } }); - await new Promise(r => setTimeout(r, 50)); - const inputs = w.findAll('input[type="number"]'); - expect(inputs.length).toBeGreaterThanOrEqual(6); // 3 rows × 2 числовых поля - }); -}); -``` - -- [ ] **Step 10: Pint + Larastan + полный Pest + npm run test:vue** - -```bash -cd app && composer pint && composer stan && ./vendor/bin/pest --parallel && npm run test:vue -- AdminSupplierPrices && npm run lint:vue && npm run type-check -``` - -- [ ] **Step 11: Commit** - -```bash -git add app/app/Http/Controllers/Api/AdminSuppliersController.php \ - app/routes/web.php \ - app/tests/Feature/Admin/AdminSuppliersControllerTest.php \ - app/resources/js/views/admin/AdminSupplierPricesView.vue \ - app/resources/js/views/admin/AdminSupplierPricesView.story.vue \ - app/resources/js/router/index.ts \ - app/tests/Vitest/views/admin/AdminSupplierPricesView.spec.ts -git commit -m "feat(admin): Plan 4 Task 10 — AdminSuppliersController + AdminSupplierPricesView (B1/B2/B3 cost editor)" -``` - ---- - -## Task 11: TenantChargesController + ChargesTab в BillingView + CSV export - -**Files:** - -- Create: `app/app/Http/Controllers/Api/TenantChargesController.php` -- Modify: [app/routes/web.php](../../app/routes/web.php) -- Create: `app/tests/Feature/Billing/TenantChargesControllerTest.php` -- Create: `app/resources/js/views/billing/ChargesTab.vue` -- Create: `app/resources/js/views/billing/ChargesTab.story.vue` -- Modify: `app/resources/js/views/BillingView.vue` -- Create: `app/tests/Vitest/views/billing/ChargesTab.spec.ts` - -- [ ] **Step 1: Написать failing backend test** - -```php -run(); - - $this->tenant = Tenant::factory()->create(); - $this->user = User::factory()->create(['tenant_id' => $this->tenant->id]); - Sanctum::actingAs($this->user); -}); - -function makeChargeFor(Tenant $tenant, array $overrides = []): LeadCharge -{ - $deal = Deal::factory()->create(['tenant_id' => $tenant->id, 'received_at' => now()]); - return LeadCharge::factory()->create(array_merge([ - 'tenant_id' => $tenant->id, - 'deal_id' => $deal->id, - 'deal_received_at' => $deal->received_at, - 'charged_at' => now(), - ], $overrides)); -} - -it('GET /api/billing/charges returns paginated list for current tenant only (RLS)', function () { - makeChargeFor($this->tenant); - makeChargeFor($this->tenant); - - $otherTenant = Tenant::factory()->create(); - makeChargeFor($otherTenant); - - $response = $this->getJson('/api/billing/charges'); - $response->assertOk(); - expect($response->json('data'))->toHaveCount(2); -}); - -it('filters by charge_source=prepaid', function () { - makeChargeFor($this->tenant, ['charge_source' => 'rub', 'price_per_lead_kopecks' => 50000]); - makeChargeFor($this->tenant, ['charge_source' => 'prepaid', 'price_per_lead_kopecks' => 0]); - makeChargeFor($this->tenant, ['charge_source' => 'prepaid', 'price_per_lead_kopecks' => 0]); - - $response = $this->getJson('/api/billing/charges?charge_source=prepaid'); - expect($response->json('data'))->toHaveCount(2); -}); - -it('filters by period=current_month / last_month / 90d', function () { - makeChargeFor($this->tenant, ['charged_at' => now()]); - makeChargeFor($this->tenant, ['charged_at' => now()->subMonth()]); - makeChargeFor($this->tenant, ['charged_at' => now()->subDays(60)]); - makeChargeFor($this->tenant, ['charged_at' => now()->subDays(120)]); - - $this->getJson('/api/billing/charges?period=current_month') - ->assertJsonCount(1, 'data'); - $this->getJson('/api/billing/charges?period=last_month') - ->assertJsonCount(1, 'data'); - $this->getJson('/api/billing/charges?period=90d') - ->assertJsonCount(3, 'data'); // current + last + 60d (90d ≥ 60) -}); - -it('returns 401 без auth', function () { - auth()->logout(); - Sanctum::actingAs(null); - $this->getJson('/api/billing/charges')->assertStatus(401); -}); - -it('pagination: ?page=2 returns next slice', function () { - for ($i = 0; $i < 30; $i++) { - makeChargeFor($this->tenant); - } - - $page1 = $this->getJson('/api/billing/charges?page=1'); - $page2 = $this->getJson('/api/billing/charges?page=2'); - - expect($page1->json('data'))->toHaveCount(20); // default per_page - expect($page2->json('data'))->toHaveCount(10); -}); - -it('POST /export streams CSV via StreamedResponse', function () { - makeChargeFor($this->tenant); - - $response = $this->postJson('/api/billing/charges/export', ['period' => '90d']); - $response->assertOk(); - $response->assertHeader('Content-Type', 'text/csv; charset=UTF-8'); - - $body = $response->streamedContent(); - expect($body)->toContain('charged_at,deal_id,tier_no,charge_source,price_rub,balance_rub_after'); -}); -``` - -- [ ] **Step 2: Запустить — FAIL** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Billing/TenantChargesControllerTest.php -``` - -- [ ] **Step 3: Реализовать TenantChargesController** - -```php -orderBy('charged_at', 'desc'); - - $this->applyFilters($query, $request); - - $page = $query->paginate(20); - - return response()->json([ - 'data' => $page->items(), - 'meta' => [ - 'current_page' => $page->currentPage(), - 'last_page' => $page->lastPage(), - 'total' => $page->total(), - 'per_page' => $page->perPage(), - ], - ]); - } - - public function export(Request $request): StreamedResponse - { - $query = LeadCharge::query()->orderBy('charged_at', 'desc'); - $this->applyFilters($query, $request); - - $filename = 'charges_'.now()->format('Y-m-d_His').'.csv'; - - return response()->stream(function () use ($query) { - $out = fopen('php://output', 'w'); - // BOM для Excel - fwrite($out, "\xEF\xBB\xBF"); - fputcsv($out, ['charged_at', 'deal_id', 'tier_no', 'charge_source', 'price_rub', 'balance_rub_after']); - - $query->chunkById(500, function ($charges) use ($out) { - foreach ($charges as $c) { - fputcsv($out, [ - $c->charged_at->toIso8601String(), - $c->deal_id, - $c->tier_no, - $c->charge_source, - number_format($c->price_per_lead_kopecks / 100, 2, '.', ''), - '', // balance_rub_after — нет в lead_charges; для PoC оставляем пустым - ]); - } - }); - - fclose($out); - }, 200, [ - 'Content-Type' => 'text/csv; charset=UTF-8', - 'Content-Disposition' => "attachment; filename=\"{$filename}\"", - ]); - } - - /** - * @param \Illuminate\Database\Eloquent\Builder $query - */ - private function applyFilters($query, Request $request): void - { - $period = $request->query('period'); - $now = Carbon::now('Europe/Moscow'); - - if ($period === 'current_month') { - $query->where('charged_at', '>=', $now->copy()->startOfMonth()); - } elseif ($period === 'last_month') { - $query->whereBetween('charged_at', [ - $now->copy()->subMonth()->startOfMonth(), - $now->copy()->subMonth()->endOfMonth(), - ]); - } elseif ($period === '90d') { - $query->where('charged_at', '>=', $now->copy()->subDays(90)); - } - - if ($source = $request->query('charge_source')) { - $query->where('charge_source', $source); - } - } -} -``` - -- [ ] **Step 4: Добавить routes** - -```php -// app/routes/web.php — рядом с другими auth+tenant маршрутами Plan 2 -Route::middleware(['auth:sanctum', 'tenant'])->prefix('/api/billing/charges')->group(function () { - Route::get('/', 'App\Http\Controllers\Api\TenantChargesController@index'); - Route::post('/export', 'App\Http\Controllers\Api\TenantChargesController@export'); -}); -``` - -- [ ] **Step 5: Запустить — PASS** - -```bash -cd app && ./vendor/bin/pest tests/Feature/Billing/TenantChargesControllerTest.php -``` - -Expected: 6 PASS. - -- [ ] **Step 6: Создать ChargesTab.vue** - -```vue - - - - - - -``` - -- [ ] **Step 7: Histoire story** - -```vue - - - - -``` - -- [ ] **Step 8: Добавить tab в BillingView.vue** - -Открыть `app/resources/js/views/BillingView.vue`. Грепнуть текущую `` структуру: - -```bash -grep -n "v-tabs\|v-tab\b" app/resources/js/views/BillingView.vue -``` - -Добавить новый tab «Списания»: - -```vue - -Списания - - - - - -``` - -В ` - - -``` - -- [ ] **Step 3.4: Re-run test to verify pass** - -```bash -cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/BulkActionsBar.spec.ts -``` - -Expected: ALL PASS (existing + 2 new). - -- [ ] **Step 3.5: Full Projects-related regression** - -```bash -cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/ProjectsView.spec.ts app/tests/Frontend/BulkActionsBar.spec.ts -``` - -Expected: ALL PASS. - -- [ ] **Step 3.6: vue-tsc + ESLint** - -```bash -cd "c:/моя/проекты/портал crm/Документация/app" && npm run type-check && npm run lint:vue -``` - -Expected: 0 errors на BulkActionsBar.vue. - -- [ ] **Step 3.7: Manual browser smoke** - -http://127.0.0.1:8000/projects (после login). Выбрать 2+ проекта → BulkActionsBar появляется снизу → «Приостановить» → confirm → если backend вернёт skipped > 0 → должен показаться snackbar (не alert). - -- [ ] **Step 3.8: Commit** - -```bash -cd "c:/моя/проекты/портал crm/Документация" && git add app/resources/js/components/projects/BulkActionsBar.vue app/tests/Frontend/BulkActionsBar.spec.ts && git commit -m "$(cat <<'EOF' -fix(projects): C5 — replace window.alert() with v-snackbar in BulkActionsBar - -window.alert блокирует UI thread, не accessible (a11y), breaks браузерный -automation (Playwright/Selenium). Заменено на v-snackbar (timeout 6s, -color warning, location bottom-right, кнопка «Закрыть»). Текст идентичен: -«Применено: N. Пропущено: M (конфликт с уже доставленными лидами).» - -+2 Vitest specs (snackbar opens / snackbar НЕ opens at skipped=0). -window.confirm для pause/resume/archive намеренно оставлен — это -deliberate blocking прерывание для деструктивных операций (UX-pattern). - -Closes audit ID C5 from docs/superpowers/specs/2026-05-15-portal-audit-design.md. - -Co-Authored-By: Claude Opus 4.7 (1M context) -EOF -)" -``` - -Expected: lefthook green. - ---- - -## Task 4: G1 — AdminPricingTiersView submit/delete error handling - -**Source lines:** [AdminPricingTiersView.vue:157-174](../../../app/resources/js/views/admin/AdminPricingTiersView.vue#L157-L174): - -```ts -async function submit(): Promise { - saving.value = true; - try { - await axios.post('/api/admin/pricing-tiers', { tiers: editor.value }); - editorOpen.value = false; - await load(); - } finally { - saving.value = false; - } -} - -async function confirmDelete(effectiveFrom: string): Promise { - if (!window.confirm(`Удалить запланированный набор с ${effectiveFrom}?`)) { - return; - } - await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`); - await load(); -} -``` - -**Files:** - -- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue` (script: + errorMessage + successMessage refs, + try/catch in submit/confirmDelete; template: + v-alert + v-snackbar) -- Test: `app/tests/Frontend/AdminPricingTiersView.spec.ts` (extend) - -### Steps - -- [ ] **Step 4.1: Write failing tests for error display** - -Edit `app/tests/Frontend/AdminPricingTiersView.spec.ts` — append at end: - -```ts -describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => { - it('submit() shows errorMessage when axios.post rejects with 422', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (axios.post as any).mockRejectedValue({ - response: { status: 422, data: { message: 'Validation failed: tier 7 leads_in_tier must be null' } }, - }); - const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } }); - await new Promise((r) => setTimeout(r, 50)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (wrapper.vm as any).submit(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const vm = wrapper.vm as any; - expect(vm.errorMessage).toContain('Validation failed'); - expect(vm.saving).toBe(false); - // Dialog should remain OPEN so user can fix and retry - expect(vm.editorOpen).toBe(true); - }); - - it('submit() shows successMessage on 200', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (axios.post as any).mockResolvedValue({ data: { effective_from: '2026-06-01' } }); - const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } }); - await new Promise((r) => setTimeout(r, 50)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (wrapper.vm as any).submit(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const vm = wrapper.vm as any; - expect(vm.successMessage).toContain('Сохранено'); - expect(vm.errorMessage).toBe(null); - expect(vm.saving).toBe(false); - expect(vm.editorOpen).toBe(false); - }); - - it('confirmDelete() shows errorMessage when axios.delete rejects', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (axios.delete as any).mockRejectedValue({ - response: { status: 500, data: { message: 'Database connection failed' } }, - }); - window.confirm = vi.fn(() => true); - const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } }); - await new Promise((r) => setTimeout(r, 50)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (wrapper.vm as any).confirmDelete('2026-06-01'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((wrapper.vm as any).errorMessage).toContain('Database connection failed'); - }); - - it('confirmDelete() shows successMessage on OK', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (axios.delete as any).mockResolvedValue({ data: { ok: true } }); - window.confirm = vi.fn(() => true); - const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } }); - await new Promise((r) => setTimeout(r, 50)); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await (wrapper.vm as any).confirmDelete('2026-06-01'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - expect((wrapper.vm as any).successMessage).toContain('Удалено'); - }); -}); -``` - -- [ ] **Step 4.2: Run test to verify fail** - -```bash -cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/AdminPricingTiersView.spec.ts -t "error handling" -``` - -Expected: FAIL — «errorMessage is undefined», «successMessage is undefined». - -- [ ] **Step 4.3: Add error/success state + try/catch to AdminPricingTiersView** - -Edit `app/resources/js/views/admin/AdminPricingTiersView.vue` script section — заменить: - -```ts - -``` - -И добавить в `