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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user