fix(биллинг): sweep и reminder перебирают тенантов через BYPASSRLS + playwright в зависимостях

Корень: после переезда на 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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-06-28 11:09:07 +03:00
parent cb88d4a382
commit be363695fd
5 changed files with 122 additions and 45 deletions
@@ -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<int, PricingTier> $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
@@ -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<int, PricingTier> $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(
+46 -1
View File
@@ -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",
+2 -1
View File
@@ -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"
}
}
+4 -4
View File
@@ -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-сессий.