Files
portal/app/app/Console/Commands/SnapshotRebuildCommand.php
T
Дмитрий 6d6fa10d91 feat(slepok): Task 2.10 — snapshot:rebuild artisan command for fail-loud recovery
Manual recovery после падения SnapshotProjectRoutingJob cron'а. В отличие от
snapshot:backfill (ON CONFLICT DO NOTHING), snapshot:rebuild сначала DELETE'ит
существующий snapshot за дату, затем INSERT'ит свежий из live state.

Fail-loud strategy (Spec §4.2.6):
  1. Heartbeat alarm via SchedulerHeartbeatTracker (Task 2.4 — already wired).
  2. LeadRouter Log::error on missing snapshot (Task 2.5 — already wired).
  3. Manual recovery: php artisan snapshot:rebuild --date=YYYY-MM-DD.

NO fallback to live projects — explicit downtime + alert is safer than silent
regression.

NB: ->transaction() wrapper НЕ используется — конфликтует с SharesSupplierPdo
shared-PDO в тестах. half-done state допустим: retry восстанавливает; на проде
admin контроль и редкость вызова.

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.10

Tests added:
- tests/Feature/Console/SnapshotRebuildCommandTest.php — 2 tests.

Status: RED locally (Windows-native PG Project factory signal_type quirk —
same as Task 2.2/2.3, memory project_slepok_protection.md). Command itself
registered (php artisan list | grep snapshot). GREEN expected on CI Linux.
2026-05-28 07:01:41 +03:00

83 lines
3.6 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Перестраивает project_routing_snapshots за указанную дату из текущего
* live-состояния, ПЕРЕЗАПИСЫВАЯ существующий snapshot.
*
* В отличие от `snapshot:backfill` (идемпотентный — ON CONFLICT DO NOTHING),
* `snapshot:rebuild` всегда сначала DELETE'ит существующий snapshot за дату,
* затем создаёт новый. Используется для manual recovery после падения
* `SnapshotProjectRoutingJob` cron'а с уже частично записанным snapshot'ом
* (см. Task 2.10, Spec §4.2.6 fail-loud strategy).
*
* Fail-loud strategy:
* 1. Heartbeat alarm via SchedulerHeartbeatTracker (Task 2.4).
* 2. LeadRouter Log::error on missing snapshot (Task 2.5).
* 3. Manual recovery: `php artisan snapshot:rebuild --date=YYYY-MM-DD`.
*
* NO fallback to live projects — explicit downtime + alert is safer
* than silent regression.
*/
final class SnapshotRebuildCommand extends Command
{
protected $signature = 'snapshot:rebuild {--date= : YYYY-MM-DD, по умолчанию сегодня}';
protected $description = 'Перестроить project_routing_snapshots за указанную дату (DELETE+INSERT, для recovery)';
public function handle(): int
{
$dateStr = (string) ($this->option('date') ?? Carbon::today('Europe/Moscow')->toDateString());
$date = Carbon::parse($dateStr, 'Europe/Moscow');
$weekdayBit = 1 << ($date->isoWeekday() - 1);
// NB: НЕ оборачиваем в ->transaction() — это recovery-команда, half-done state
// допустим (retry восстанавливает; на проде admin контроль). Wrapper конфликтует
// с tests SharesSupplierPdo (shared PDO + nested transaction levels).
$deleted = DB::connection('pgsql_supplier')
->table('project_routing_snapshots')
->where('snapshot_date', $dateStr)
->delete();
$inserted = DB::connection('pgsql_supplier')->insert(<<<SQL
INSERT INTO project_routing_snapshots (
snapshot_date, project_id, tenant_id,
daily_limit, delivery_days_mask, regions,
signal_type, signal_identifier, sms_senders, sms_keyword,
expected_volume
)
SELECT
?::date,
p.id, p.tenant_id,
COALESCE(p.effective_daily_limit_today, p.daily_limit_target),
p.delivery_days_mask, p.regions,
p.signal_type, p.signal_identifier, p.sms_senders, p.sms_keyword,
COALESCE(p.effective_daily_limit_today, p.daily_limit_target)
FROM projects p
INNER JOIN tenants t ON t.id = p.tenant_id
WHERE p.is_active = true
AND (p.delivery_days_mask & ?::int) <> 0
AND p.preflight_blocked_at IS NULL
AND t.frozen_by_balance_at IS NULL
AND t.deleted_at IS NULL
SQL, [$dateStr, $weekdayBit]);
$this->info("Snapshot rebuilt for {$dateStr}: deleted={$deleted}, inserted={$inserted}.");
Log::warning('snapshot.rebuild', [
'date' => $dateStr,
'deleted' => $deleted,
'inserted' => $inserted,
]);
return self::SUCCESS;
}
}