ecb6314e3b
Components:
- supplier:retry-failed Console command (hourly cron):
Re-dispatch RouteSupplierLeadJob для failed_webhook_jobs eligible
(retried_at IS NULL OR < NOW()-1h; max-age guard via failed_at).
5 Schedule entries в routes/console.php:
- RefreshSupplierSessionJob hourly + dailyAt('20:15') МСК
- SyncSupplierProjectsJob dailyAt('20:30') МСК
- CleanupInactiveSupplierProjectsJob dailyAt('02:00') МСК
- supplier:retry-failed hourly
NB: ->onOneServer() НЕ применяется — нет cache_locks таблицы (см.
project_state фаза 1). Все операции идемпотентны.
+9 tests (subagent built per actual failed_webhook_jobs schema —
retried_at/retry_count columns). PHPStan baseline +21 Pest TestCall
+ property access entries (Mockery+Pest compat pattern).
Schedule verified via `artisan schedule:list`: 5 supplier entries listed
alongside existing projects:reset-delivered-today.
152 lines
5.4 KiB
PHP
152 lines
5.4 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Console\Commands;
|
||
|
||
use App\Jobs\RouteSupplierLeadJob;
|
||
use Illuminate\Console\Command;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Log;
|
||
|
||
/**
|
||
* Hourly cron: re-dispatch RouteSupplierLeadJob для supplier-marked rows в
|
||
* failed_webhook_jobs.
|
||
*
|
||
* Spec:
|
||
* - docs/superpowers/specs/2026-05-11-plan3-supplier-sync-design.md §7 (retry-mechanism).
|
||
*
|
||
* Schema adaptation (db/schema.sql v8.11 failed_webhook_jobs):
|
||
* - НЕТ supplier_lead_id колонки → марка supplier-flow rows:
|
||
* tenant_id IS NULL AND raw_payload->>'supplier_lead_id' IS NOT NULL.
|
||
* Эти rows вставляются исключительно RouteSupplierLeadJob::failed() (BYPASSRLS
|
||
* через DB_CONNECTION='pgsql_supplier').
|
||
* - НЕТ retry_attempts/last_retried_at → используем existing колонки:
|
||
* - retry_count (INT) — counter оставшихся manual retry-attempts.
|
||
* - retried_at (TIMESTAMPTZ) — last retry timestamp (cooldown 1h).
|
||
* - resolved_at (TIMESTAMPTZ) — терминальное состояние (исключает retry).
|
||
* - failed_at (TIMESTAMPTZ) — 24h window (старше — skip).
|
||
*
|
||
* Selection criteria:
|
||
* 1. tenant_id IS NULL (supplier-flow marker)
|
||
* 2. raw_payload ? 'supplier_lead_id' (JSONB key existence)
|
||
* 3. resolved_at IS NULL (не закрыт)
|
||
* 4. failed_at >= NOW() - 24h (window safety cap)
|
||
* 5. retry_count > 0 (есть оставшиеся попытки)
|
||
* 6. retried_at IS NULL OR retried_at < NOW() - 1h (cooldown)
|
||
*
|
||
* On dispatch per row:
|
||
* - RouteSupplierLeadJob::dispatch(supplier_lead_id из raw_payload)
|
||
* - retried_at = NOW()
|
||
* - retry_count = retry_count - 1
|
||
* - если retry_count - 1 <= 0: resolved_at = NOW() (exhausted, не будет re-tried)
|
||
*
|
||
* Использует connection `pgsql_supplier` (BYPASSRLS-роль crm_supplier_worker) для
|
||
* доступа к row'ам с tenant_id=NULL (политика RLS под обычной ролью их скрывает).
|
||
*/
|
||
final class RetryFailedSupplierJobsCommand extends Command
|
||
{
|
||
/** @var string */
|
||
protected $signature = 'supplier:retry-failed';
|
||
|
||
/** @var string */
|
||
protected $description = 'Re-dispatch RouteSupplierLeadJob для supplier-flow failed_webhook_jobs (hourly cron, max retries cap)';
|
||
|
||
private const DB_CONNECTION = 'pgsql_supplier';
|
||
|
||
private const MAX_AGE_HOURS = 24;
|
||
|
||
private const RETRY_COOLDOWN_HOURS = 1;
|
||
|
||
public function handle(): int
|
||
{
|
||
$now = Carbon::now();
|
||
$ageCutoff = $now->copy()->subHours(self::MAX_AGE_HOURS);
|
||
$cooldownCutoff = $now->copy()->subHours(self::RETRY_COOLDOWN_HOURS);
|
||
|
||
$eligible = DB::connection(self::DB_CONNECTION)
|
||
->table('failed_webhook_jobs')
|
||
->whereNull('tenant_id')
|
||
// PG JSONB key-existence operator `?` коллизирует с PDO placeholder.
|
||
// Escape `?` как `??` (Laravel-конвенция) для прохода raw `?` в SQL.
|
||
->whereRaw("raw_payload ?? 'supplier_lead_id'")
|
||
->whereNull('resolved_at')
|
||
->where('failed_at', '>=', $ageCutoff)
|
||
->where('retry_count', '>', 0)
|
||
->where(function ($q) use ($cooldownCutoff) {
|
||
$q->whereNull('retried_at')
|
||
->orWhere('retried_at', '<', $cooldownCutoff);
|
||
})
|
||
->orderBy('id')
|
||
->get();
|
||
|
||
$dispatched = 0;
|
||
$exhausted = 0;
|
||
|
||
foreach ($eligible as $row) {
|
||
$payload = $this->decodePayload($row->raw_payload);
|
||
$supplierLeadId = isset($payload['supplier_lead_id'])
|
||
? (int) $payload['supplier_lead_id']
|
||
: null;
|
||
|
||
if ($supplierLeadId === null || $supplierLeadId <= 0) {
|
||
// Defensive: whereRaw уже фильтрует, но decode мог дать null.
|
||
Log::warning('supplier.retry_failed.invalid_payload', [
|
||
'failed_webhook_job_id' => $row->id,
|
||
]);
|
||
|
||
continue;
|
||
}
|
||
|
||
RouteSupplierLeadJob::dispatch($supplierLeadId);
|
||
|
||
$newRetryCount = max((int) $row->retry_count - 1, 0);
|
||
$update = [
|
||
'retried_at' => $now,
|
||
'retry_count' => $newRetryCount,
|
||
];
|
||
if ($newRetryCount === 0) {
|
||
$update['resolved_at'] = $now;
|
||
$exhausted++;
|
||
}
|
||
|
||
DB::connection(self::DB_CONNECTION)
|
||
->table('failed_webhook_jobs')
|
||
->where('id', $row->id)
|
||
->update($update);
|
||
|
||
$dispatched++;
|
||
}
|
||
|
||
Log::info('supplier.retry_failed', [
|
||
'dispatched' => $dispatched,
|
||
'exhausted' => $exhausted,
|
||
]);
|
||
|
||
$this->info("Re-dispatched {$dispatched} failed supplier webhook job(s); exhausted {$exhausted}.");
|
||
|
||
return self::SUCCESS;
|
||
}
|
||
|
||
/**
|
||
* Decode raw_payload — JSONB column возвращается как string из DB::table().
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function decodePayload(mixed $raw): array
|
||
{
|
||
if (is_array($raw)) {
|
||
return $raw;
|
||
}
|
||
|
||
if (! is_string($raw)) {
|
||
return [];
|
||
}
|
||
|
||
$decoded = json_decode($raw, true);
|
||
|
||
return is_array($decoded) ? $decoded : [];
|
||
}
|
||
}
|