f94552d452
92 файла одной пачкой. Исключены чужие зоны: CLAUDE.md, .claude/settings.json, docs/observer/.pii-counters.json. gitleaks staged: no leaks found. Не верифицировано тестами - сохранение труда в историю. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
83 lines
3.6 KiB
PHP
83 lines
3.6 KiB
PHP
<?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;
|
||
}
|
||
}
|