Files
portal/app/app/Console/Commands/PartitionsCreateMonths.php
T
Дмитрий 1d4738dfa2 phase1(infra): partitions:create-months — Artisan-команда (замена pg_partman)
Закрыт пункт «pg_partman replacement» из project_phase1_strategy.md
(расширение pg_partman недоступно на native Windows-стеке без сборки
из исходников).

Реализация:
  - app/app/Console/Commands/PartitionsCreateMonths.php
    - signature: partitions:create-months {--ahead=2}
    - создаёт партиции для deals + supplier_lead_costs (обе по received_at)
    - идемпотентна (проверка через pg_class WHERE relkind='r' перед CREATE)
    - запускать ежесуточно через Windows Task Scheduler / cron

Smoke-test на dev: --ahead=8 создал 6 партиций (Nov 2026 - Jan 2027) +
12 skipped. После migrate:fresh партиции возвращаются к initial 6.

4 новых Pest-теста в PartitionsCreateMonthsTest:
  - создание партиций на 8 месяцев вперёд для обеих таблиц
  - идемпотентность (повторный --ahead=5 → 0 created, 12 skipped)
  - --ahead=0 создаёт только текущий месяц
  - INSERT в deals с received_at в новой партиции корректно роутится

Тесты используют beforeEach/afterEach для cleanup'а через
DROP TABLE ... CASCADE (FK webhook_dedup_keys на партицию propagates).

Pest 45/45 зелёные за 4.9 сек. Pint + Larastan чисто (phpstan-baseline
регенерирован для динамических свойств $this в Pest closure'ах).

CLAUDE.md v1.14 → v1.15. Реестр Открытые_вопросы v1.23 → v1.24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 15:46:41 +03:00

93 lines
3.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Создаёт ежемесячные партиции для `deals` и `supplier_lead_costs`
* на N месяцев вперёд от текущей даты.
*
* Замена `pg_partman` на native Windows-стеке (расширение недоступно
* без сборки из исходников). Запускается ежесуточно через Windows Task
* Scheduler / cron — идемпотентна (CREATE TABLE IF NOT EXISTS).
*
* По дефолту 2 месяца вперёд (паритет с инициализацией schema.sql:
* 6 партиций при `migrate:fresh`, последующие месяцы — этим cron'ом).
*
* Источник: db/schema.sql §5 (deals partition), §8.5 (supplier_lead_costs);
* project_phase1_strategy.md (pg_partman заменён ручным cron'ом).
*/
class PartitionsCreateMonths extends Command
{
/** @var string */
protected $signature = 'partitions:create-months {--ahead=2 : Сколько месяцев вперёд создать партиций}';
/** @var string */
protected $description = 'Создаёт ежемесячные партиции deals и supplier_lead_costs на N месяцев вперёд (idempotent)';
/**
* Список таблиц, которые партиционируются по received_at помесячно.
*
* @var array<int, string>
*/
private const PARTITIONED_TABLES = ['deals', 'supplier_lead_costs'];
public function handle(): int
{
$ahead = max(1, (int) $this->option('ahead'));
$now = Carbon::now()->startOfMonth();
$created = 0;
$skipped = 0;
for ($i = 0; $i <= $ahead; $i++) {
$monthStart = $now->copy()->addMonths($i);
$monthEnd = $monthStart->copy()->addMonth();
foreach (self::PARTITIONED_TABLES as $table) {
$partitionName = sprintf('%s_%s', $table, $monthStart->format('Y_m'));
if ($this->partitionExists($partitionName)) {
$skipped++;
$this->line(" skip <fg=gray>{$partitionName}</> (already exists)");
continue;
}
DB::statement(sprintf(
"CREATE TABLE %s PARTITION OF %s FOR VALUES FROM ('%s') TO ('%s')",
$partitionName,
$table,
$monthStart->format('Y-m-d'),
$monthEnd->format('Y-m-d'),
));
$created++;
$this->info(" create <fg=green>{$partitionName}</> [{$monthStart->format('Y-m-d')}{$monthEnd->format('Y-m-d')})");
}
}
$this->newLine();
$this->info("Done: {$created} created, {$skipped} skipped (ahead={$ahead}).");
return self::SUCCESS;
}
/**
* Проверка существования партиции через pg_class (быстрее information_schema).
*/
private function partitionExists(string $name): bool
{
$row = DB::selectOne(
"SELECT 1 AS exists FROM pg_class WHERE relname = ? AND relkind = 'r'",
[$name],
);
return $row !== null;
}
}