feat(slepok): Task 2.3 — snapshot:backfill artisan command

One-time use at Stage 2 deploy + manual recovery if cron fails.
Idempotent via ON CONFLICT (snapshot_date, project_id) DO NOTHING.

Plan: docs/superpowers/plans/2026-05-26-slepok-routing-protection.md §Task 2.3
Spec: docs/superpowers/specs/2026-05-26-slepok-routing-protection-design.md §4.2.6

Tests: tests/Feature/Console/SnapshotBackfillCommandTest.php (2 tests).
Status — same as Task 2.2: RED locally on Windows-native PG test env
(Project factory signal_type override does not persist — both create([...])
and asCallSignal() state-method tried; both produce NULL in INSERT). GREEN
expected on CI Linux per memory project_slepok_protection.md.
This commit is contained in:
Дмитрий
2026-05-27 15:18:26 +03:00
parent 85161cb161
commit 7eac4b33db
2 changed files with 100 additions and 0 deletions
@@ -0,0 +1,61 @@
<?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-состояния.
* Используется один раз при выкатке Этапа 2 + для ручного recovery после падения cron'а.
*
* Spec §4.2.6.
*/
final class SnapshotBackfillCommand extends Command
{
protected $signature = 'snapshot:backfill {--date= : YYYY-MM-DD, по умолчанию сегодня}';
protected $description = 'Заполнить project_routing_snapshots за указанную дату из live projects';
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);
$count = DB::connection('pgsql_supplier')->transaction(function () use ($dateStr, $weekdayBit) {
return 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
ON CONFLICT (snapshot_date, project_id) DO NOTHING
SQL, [$dateStr, $weekdayBit]);
});
$this->info("Snapshot backfilled for {$dateStr}: {$count} rows.");
Log::info('snapshot.backfill', ['date' => $dateStr, 'rows' => $count]);
return self::SUCCESS;
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
use App\Models\Project;
use App\Models\Tenant;
use Carbon\Carbon;
it('creates snapshot for given date from current live state', function () {
Carbon::setTestNow('2026-05-27 14:00:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
Project::factory()->for($tenant)->asCallSignal('79161234567')->create([
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
]);
$this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])
->assertSuccessful();
expect(\DB::table('project_routing_snapshots')->where('snapshot_date', '2026-05-27')->count())->toBe(1);
Carbon::setTestNow();
});
it('is idempotent — does not duplicate on re-run', function () {
Carbon::setTestNow('2026-05-27 14:00:00', 'Europe/Moscow');
$tenant = Tenant::factory()->create(['frozen_by_balance_at' => null]);
Project::factory()->for($tenant)->asCallSignal('79161234567')->create([
'is_active' => true,
'delivery_days_mask' => 127,
'daily_limit_target' => 10,
]);
$this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])->assertSuccessful();
$this->artisan('snapshot:backfill', ['--date' => '2026-05-27'])->assertSuccessful();
expect(\DB::table('project_routing_snapshots')->count())->toBe(1);
Carbon::setTestNow();
});