diff --git a/app/app/Console/Commands/IncidentsWatchFailures.php b/app/app/Console/Commands/IncidentsWatchFailures.php index c5b39dbc..a8552cba 100644 --- a/app/app/Console/Commands/IncidentsWatchFailures.php +++ b/app/app/Console/Commands/IncidentsWatchFailures.php @@ -178,8 +178,14 @@ class IncidentsWatchFailures extends Command $excSig = (string) $row->exc_sig; $dedupKey = "persistent:{$jobClass}:{$excSig}"; - if ($this->isDup($dedupKey, $dedupAt)) { - $this->line("Skipping persistent (dedup): {$dedupKey}"); + // Дедуп persistent — БЕЗ окна по времени (прод-инцидент 14.07.2026). + // Строка в failed_jobs живёт вечно, поэтому правило срабатывает на каждом + // прогоне (раз в 10 мин), а окно дедупа в 60 мин истекает → раньше сторож + // плодил копию того же инцидента КАЖДЫЙ ЧАС (2 залипшие строки → 31 запись + // за сутки, лампа «Очереди/джобы» красная с растущей цифрой). Пока прошлый + // инцидент не разобран — новый не нужен; после resolved_at сработает снова. + if ($this->hasOpenIncident($dedupKey)) { + $this->line("Skipping persistent (open incident exists): {$dedupKey}"); continue; } @@ -257,6 +263,20 @@ class IncidentsWatchFailures extends Command return self::SUCCESS; } + /** + * Есть ли НЕразобранный инцидент с этой сигнатурой — без ограничения по времени. + * Для persistent-правила: источник (строка в failed_jobs) не исчезает сам, поэтому + * ориентир — состояние инцидента, а не возраст его последней копии. + */ + private function hasOpenIncident(string $dedupKey): bool + { + return DB::connection(self::DB_CONNECTION) + ->table('incidents_log') + ->where('root_cause', $dedupKey) + ->whereNull('resolved_at') + ->exists(); + } + private function isDup(string $dedupKey, Carbon $dedupAt): bool { // Сигнатура сохраняется в root_cause для надёжного дедупа diff --git a/app/app/Mail/BalanceFrozenFinalMail.php b/app/app/Mail/BalanceFrozenFinalMail.php index 502eacf5..a6c2fc2d 100644 --- a/app/app/Mail/BalanceFrozenFinalMail.php +++ b/app/app/Mail/BalanceFrozenFinalMail.php @@ -10,16 +10,17 @@ use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Queue\SerializesModels; /** * Финальное письмо: приём лидов приостановлен 3 дня (Billing v2 Spec C §3.7, T+72h). * После него повторов нет до следующего цикла заморозки. + * + * 🔴 БЕЗ SerializesModels — НЕ возвращать: воркер не сможет достать тенанта из-под RLS. + * Причина и регрессия — в BalanceFrozenMail / BalanceMailsQueueRestoreTest. */ final class BalanceFrozenFinalMail extends Mailable { use Queueable; - use SerializesModels; public function __construct( public readonly Tenant $tenant, diff --git a/app/app/Mail/BalanceFrozenMail.php b/app/app/Mail/BalanceFrozenMail.php index 68eb2b3a..3e3d08b6 100644 --- a/app/app/Mail/BalanceFrozenMail.php +++ b/app/app/Mail/BalanceFrozenMail.php @@ -10,17 +10,21 @@ use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Queue\SerializesModels; /** * Письмо клиенту: приём лидов приостановлен из-за нехватки баланса (Billing v2 Spec C §3.7). * * Триггер: BalancePreflightSweepJob при переходе тенанта active → frozen (cut-off 18:00 MSK). + * + * 🔴 БЕЗ SerializesModels — НЕ возвращать (прод-инцидент 14.07.2026). С этим трейтом + * в очередь уезжает только id тенанта, а воркер грузит модель заново — под ролью + * crm_app_user, где RLS-policy tenants_self_isolation без app.current_tenant_id отдаёт + * 0 строк → ModelNotFoundException, письмо не уходит НИКОГДА. Без трейта письмо несёт + * снимок данных и отправляется без похода в БД. Регрессия: BalanceMailsQueueRestoreTest. */ final class BalanceFrozenMail extends Mailable { use Queueable; - use SerializesModels; public function __construct( public readonly Tenant $tenant, diff --git a/app/app/Mail/BalanceFrozenReminderMail.php b/app/app/Mail/BalanceFrozenReminderMail.php index 0354ba7c..ea3564bc 100644 --- a/app/app/Mail/BalanceFrozenReminderMail.php +++ b/app/app/Mail/BalanceFrozenReminderMail.php @@ -10,15 +10,16 @@ use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Queue\SerializesModels; /** * Письмо-напоминание: приём лидов всё ещё приостановлен (Billing v2 Spec C §3.7, T+24h). + * + * 🔴 БЕЗ SerializesModels — НЕ возвращать: воркер не сможет достать тенанта из-под RLS. + * Причина и регрессия — в BalanceFrozenMail / BalanceMailsQueueRestoreTest. */ final class BalanceFrozenReminderMail extends Mailable { use Queueable; - use SerializesModels; public function __construct( public readonly Tenant $tenant, diff --git a/app/app/Mail/BalanceUnfrozenMail.php b/app/app/Mail/BalanceUnfrozenMail.php index c3080b61..88d4af55 100644 --- a/app/app/Mail/BalanceUnfrozenMail.php +++ b/app/app/Mail/BalanceUnfrozenMail.php @@ -10,17 +10,18 @@ use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Queue\SerializesModels; /** * Письмо клиенту: приём лидов возобновлён (Billing v2 Spec C §3.7). * * Триггер: BalancePreflightSweepJob при переходе frozen → active (пополнение/снижение лимита). + * + * 🔴 БЕЗ SerializesModels — НЕ возвращать: воркер не сможет достать тенанта из-под RLS. + * Причина и регрессия — в BalanceFrozenMail / BalanceMailsQueueRestoreTest. */ final class BalanceUnfrozenMail extends Mailable { use Queueable; - use SerializesModels; public function __construct( public readonly Tenant $tenant, diff --git a/app/app/Mail/ZeroBalancePausedMail.php b/app/app/Mail/ZeroBalancePausedMail.php index 36d43c43..21332448 100644 --- a/app/app/Mail/ZeroBalancePausedMail.php +++ b/app/app/Mail/ZeroBalancePausedMail.php @@ -10,7 +10,6 @@ use Illuminate\Bus\Queueable; use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Queue\SerializesModels; /** * Email клиенту Лидерры о приостановке проекта из-за недостаточного баланса. @@ -20,11 +19,13 @@ use Illuminate\Queue\SerializesModels; * письмо с rate-limit 1/час/tenant (Redis SETNX). * * Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §4.4 + * + * 🔴 БЕЗ SerializesModels — НЕ возвращать: воркер не сможет достать тенанта и проект + * из-под RLS. Причина и регрессия — в BalanceFrozenMail / BalanceMailsQueueRestoreTest. */ final class ZeroBalancePausedMail extends Mailable { use Queueable; - use SerializesModels; public function __construct( public readonly Tenant $tenant, diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 10cdde8a..3677f937 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -2043,7 +2043,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' identifier: method.notFound - count: 8 + count: 12 path: tests/Feature/Incidents/IncidentsWatchFailuresExpandedTest.php - diff --git a/app/tests/Feature/Billing/BalanceMailsQueueRestoreTest.php b/app/tests/Feature/Billing/BalanceMailsQueueRestoreTest.php new file mode 100644 index 00000000..1b686be3 --- /dev/null +++ b/app/tests/Feature/Billing/BalanceMailsQueueRestoreTest.php @@ -0,0 +1,84 @@ +create([ + 'organization_name' => 'ООО Заморозка', + 'contact_email' => 'frozen@example.test', + ]); + $result = new PreflightResult(false, 30, 20, 10); + + // Что уходит в очередь (Redis) — PHP-сериализация письма. + $payload = serialize(new $mailClass($tenant, $result)); + + // Воркер строку не видит (на проде — RLS; здесь — записи уже нет). + DB::table('tenants')->where('id', $tenant->id)->delete(); + + $rendered = unserialize($payload)->render(); + + expect($rendered)->toContain('ООО Заморозка'); + expect($rendered)->toContain($expectedText); +})->with([ + 'заморозка' => [BalanceFrozenMail::class, 'приостановлен'], + 'напоминание' => [BalanceFrozenReminderMail::class, 'всё ещё приостановлен'], + 'последнее напоминание' => [BalanceFrozenFinalMail::class, 'последнее напоминание'], + 'разморозка' => [BalanceUnfrozenMail::class, 'возобновлён'], +]); + +it('addresses queued balance mail to the tenant after the row is invisible', function () { + $tenant = Tenant::factory()->create(['contact_email' => 'frozen@example.test']); + $payload = serialize(new BalanceFrozenMail($tenant, new PreflightResult(false, 30, 20, 10))); + + DB::table('tenants')->where('id', $tenant->id)->delete(); + + $envelope = unserialize($payload)->envelope(); + + expect($envelope->to[0]->address)->toBe('frozen@example.test'); +}); + +it('renders queued zero-balance mail after tenant and project rows are invisible', function () { + $tenant = Tenant::factory()->create([ + 'organization_name' => 'ООО Ноль', + 'contact_email' => 'zero@example.test', + 'balance_rub' => '0.00', + ]); + $project = Project::factory()->for($tenant)->create(['name' => 'Стоматология Москва']); + + $payload = serialize(new ZeroBalancePausedMail($tenant, $project, 5000)); + + DB::table('projects')->where('id', $project->id)->delete(); + DB::table('tenants')->where('id', $tenant->id)->delete(); + + $rendered = unserialize($payload)->render(); + + expect($rendered)->toContain('Стоматология Москва'); + expect($rendered)->toContain('приостановлен'); +}); diff --git a/app/tests/Feature/Incidents/IncidentsWatchFailuresExpandedTest.php b/app/tests/Feature/Incidents/IncidentsWatchFailuresExpandedTest.php index c8dd1359..5cc5d5cd 100644 --- a/app/tests/Feature/Incidents/IncidentsWatchFailuresExpandedTest.php +++ b/app/tests/Feature/Incidents/IncidentsWatchFailuresExpandedTest.php @@ -145,6 +145,57 @@ test('failed_jobs persistent exception creates incident severity=medium', functi Mail::assertNotSent(IncidentDetectedMail::class); }); +/** + * Регрессия прод-инцидента 14.07.2026: 2 залипшие строки в failed_jobs дали 31 + * незакрытый авто-инцидент за сутки (46 всего) — «Очереди/джобы» горело красным + * с растущей цифрой. + * + * Причина: persistent-правило смотрит на строки старше N часов (такая строка живёт + * в failed_jobs вечно), а дедуп ограничен окном в 60 мин. Сторож бегает раз в 10 мин + * → каждый час новая копия того же инцидента, бесконечно. Дедуп persistent-правила + * должен опираться на факт «незакрытый инцидент с этой сигнатурой уже есть», а не + * на возраст последней копии. + */ +test('persistent rule does not clone the incident hour after hour while it stays unresolved', function () { + makeFailedJob( + 'App\\Mail\\BalanceFrozenMail', + 'Illuminate\\Database\\Eloquent\\ModelNotFoundException: No query results for model [App\\Models\\Tenant].', + Carbon::now()->subHours(4) + ); + + $persistent = fn () => DB::table('incidents_log')->where('summary', 'like', '%persistent%')->count(); + + $this->artisan('incidents:watch-failures')->assertSuccessful(); + expect($persistent())->toBe(1); + + // Прошли сутки: ошибка та же, инцидент никто не разобрал, окно дедупа (60 мин) давно истекло. + Carbon::setTestNow(Carbon::now()->addDay()); + $this->artisan('incidents:watch-failures')->assertSuccessful(); + + expect($persistent())->toBe(1); + + Carbon::setTestNow(); +}); + +test('persistent rule fires again after the previous incident was resolved', function () { + makeFailedJob( + 'App\\Mail\\BalanceFrozenMail', + 'Illuminate\\Database\\Eloquent\\ModelNotFoundException: No query results for model [App\\Models\\Tenant].', + Carbon::now()->subHours(4) + ); + + $this->artisan('incidents:watch-failures')->assertSuccessful(); + + // Админ разобрал инцидент, но ошибка повторилась снова — новый инцидент нужен. + DB::table('incidents_log')->update(['resolved_at' => Carbon::now()]); + Carbon::setTestNow(Carbon::now()->addDay()); + $this->artisan('incidents:watch-failures')->assertSuccessful(); + + expect(DB::table('incidents_log')->where('summary', 'like', '%persistent%')->count())->toBe(2); + + Carbon::setTestNow(); +}); + test('dedup prevents duplicate incidents for same failed_jobs spike', function () { $now = Carbon::now(); for ($i = 0; $i < 11; $i++) {