75dded78a1
Корень: после переезда на 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>
161 lines
8.1 KiB
PHP
161 lines
8.1 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Jobs\Billing;
|
||
|
||
use App\Jobs\SyncSupplierProjectJob;
|
||
use App\Mail\BalanceFrozenMail;
|
||
use App\Models\PricingTier;
|
||
use App\Models\Tenant;
|
||
use App\Repositories\PricingTierRepository;
|
||
use App\Services\Billing\BalancePreflightService;
|
||
use App\Services\Billing\PreflightResult;
|
||
use App\Services\Billing\ProjectBlockReleaseService;
|
||
use App\Services\Supplier\SupplierExportMode;
|
||
use Illuminate\Bus\Queueable;
|
||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||
use Illuminate\Database\Eloquent\Collection;
|
||
use Illuminate\Foundation\Bus\Dispatchable;
|
||
use Illuminate\Queue\InteractsWithQueue;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Mail;
|
||
|
||
/**
|
||
* Ежедневный преfflight всех тенантов перед формированием заказа поставщику.
|
||
* Запускается cron @18:00 MSK (routes/console.php). См. спек §3.5, §5.2.
|
||
*
|
||
* NB: бегает без tenant-RLS (системный контекст); запросы к projects/tenants
|
||
* явные по tenant_id (урок Спека B). Переход active→frozen / frozen→active
|
||
* шлёт письмо; стабильное состояние не трогается (идемпотентность).
|
||
*/
|
||
final class BalancePreflightSweepJob implements ShouldQueue
|
||
{
|
||
use Dispatchable;
|
||
use InteractsWithQueue;
|
||
use Queueable;
|
||
|
||
public function handle(): void
|
||
{
|
||
$service = new BalancePreflightService;
|
||
// Косяк 01: действующая версия тарифа по дате (как списание/витрина), а не «по-простому».
|
||
$tiers = app(PricingTierRepository::class)->activeAt(now('Europe/Moscow'));
|
||
|
||
// Переезд на 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(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 ($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(
|
||
balanceRub: (string) $tenant->balance_rub,
|
||
deliveredInMonth: (int) $tenant->delivered_in_month,
|
||
requiredLeads: $required,
|
||
tiers: $tiers,
|
||
);
|
||
|
||
$isFrozen = $tenant->frozen_by_balance_at !== null;
|
||
|
||
if (! $result->passes) {
|
||
// Переход active → frozen (разморозку/снятие блоков здесь НЕ делаем —
|
||
// заморозка главнее, см. иерархию J спеки balance-lock-unify-FJ).
|
||
if (! $isFrozen) {
|
||
$freezeAt = now();
|
||
$tenant->frozen_by_balance_at = $freezeAt;
|
||
$tenant->save();
|
||
|
||
// Stage 3 R-13 (spec §4.3.2): помечаем все непаузнутые проекты
|
||
// тенанта моментом заморозки. Это даёт SupplierSnapshotGuard
|
||
// зацепку (paused_at свежее grace-периода) — клиент не сможет
|
||
// удалить/сменить источник пока хвост слепка ещё может прилететь.
|
||
DB::connection('pgsql_supplier')->table('projects')
|
||
->where('tenant_id', $tenant->id)
|
||
->whereNull('paused_at')
|
||
->update(['paused_at' => $freezeAt]);
|
||
|
||
$this->logEvent($tenant, 'frozen', 'cutoff_18msk', $result);
|
||
Mail::queue(new BalanceFrozenMail($tenant, $result));
|
||
$this->dispatchSupplierSyncIfOnline($tenant);
|
||
}
|
||
|
||
return; // заморожен и не хватает — стабильное состояние, блоки не трогаем.
|
||
}
|
||
|
||
// passes → единый путь разблокировки (D6): разморозить клиента (если был, J)
|
||
// + снять блоки всех проектов (F). Идемпотентно: нет замков → no-op.
|
||
(new ProjectBlockReleaseService)->releaseForTenant($tenant->id);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Spec C extension (26.05.2026): при переходе freeze ↔ unfreeze в режиме 'online'
|
||
* диспатчим точечный sync с поставщиком per-project (group-recalc внутри handleOnline
|
||
* сам учтёт шеринг через signal_identifier). В режиме 'batch' изменения уезжают
|
||
* cut-off cron'ом @18:00 MSK через SyncSupplierProjectsJob (множественный).
|
||
* Привязка к админ-переключателю SupplierExportMode (system_settings.supplier_export_mode).
|
||
*
|
||
* Вызывается ВНУТРИ DB::transaction обёртки evaluateTenant — app.current_tenant_id выставлен,
|
||
* RLS-фильтрация projects работает.
|
||
*/
|
||
private function dispatchSupplierSyncIfOnline(Tenant $tenant): void
|
||
{
|
||
if (! SupplierExportMode::isOnline()) {
|
||
return;
|
||
}
|
||
|
||
$projectIds = $tenant->projects()
|
||
->where('is_active', true)
|
||
->whereNull('preflight_blocked_at')
|
||
->pluck('id');
|
||
|
||
foreach ($projectIds as $id) {
|
||
SyncSupplierProjectJob::dispatch((int) $id);
|
||
}
|
||
}
|
||
|
||
private function logEvent(Tenant $tenant, string $event, string $trigger, PreflightResult $result): void
|
||
{
|
||
DB::connection('pgsql_supplier')->table('balance_freeze_log')->insert([
|
||
'tenant_id' => $tenant->id,
|
||
'event_type' => $event,
|
||
'triggered_by' => $trigger,
|
||
'balance_rub_at_event' => $tenant->balance_rub,
|
||
'required_leads' => $result->requiredLeads,
|
||
'capacity_leads' => $result->capacityLeads,
|
||
'total_daily_limit' => $result->requiredLeads,
|
||
'details' => json_encode(['deficit_leads' => $result->deficitLeads]),
|
||
'created_at' => now(),
|
||
]);
|
||
}
|
||
}
|