From be363695fdc6859d02f2b7ece19e0ddcc3a07ee2 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: Sun, 28 Jun 2026 11:09:07 +0300 Subject: [PATCH] =?UTF-8?q?fix(=D0=B1=D0=B8=D0=BB=D0=BB=D0=B8=D0=BD=D0=B3)?= =?UTF-8?q?:=20sweep=20=D0=B8=20reminder=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B1?= =?UTF-8?q?=D0=B8=D1=80=D0=B0=D1=8E=D1=82=20=D1=82=D0=B5=D0=BD=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20BYPASSRLS?= =?UTF-8?q?=20+=20playwright=20=D0=B2=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81?= =?UTF-8?q?=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8F=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корень: после переезда на Managed PG очередь ходит под ролью crm_app_user (RLS), и Tenant::query() в BalancePreflightSweepJob/BalanceFrozenReminderJob отдавал 0 строк без app.current_tenant_id — биллинг-преflight молча стал no-op с 26.06 (ни заморозок, ни снятия проектных блоков). Перечень тенантов теперь берётся через pgsql_supplier (BYPASSRLS), модель грузится внутри per-tenant SET LOCAL контекста. Логика проверена на боевых данных: t25/t26 снимутся, t27/t30 заморозятся. Playwright рантайма supplier-портала объявлен в dependencies ровно 1.59.0 под chromium-1217 + package-lock синхронизирован; деплой ставит его npm ci --omit=dev, durable к чистке node_modules. Тесты Billing 18/18, pint/phpstan чисто. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Jobs/Billing/BalanceFrozenReminderJob.php | 76 +++++++++++-------- .../Jobs/Billing/BalancePreflightSweepJob.php | 33 +++++--- app/package-lock.json | 47 +++++++++++- app/package.json | 3 +- docs/observer/STATUS.md | 8 +- 5 files changed, 122 insertions(+), 45 deletions(-) diff --git a/app/app/Jobs/Billing/BalanceFrozenReminderJob.php b/app/app/Jobs/Billing/BalanceFrozenReminderJob.php index 991a2253..e56442ad 100644 --- a/app/app/Jobs/Billing/BalanceFrozenReminderJob.php +++ b/app/app/Jobs/Billing/BalanceFrozenReminderJob.php @@ -51,49 +51,65 @@ final class BalanceFrozenReminderJob implements ShouldQueue // Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому». $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); - Tenant::query() + // Переезд на Managed PG (26.06.2026): очередь под ролью crm_app_user (RLS). + // Список замороженных тенантов брать через дефолтное соединение нельзя — без + // app.current_tenant_id policy tenants_self_isolation отдаёт 0 строк (тот же + // баг, что у BalancePreflightSweepJob). Берём id через pgsql_supplier (BYPASSRLS). + $tenantIds = DB::connection('pgsql_supplier')->table('tenants') ->whereNotNull('frozen_by_balance_at') ->whereNull('deleted_at') - ->chunkById(200, function (Collection $tenants) use ($service, $tiers): void { - foreach ($tenants as $tenant) { - /** @var Tenant $tenant */ - $this->processTenant($tenant, $service, $tiers); - } - }); + ->orderBy('id') + ->pluck('id'); + + foreach ($tenantIds as $tenantId) { + $this->processTenant((int) $tenantId, $service, $tiers); + } } /** * @param Collection $tiers */ - private function processTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void + private function processTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void { - // diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK). - $hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false)); + // SET LOCAL внутри транзакции восстанавливает tenant-контекст: и Tenant::find, + // и requiredLeadsForTomorrow() (читает projects) RLS-зависимы. mark()/alreadySent() + // идут через pgsql_supplier (BYPASSRLS) — им контекст не нужен. + DB::transaction(function () use ($tenantId, $service, $tiers): void { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); - $window = $this->matchWindow($hours); - if ($window === null) { - return; // вне окон reminder/final - } + $tenant = Tenant::find($tenantId); + if ($tenant === null || $tenant->frozen_by_balance_at === null) { + return; // разморожен/удалён между pluck и обработкой. + } - $marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent'; - if ($this->alreadySent($tenant->id, $marker)) { - return; - } + // diffInHours округляет — у заморозки на 25h это 25, на 73h это 73 (OK). + $hours = (int) abs(now()->diffInHours($tenant->frozen_by_balance_at, false)); - // Re-evaluate для актуального дефицита в тексте письма. - $result = $service->evaluate( - balanceRub: (string) $tenant->balance_rub, - deliveredInMonth: (int) $tenant->delivered_in_month, - requiredLeads: $tenant->requiredLeadsForTomorrow(), - tiers: $tiers, - ); + $window = $this->matchWindow($hours); + if ($window === null) { + return; // вне окон reminder/final + } - $mail = $window === 'reminder' - ? new BalanceFrozenReminderMail($tenant, $result) - : new BalanceFrozenFinalMail($tenant, $result); + $marker = $window === 'reminder' ? 'reminder_sent' : 'final_sent'; + if ($this->alreadySent($tenant->id, $marker)) { + return; + } - Mail::queue($mail); - $this->mark($tenant, $marker, $result); + // Re-evaluate для актуального дефицита в тексте письма. + $result = $service->evaluate( + balanceRub: (string) $tenant->balance_rub, + deliveredInMonth: (int) $tenant->delivered_in_month, + requiredLeads: $tenant->requiredLeadsForTomorrow(), + tiers: $tiers, + ); + + $mail = $window === 'reminder' + ? new BalanceFrozenReminderMail($tenant, $result) + : new BalanceFrozenFinalMail($tenant, $result); + + Mail::queue($mail); + $this->mark($tenant, $marker, $result); + }); } private function matchWindow(int $hours): ?string diff --git a/app/app/Jobs/Billing/BalancePreflightSweepJob.php b/app/app/Jobs/Billing/BalancePreflightSweepJob.php index 61f213f1..8824b8fd 100644 --- a/app/app/Jobs/Billing/BalancePreflightSweepJob.php +++ b/app/app/Jobs/Billing/BalancePreflightSweepJob.php @@ -41,25 +41,40 @@ final class BalancePreflightSweepJob implements ShouldQueue // Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому». $tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow')); - Tenant::query()->whereNull('deleted_at')->chunkById(200, function (Collection $tenants) use ($service, $tiers): void { - foreach ($tenants as $tenant) { - /** @var Tenant $tenant */ - $this->evaluateTenant($tenant, $service, $tiers); - } - }); + // Переезд на Managed PG (26.06.2026): очередь ходит в БД под ролью crm_app_user + // (RLS). Перечень тенантов брать через ДЕФОЛТНОЕ соединение нельзя — без + // app.current_tenant_id RLS-policy tenants_self_isolation отдаёт 0 строк, и + // sweep молча превращался в no-op (ни заморозок, ни снятия блоков). Берём id + // через pgsql_supplier (BYPASSRLS — системный контекст), как джоба уже делает + // для balance_freeze_log. Дальше per-tenant SET LOCAL восстанавливает контекст. + $tenantIds = DB::connection('pgsql_supplier')->table('tenants') + ->whereNull('deleted_at') + ->orderBy('id') + ->pluck('id'); + + foreach ($tenantIds as $tenantId) { + $this->evaluateTenant((int) $tenantId, $service, $tiers); + } } /** * @param Collection $tiers */ - private function evaluateTenant(Tenant $tenant, BalancePreflightService $service, Collection $tiers): void + private function evaluateTenant(int $tenantId, BalancePreflightService $service, Collection $tiers): void { // Spec C deploy hotfix (25.05.2026): CLI-команды и фоновые джобы не проходят // через SetTenantContext middleware → app.current_tenant_id не выставлен → // RLS-policy на projects падает с "unrecognized configuration parameter". // Зеркалим mechanic SetTenantContext: SET LOCAL внутри транзакции (PgBouncer-safe). - DB::transaction(function () use ($tenant, $service, $tiers): void { - DB::statement('SET LOCAL app.current_tenant_id = '.(int) $tenant->id); + DB::transaction(function () use ($tenantId, $service, $tiers): void { + DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId); + + // Модель грузим ВНУТРИ контекста — под RLS-ролью без SET LOCAL Tenant::find + // вернёт null (id-isolation policy). После SET LOCAL запись своей компании видна. + $tenant = Tenant::find($tenantId); + if ($tenant === null) { + return; // удалён между pluck и обработкой — пропускаем. + } $required = $tenant->requiredLeadsForTomorrow(); $result = $service->evaluate( diff --git a/app/package-lock.json b/app/package-lock.json index 14478d2d..ac1b9f01 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -5,7 +5,8 @@ "packages": { "": { "dependencies": { - "lucide-vue-next": "^1.0.0" + "lucide-vue-next": "^1.0.0", + "playwright": "1.59.0" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -7787,6 +7788,50 @@ "@vue/devtools-kit": "^7.7.9" } }, + "node_modules/playwright": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.0.tgz", + "integrity": "sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0.tgz", + "integrity": "sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", diff --git a/app/package.json b/app/package.json index 6c011aa5..892d87d2 100644 --- a/app/package.json +++ b/app/package.json @@ -50,6 +50,7 @@ "vuetify": "^3.12.5" }, "dependencies": { - "lucide-vue-next": "^1.0.0" + "lucide-vue-next": "^1.0.0", + "playwright": "1.59.0" } } diff --git a/docs/observer/STATUS.md b/docs/observer/STATUS.md index af80f8b3..09a75ac7 100644 --- a/docs/observer/STATUS.md +++ b/docs/observer/STATUS.md @@ -1,6 +1,6 @@ # Brain Status (auto-generated) -Last updated: 2026-06-28T07:15:07.331Z +Last updated: 2026-06-28T07:23:41.603Z | Контролёр | Состояние | Детали | |---|---|---| @@ -125,9 +125,9 @@ Episodes since last run: 542 / threshold: 10 | PID | Имя | CPU-время | Возраст | |---|---|---|---| -| 3440 | MsMpEng | 17.47ч | 0.0ч | -| 21928 | Code | 7.73ч | 0.0ч | -| 1212 | svchost | 4.46ч | NaNч | +| 3440 | MsMpEng | 17.50ч | 0.0ч | +| 21928 | Code | 7.75ч | NaNч | +| 1212 | svchost | 4.46ч | 0.0ч | ⚠️ Проверь, не «осиротевшие» ли это процессы от завершённых Claude-сессий.