Files
portal/app/app/Console/Commands/RetryFailedSupplierJobsCommand.php
T
Дмитрий ecb6314e3b feat(supplier): Plan 3 Task 8 — RetryFailedSupplierJobsCommand + 5 Schedule entries
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.
2026-05-11 06:46:13 +03:00

152 lines
5.4 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 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 : [];
}
}