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); +});