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