From c35c7ba06563ba153adddd0426c689cd6e6613f8 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: Wed, 27 May 2026 03:58:23 +0300 Subject: [PATCH 1/3] =?UTF-8?q?fix(lead-router):=20R-12=20=E2=80=94=20remo?= =?UTF-8?q?ve=20balance=5Fleads=20from=20eligibility=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit balance_rub is the only balance used after Spec A Phase A. LeadRouter SQL still referenced legacy balance_leads in OR clause — would crash on Spec B Phase B DROP COLUMN. Filter now only checks balance_rub. Co-Authored-By: Claude Opus 4.7 --- app/app/Services/LeadRouter.php | 4 +- .../Feature/LeadRouter/BalanceFilterTest.php | 67 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 app/tests/Feature/LeadRouter/BalanceFilterTest.php diff --git a/app/app/Services/LeadRouter.php b/app/app/Services/LeadRouter.php index 0df9015d..f67ed61a 100644 --- a/app/app/Services/LeadRouter.php +++ b/app/app/Services/LeadRouter.php @@ -63,7 +63,7 @@ class LeadRouter AND EXISTS ( SELECT 1 FROM tenants WHERE tenants.id = projects.tenant_id - AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0) + AND tenants.balance_rub > 0 ) ORDER BY projects.tenant_id, @@ -94,7 +94,7 @@ class LeadRouter AND EXISTS ( SELECT 1 FROM tenants WHERE tenants.id = projects.tenant_id - AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0) + AND tenants.balance_rub > 0 ) ORDER BY projects.tenant_id, diff --git a/app/tests/Feature/LeadRouter/BalanceFilterTest.php b/app/tests/Feature/LeadRouter/BalanceFilterTest.php new file mode 100644 index 00000000..d486624b --- /dev/null +++ b/app/tests/Feature/LeadRouter/BalanceFilterTest.php @@ -0,0 +1,67 @@ +create([ + 'balance_rub' => '0.00', + 'balance_leads' => 999, // legacy — должно игнорироваться после фикса + ]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'delivery_days_mask' => 127, + 'daily_limit_target' => 10, + 'delivered_today' => 0, + ]); + $sp = SupplierProject::factory()->create(); + \DB::table('project_supplier_links')->insert([ + 'project_id' => $project->id, + 'supplier_project_id' => $sp->id, + 'platform' => $sp->platform, + 'subject_code' => null, + ]); + + $matched = app(LeadRouter::class)->matchEligibleProjects($sp); + + expect($matched)->toHaveCount(0); // balance_rub=0 → не eligible +}); + +it('matches project for tenant with balance_rub > 0 (balance_leads ignored)', function () { + $tenant = Tenant::factory()->create([ + 'balance_rub' => '500.00', + 'balance_leads' => 0, + ]); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + 'delivery_days_mask' => 127, + 'daily_limit_target' => 10, + 'delivered_today' => 0, + ]); + $sp = SupplierProject::factory()->create(); + \DB::table('project_supplier_links')->insert([ + 'project_id' => $project->id, + 'supplier_project_id' => $sp->id, + 'platform' => $sp->platform, + 'subject_code' => null, + ]); + + $matched = app(LeadRouter::class)->matchEligibleProjects($sp); + + expect($matched)->toHaveCount(1); +}); From 47d29114ee253a60c9b8cac57e5ec7462885e905 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: Wed, 27 May 2026 04:18:04 +0300 Subject: [PATCH 2/3] =?UTF-8?q?fix(supplier):=20R-16=20=E2=80=94=20cleanup?= =?UTF-8?q?=20uses=20pivot,=20not=20legacy=20FK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CleanupInactiveSupplierProjectsJob Phase A/B/C subquery determined active supplier_projects through legacy supplier_b{1,2,3}_project_id FKs, which are NULL for Plan 3+ projects (using project_supplier_links pivot). After 180d TTL these supplier_projects would be deleted from supplier, breaking real lead flow. Subquery now uses pivot. --- .../CleanupInactiveSupplierProjectsJob.php | 19 ++++----- .../Supplier/CleanupInactiveOnPivotTest.php | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 12 deletions(-) create mode 100644 app/tests/Feature/Supplier/CleanupInactiveOnPivotTest.php diff --git a/app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php b/app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php index 39025585..9a340ef2 100644 --- a/app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php +++ b/app/app/Jobs/Supplier/CleanupInactiveSupplierProjectsJob.php @@ -59,19 +59,14 @@ class CleanupInactiveSupplierProjectsJob implements ShouldQueue { $client ??= app(SupplierPortalClient::class); - // Подзапрос — DISTINCT id'шники supplier_projects, на которые ссылается - // хотя бы один Лидерра-project с is_active=true через любой из трёх FK. + // Источник истинности активности — `project_supplier_links` pivot (Plan 3+). + // Legacy FK `supplier_b{1,2,3}_project_id` оставлены для read-compat, + // но не определяют активность. $activeIdsSubquery = <<<'SQL' - SELECT DISTINCT id FROM ( - SELECT supplier_b1_project_id AS id FROM projects - WHERE is_active = true AND supplier_b1_project_id IS NOT NULL - UNION - SELECT supplier_b2_project_id FROM projects - WHERE is_active = true AND supplier_b2_project_id IS NOT NULL - UNION - SELECT supplier_b3_project_id FROM projects - WHERE is_active = true AND supplier_b3_project_id IS NOT NULL - ) AS active_supplier_ids + SELECT DISTINCT psl.supplier_project_id AS id + FROM project_supplier_links psl + INNER JOIN projects p ON p.id = psl.project_id + WHERE p.is_active = true SQL; // Phase A — re-activate (СНАЧАЛА для safety: до Phase C, чтобы недавно diff --git a/app/tests/Feature/Supplier/CleanupInactiveOnPivotTest.php b/app/tests/Feature/Supplier/CleanupInactiveOnPivotTest.php new file mode 100644 index 00000000..3fb34ee6 --- /dev/null +++ b/app/tests/Feature/Supplier/CleanupInactiveOnPivotTest.php @@ -0,0 +1,41 @@ +create(); + $project = Project::factory()->for($tenant)->create([ + 'is_active' => true, + // легаси FK НЕ заполнены (Plan 3+ архитектура): + 'supplier_b1_project_id' => null, + 'supplier_b2_project_id' => null, + 'supplier_b3_project_id' => null, + ]); + $sp = SupplierProject::factory()->create([ + 'inactive_since' => null, + ]); + \DB::table('project_supplier_links')->insert([ + 'project_id' => $project->id, + 'supplier_project_id' => $sp->id, + 'platform' => $sp->platform, + 'subject_code' => null, + ]); + + (new CleanupInactiveSupplierProjectsJob)->handle(app(\App\Services\Supplier\SupplierPortalClient::class)); + + expect($sp->fresh()->inactive_since)->toBeNull(); +}); + +it('marks supplier_project inactive when no pivot link exists', function () { + $sp = SupplierProject::factory()->create(['inactive_since' => null]); + // нет project_supplier_links + + (new CleanupInactiveSupplierProjectsJob)->handle(app(\App\Services\Supplier\SupplierPortalClient::class)); + + expect($sp->fresh()->inactive_since)->not->toBeNull(); +}); From d208f23d930eebacdb4ce9c4f05920ed9315dd72 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: Wed, 27 May 2026 04:54:45 +0300 Subject: [PATCH 3/3] =?UTF-8?q?docs(pilot):=20=D0=AD=D1=82=D0=B0=D0=BF=201?= =?UTF-8?q?=20slepok=20plan=20code=20closure=20=E2=80=94=20Task=201.1/1.2/?= =?UTF-8?q?1.4=20done?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1.1 finalize via PR #24 (origin/main 0ec7e764). Task 1.2 R-12 LeadRouter balance_leads dropped from filter. Task 1.4 R-16 CleanupInactiveJob via project_supplier_links pivot. Pending: Task 1.3 SSH smoke (R-14, prod RouteSupplierLeadJob version verify) + PR feat/slepok-stage-1 → main + redeploy.sh on liderra.ru. --- ПИЛОТ.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ПИЛОТ.md b/ПИЛОТ.md index e6121fda..45ebc5bd 100644 --- a/ПИЛОТ.md +++ b/ПИЛОТ.md @@ -8,6 +8,8 @@ - Волатильную часть (доступ, версии, что развёрнуто) перед рискованными действиями **перепроверять реальной командой по SSH**, не доверять снимку вслепую. - Обновляется по команде заказчика **«обнови пилот»**. +**Снимок снят:** 27.05.2026 (день) — **🛠 РАЗРАБОТКА (прод НЕ затронут): Этап 1 плана slepok-routing-protection — code-часть закрыта.** Ветка `feat/slepok-stage-1` HEAD `4188fcbc` на origin (2 коммита: R-12 LeadRouter DROP balance_leads `bb22c832` + R-16 CleanupInactiveSupplierProjectsJob через pivot `4188fcbc`); main НЕ влит, ждёт PR. **Task 1.1 (rebase spec-c → main) ✅ ЗАКРЫТ** ранее этим днём — PR #24 заказчиком влит в браузере, origin/main `01b50b1e` теперь содержит 13 коммитов Spec C через FF merge после rebase (worktree `.claude/worktrees/billing-v2-spec-c` оставлен; scp-drift между репозиторием и прод-кодом устранён). **Task 1.2 R-12:** `LeadRouter::matchEligibleProjects` SQL в обоих местах (DIRECT path lines 63-67 + B1/B2/B3 path lines 94-98) — было `AND (tenants.balance_leads > 0 OR tenants.balance_rub > 0)`, стало `AND tenants.balance_rub > 0`. Закрывает риск Spec B Phase B DROP COLUMN balance_leads (без фикса LeadRouter крашнулся бы). **Task 1.4 R-16:** `CleanupInactiveSupplierProjectsJob` subquery определения активности — было через legacy FK `supplier_b{1,2,3}_project_id` (UNION 3 SELECT'ов), стало через pivot `project_supplier_links` (INNER JOIN). Закрывает риск 180д TTL-удаления реально-активных supplier_projects от поставщика (Plan 3+ проекты с NULL legacy FK). **Task 1.3 R-14 (SSH-проверка прод-версии RouteSupplierLeadJob)** — pending от заказчика (SSH-команда: `head -210 /var/www/liderra/app/app/Jobs/RouteSupplierLeadJob.php | grep -A3 'parseProjectField|DIRECT'`; ожидаем main-версию с DIRECT path). **Регресс на feat/slepok-stage-1:** Task 1.2 BalanceFilterTest 2/2 pass, Task 1.4 CleanupInactiveOnPivotTest 2/2 pass (841ms). Полный регресс subagent'ом не довёл (зависал на полном `composer test --parallel` на Windows-native PG test-DB env, 21+ pre-existing failures не связаны с фиксами). **Дальше — PR `feat/slepok-stage-1 → main` от заказчика** (https://github.com/CoralMinister/lidpotok/compare/main...feat/slepok-stage-1?expand=1), затем tar+scp+redeploy.sh на боевой, smoke R-12+R-16. **Затем — Этап 2 плана** (12 task'ов, 3-5 дней) — собственная snapshot-таблица `project_routing_snapshots` + cron 18:02 МСК + переключение LeadRouter SQL на JOIN со snapshot. Через прямой Edit ПИЛОТ.md в worktree slepok-stage-1. + **Снимок снят:** 26.05.2026 (поздний вечер UTC) — **📋 АРТЕФАКТЫ ДИЗАЙНА ЗАПИСАНЫ В РЕПО, ПРОД НЕ ЗАТРОНУТ.** Спек v0.4 + план реализации защиты слепка поставщика готовы в `docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md` (820+ строк, 5 этапов, 19 рисков R-01..R-19 покрыты) и `docs/superpowers/plans/2026-05-26-slepok-routing-protection.md` (~1300 строк, 22 task'а × 4-7 шагов TDD). **Это design-only артефакты — реализация не начата, прод не затронут.** Бизнес-инвариант (формулировка заказчика): slepok №NЛ создаётся клиентом до 18:00 МСК → slepok №NП фиксируется поставщиком в 21:00 МСК → лиды по slepok №NЛ/№NП летят с N.21:00 МСК до (N+1).20:59 МСК. **Главная находка аудита (сессия `135a4adf`):** `LeadRouter` сейчас читает live `projects.*` вместо зафиксированного снимка → клиенты paus'нувшие проект сегодня НЕ получают свои оплаченные лиды по вчерашнему слепку поставщика (R-01). **5 этапов реализации ~7.5-10 рабочих дней:** (1) Стабилизация прод-state — merge `feat/billing-v2-spec-c` в main (50+ коммитов backward!) + 3 точечных фикса (R-12/R-14/R-16); (2) Собственная snapshot-таблица `project_routing_snapshots` + cron 18:02 МСК + переключение `LeadRouter` SQL; (3) Frozen filter + paused_at sync; (4) Корректные расчёты (R-17/R-18/R-19/CSV business-drift); (5) Переключение режима `online → batch` после 7-дневного мониторинга. **Open decisions заказчика:** OPEN-1 (поставщик после 21:00 МСК — только завтрашний слепок) ✅; OPEN-2 (resume в любое время = со следующего slepok'а, emergency-кнопка НЕ нужна) ✅; OPEN-5 (`effective_daily_limit_today` — вариант A: сохраняется, в snapshot подхватывается, обнуляется в 00:00 МСК) ✅. **Не верифицировано на проде:** R-11/R-16/R-17/R-19 (SQL для верификации в спеке §11) — assumptions при реализации уточнятся через прод-запросы. **Memory:** `project_slepok_protection.md` + `project_billing_v2.md` (online/batch policy). **Hook `tools/router-tool-gate.mjs`** снят на время batch-commit'а через `/update-config` skill + восстановлен через `git restore .claude/settings.json` после push'а. **Следующий шаг:** ожидает команды на запуск реализации. **Снимок снят:** 26.05.2026 (~09:55 UTC) — **✅ ВЫКАЧЕН на боевой `liderra.ru` Supplier Snapshot Guard** (merge commit `5b7d958e` на `origin/main`, 10 коммитов feature `8b6b4101..fdfaa956` через `worktree-supplier-snapshot-guard` → non-FF merge в main + push). **Что чинит:** прямой убыток Лидерре — клиент создаёт проект до 21:00 МСК → мы передаём поставщику → поставщик в 21:00 заказывает лиды на завтра → клиент удаляет/меняет источник до утра → лиды летят, поступают в webhook (`/api/webhook/supplier/{secret}`), принимаются 202, но `LeadRouter::matchEligibleProjects` для удалённого/изменённого проекта возвращает empty (pivot `project_supplier_links` каскадом удалён или указывает на старый identifier) → сделки в `deals` НЕ создаются → баланс клиента НЕ списывается, **но** поставщик в CSV-reconcile выставит счёт за «доставленные» лиды (`202` принято). **Решение — guard на сервере:** новый pure-сервис `App\Services\Project\SupplierSnapshotGuard` (`computeGraceUntil` + `isProtected` + `assertCanMutateSource`), врезан в `ProjectService::delete()` (приоритет ПЕРЕД has-deals, формулировка перевешивает) и `ProjectService::update()` (только при смене `signal_identifier`/`sms_senders`/`sms_keyword`). Алгоритм защиты: (1) проект без `project_supplier_links` — не защищаем (поставщик про него не знает); (2) `is_active=true` + есть pivot — защищён всегда; (3) `is_active=false` + `paused_at` свежее grace-периода — защищён. **Grace-формула:** anchor 21:00 МСК (час когда поставщик заказывает лиды на следующий день) + 24h на хвост уже-доставленных. Pause до 21:00 → grace до завтра 21:00 МСК; pause после 21:00 → grace до послезавтра 21:00 МСК. Сообщение 422 — дословная формулировка заказчика: «Мы уже начали сбор лидов по этому проекту на завтра. Пока поставьте на паузу — мы увидим это сегодня в 18:00 и завтра не будем запускать сбор лидов по этому проекту. **Удалить** можно будет послезавтра.» Для смены источника глагол меняется на «**Изменить источник**». **БД:** schema v8.37→**v8.38** + новая колонка `projects.paused_at TIMESTAMPTZ NULL` + `CREATE INDEX projects_paused_at_idx`. Backfill в миграции: `UPDATE projects SET paused_at = updated_at WHERE is_active = false AND paused_at IS NULL` (best-effort approximation для legacy paused — `updated_at` близок к реальному моменту паузы). `paused_at` пишется явно в `ProjectController::toggleActive` + `ProjectService::bulkPauseResume` (mass-update НЕ триггерит Eloquent events, `DB::raw('NOW()')` напрямую в UPDATE). **UI:** `ProjectDetailsDrawer.vue` — `onDelete` обёрнут в try/catch (раньше 422 был молчанием — пользователь видел не-закрывающийся drawer без объяснения); template +баннер `errors.project` сверху формы (раньше отсутствовал — отображались только полевые ошибки `name`/`signal_identifier`/`sms_*`). `BulkActionsBar.vue::runBulk` — toast разделяет skipped по `reason`: `supplier_snapshot_locked` → «N — мы уже начали сбор лидов на завтра», `has_deals` → «M — по проекту есть сделки», fallback на старый текст для неизвестных причин. `ProjectService::bulkDelete` — catch'ит `HttpResponseException`, парсит message, разделяет `reason` (`supplier_snapshot_locked` vs `has_deals`). **Тесты:** 20 unit-тестов (`SupplierSnapshotGuardTest`: 12 — 4 grace-формула + 5 isProtected + 3 assertCanMutateSource через Mockery; `ProjectPausedAtTest`: 2 — fillable + cast; `ProjectServiceGuardWiringTest`: 3 — wire delete/update + бездействие при non-source-fields; `PausedAtWriteSideTest`: 3 — reflection-стиль проверка `paused_at` в bulkPauseResume/toggleActive/bulkDelete) + 4 Vitest UI-теста (Delete 422 → drawer не закрывается + текст; Save 422 errors.project; bulk supplier_snapshot_locked + mixed reasons). **Все зелёные.** Vitest полный suite на main checkout перед деплоем: **924/927** (3 pre-existing skipped). **Pre-deploy-validator (`#85`):** 8/8 GREEN — бэкап `liderra-daily-20260526-033001.dump` (~6ч), диск 40%, queue worker `--timeout=300` живой, fail2ban active, nginx ok, 0 pending миграций. **Деплой пошагово:** (1) `npm run build` локально на main checkout (Vite 3.25s) → `public/build/` свежий с новыми Vue-компонентами; (2) `tar czf /tmp/snapshot-guard-deploy.tgz` — 10 файлов (backend 4 + frontend 2 vue + миграция + schema/changelog + public/build/); (3) `scp` на сервер `/tmp/snapshot-guard-deploy.tgz` (548 KB); (4) `sudo tar xzf + sudo chown -R www-data:www-data` 3 новых файла; (5) **миграция `migrate --force` упала** на `Insufficient privilege: must be owner of table projects` (default `pgsql` connection = `crm_app_user`, таблица `projects` owned by `crm_migrator` — известный квирк партиционирования 23.05); **применил вручную через `psql -U crm_migrator`** одной транзакцией: `BEGIN; ALTER TABLE projects ADD COLUMN IF NOT EXISTS paused_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS projects_paused_at_idx; UPDATE projects SET paused_at = updated_at WHERE is_active = false AND paused_at IS NULL; INSERT INTO migrations VALUES ('2026_05_26_120000_add_paused_at_to_projects', batch=11); COMMIT;` — `migrate:status` показал `[11] Ran ✅`; (6) `view:clear` + `route:clear` + `config:clear` (НЕ `config:cache` под root — квирк 107 от 24.05); (7) `php artisan queue:restart` (soft signal через cache, нет `laravel-queue.service` systemd unit). **Smoke на проде:** `curl https://liderra.ru/` → 200; `tail -30 laravel.log` → 0 error/critical/exception в последних 30 строках; `migrate:status` → paused_at `[11] Ran`; **end-to-end guard на реальном активном синкнутом проекте** (id=1 «Окна СПб (сайт)», tenant=1, `is_active=true`, есть pivot `project_supplier_links`): `SupplierSnapshotGuard::isProtected` → YES; `assertCanMutateSource('delete')` → **HTTP 422 + полное сообщение с заказчиковой формулировкой**. Защита работает на проде. **SSH-нестабильность:** во время деплоя 5+ повторных таймаутов `Connection timed out during banner exchange` — workaround `-o ConnectTimeout=60 -o ServerAliveInterval=20` + ретраи между шагами. Quirk #109 расширен (но fail2ban на боевом whitelist'ит мой IP `185.116.239.110`, баны не накапливаются). **redeploy.sh НЕ использовал** — индивидуальный `scp` + `tar -x` + точечные `artisan` команды (proven pattern). **Spec/plan:** `docs/superpowers/plans/2026-05-26-supplier-snapshot-guard.md`. **Что НЕ затронуто:** UI route-guard (если клиент тыкает «удалить» через DevTools или curl напрямую — guard на сервере отдаёт 422 безусловно, frontend это второй слой), Pravila/CLAUDE.md/Tooling нормативка (фича прикладная, не off-phase плагин, не идёт в Tooling канон). **Worktree сохранён** `.claude/worktrees/supplier-snapshot-guard` (branch `worktree-supplier-snapshot-guard`, 10 коммитов; на origin есть, в main мерджена `5b7d958e`).