feat(commands): Plan 4 Task 5 — ResetMonthlyCountersCommand + Schedule monthlyOn(1, 00:00) МСК

Месячный 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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-11 10:28:13 +03:00
parent e401491947
commit dadfdcaa7e
4 changed files with 134 additions and 0 deletions
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* Сброс tenants.delivered_in_month + projects.delivered_in_month = 0.
*
* Spec: docs/superpowers/specs/2026-05-11-plan4-billing-csv-admin-design.md §4.1
* Расписание: 1-го числа каждого месяца в 00:00 Europe/Moscow.
*
* Идёт через connection `pgsql_supplier` (BYPASSRLS-роль crm_supplier_worker)
* паттерн ResetDeliveredTodayCommand. Один statement на таблицу, без SET LOCAL.
*/
class ResetMonthlyCountersCommand extends Command
{
protected $signature = 'projects:reset-monthly';
protected $description = 'Сброс tenants.delivered_in_month + projects.delivered_in_month = 0 (1-го числа в 00:00 МСК, Plan 4)';
public function handle(): int
{
// Без оборачивающего DB::transaction: два UPDATE'а независимо идемпотентны
// (WHERE delivered_in_month <> 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;
}
}
+12
View File
@@ -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
+5
View File
@@ -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 таблицу, которой у нас нет
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Tests\Concerns\SharesSupplierPdo;
uses(DatabaseTransactions::class);
uses(SharesSupplierPdo::class);
beforeEach(function () {
DB::statement("SELECT set_config('app.current_tenant_id', '0', true)");
});
it('resets tenants.delivered_in_month and projects.delivered_in_month to 0', function () {
$tenantA = Tenant::factory()->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);
});