From dadfdcaa7e2f9b3cc36f82ca56269c90fa0cc438 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: Mon, 11 May 2026 10:28:13 +0300 Subject: [PATCH] =?UTF-8?q?feat(commands):=20Plan=204=20Task=205=20?= =?UTF-8?q?=E2=80=94=20ResetMonthlyCountersCommand=20+=20Schedule=20monthl?= =?UTF-8?q?yOn(1,=2000:00)=20=D0=9C=D0=A1=D0=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Месячный cron-сброс tenants.delivered_in_month + projects.delivered_in_month 1-го числа каждого месяца в 00:00 МСК. Идёт через pgsql_supplier BYPASSRLS connection (паттерн ResetDeliveredTodayCommand). Идемпотентный (WHERE delivered_in_month <> 0 → повторный запуск 0 affected rows). 4 теста: reset multi-tenant + idempotency + Schedule registration + BYPASSRLS without SET LOCAL. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Commands/ResetMonthlyCountersCommand.php | 42 +++++++++++ app/phpstan-baseline.neon | 12 +++ app/routes/console.php | 5 ++ .../ResetMonthlyCountersCommandTest.php | 75 +++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 app/app/Console/Commands/ResetMonthlyCountersCommand.php create mode 100644 app/tests/Feature/Console/ResetMonthlyCountersCommandTest.php diff --git a/app/app/Console/Commands/ResetMonthlyCountersCommand.php b/app/app/Console/Commands/ResetMonthlyCountersCommand.php new file mode 100644 index 00000000..ce84c3bf --- /dev/null +++ b/app/app/Console/Commands/ResetMonthlyCountersCommand.php @@ -0,0 +1,42 @@ + 0). Если один упадёт, следующий cron-запуск + // довершит сброс. Атомарность не требуется. Дополнительно — оборачивание + // ломает тесты с shared PDO (SharesSupplierPdo trait): PostgreSQL не + // допускает nested transactions без savepoints, см. precedent + // ResetDeliveredTodayCommand (тоже без обёртки). + $tenants = DB::connection('pgsql_supplier') + ->update('UPDATE tenants SET delivered_in_month = 0 WHERE delivered_in_month <> 0'); + $projects = DB::connection('pgsql_supplier') + ->update('UPDATE projects SET delivered_in_month = 0 WHERE delivered_in_month <> 0'); + + $this->info("Monthly reset: {$tenants} tenants, {$projects} projects."); + + return self::SUCCESS; + } +} diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index bbf0d6aa..1077fac6 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -414,6 +414,18 @@ parameters: count: 2 path: tests/Feature/Console/ResetDeliveredTodayCommandTest.php + - + message: '#^Access to an undefined property App\\Models\\Tenant\:\:\$delivered_in_month\.$#' + identifier: property.notFound + count: 3 + path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Console/ResetMonthlyCountersCommandTest.php + - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' identifier: property.notFound diff --git a/app/routes/console.php b/app/routes/console.php index e7d6874b..6bfd070b 100644 --- a/app/routes/console.php +++ b/app/routes/console.php @@ -22,6 +22,11 @@ Schedule::command('projects:reset-delivered-today') ->dailyAt('00:00') ->timezone('Europe/Moscow'); +// Plan 4: monthly reset 1-го числа в 00:00 МСК для tier-lookup в LedgerService. +Schedule::command('projects:reset-monthly') + ->monthlyOn(1, '00:00') + ->timezone('Europe/Moscow'); + // Plan 3 Task 8: 5 Schedule entries для supplier-flow. // // NB: ->onOneServer() требует cache_locks таблицу, которой у нас нет diff --git a/app/tests/Feature/Console/ResetMonthlyCountersCommandTest.php b/app/tests/Feature/Console/ResetMonthlyCountersCommandTest.php new file mode 100644 index 00000000..3d472376 --- /dev/null +++ b/app/tests/Feature/Console/ResetMonthlyCountersCommandTest.php @@ -0,0 +1,75 @@ +create(['delivered_in_month' => 100]); + $tenantB = Tenant::factory()->create(['delivered_in_month' => 5]); + $tenantC = Tenant::factory()->create(['delivered_in_month' => 0]); + + Project::factory()->create(['tenant_id' => $tenantA->id, 'delivered_in_month' => 50]); + Project::factory()->create(['tenant_id' => $tenantA->id, 'delivered_in_month' => 50]); + Project::factory()->create(['tenant_id' => $tenantB->id, 'delivered_in_month' => 5]); + + Artisan::call('projects:reset-monthly'); + + expect($tenantA->fresh()->delivered_in_month)->toBe(0); + expect($tenantB->fresh()->delivered_in_month)->toBe(0); + expect($tenantC->fresh()->delivered_in_month)->toBe(0); + + expect(Project::sum('delivered_in_month'))->toBe(0); +}); + +it('is idempotent — second run reports 0 affected', function () { + $tenant = Tenant::factory()->create(['delivered_in_month' => 10]); + Project::factory()->create(['tenant_id' => $tenant->id, 'delivered_in_month' => 10]); + + // Первый прогон сбрасывает оба счётчика. + Artisan::call('projects:reset-monthly'); + + // Второй прогон должен сообщить о 0 затронутых строках (WHERE delivered_in_month <> 0). + // info() пишет ОДНУ строку «Monthly reset: 0 tenants, 0 projects.» — + // expectsOutputToContain в Laravel 13 line-by-line, поэтому проверяем + // обе подстроки одним substring'ом. + $this->artisan('projects:reset-monthly') + ->expectsOutputToContain('0 tenants, 0 projects') + ->assertExitCode(0); +}); + +it('Schedule entry registered for monthly on 1st 00:00 Europe/Moscow', function () { + /** @var Schedule $schedule */ + $schedule = app(Schedule::class); + + $found = collect($schedule->events())->first( + fn ($event) => str_contains($event->command ?? '', 'projects:reset-monthly') + ); + + expect($found)->not->toBeNull(); + expect($found->expression)->toBe('0 0 1 * *'); + expect($found->timezone)->toBe('Europe/Moscow'); +}); + +it('uses pgsql_supplier BYPASSRLS connection (touches all tenants without SET LOCAL)', function () { + Tenant::factory()->count(3)->create(['delivered_in_month' => 7]); + + // Без SET LOCAL app.current_tenant_id reset должен затронуть всех 3 tenant'ов. + // Это тест на использование pgsql_supplier (BYPASSRLS), не default pgsql. + Artisan::call('projects:reset-monthly'); + + expect(Tenant::where('delivered_in_month', '>', 0)->count())->toBe(0); +});