Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.63% |
1 / 158 |
|
14.29% |
1 / 7 |
CRAP | |
0.00% |
0 / 1 |
| QuotationsRetryFailed | |
0.63% |
1 / 158 |
|
14.29% |
1 / 7 |
2308.53 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| handle | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
42 | |||
| processRegion | |
0.00% |
0 / 84 |
|
0.00% |
0 / 1 |
306 | |||
| getFailedIds | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
20 | |||
| isTerminalError | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
20 | |||
| scrubTerminalIdsFromPool | |
0.00% |
0 / 19 |
|
0.00% |
0 / 1 |
56 | |||
| rebuildErrorMessage | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
90 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Console\Commands; |
| 4 | |
| 5 | use App\Models\TblCompanies; |
| 6 | use App\Models\TblG3WOrdersUpdateLogs; |
| 7 | use App\Models\TblG3WResyncRuns; |
| 8 | use App\Models\TblQuotations; |
| 9 | use App\Services\GestionaService; |
| 10 | use App\Services\PresupuestosService; |
| 11 | use Illuminate\Console\Command; |
| 12 | use Illuminate\Support\Facades\Log; |
| 13 | |
| 14 | class QuotationsRetryFailed extends Command |
| 15 | { |
| 16 | /** |
| 17 | * The name and signature of the console command. |
| 18 | * |
| 19 | * @var string |
| 20 | */ |
| 21 | protected $signature = 'quotations:retry-failed |
| 22 | {--region=} |
| 23 | {--verbose-errors} |
| 24 | {--limit=200 : Max IDs to process per run (across all regions/companies)} |
| 25 | {--days=7 : Only consider failed-sync log rows newer than this many days}'; |
| 26 | |
| 27 | /** |
| 28 | * The console command description. |
| 29 | * |
| 30 | * @var string |
| 31 | */ |
| 32 | protected $description = 'Re-syncs failed and zero-amount quotations across all regions (FIRE-977)'; |
| 33 | |
| 34 | /** |
| 35 | * All active regions. |
| 36 | */ |
| 37 | private const REGIONS = [ |
| 38 | 'Cataluña', |
| 39 | 'Madrid', |
| 40 | 'Comunidad Valenciana', |
| 41 | 'Andalucía', |
| 42 | 'Baleares', |
| 43 | ]; |
| 44 | |
| 45 | /** |
| 46 | * Active budget statuses to consider for zero-amount resync. |
| 47 | */ |
| 48 | private const ACTIVE_STATUS_IDS = [1, 2, 3, 11, 17]; |
| 49 | |
| 50 | /** |
| 51 | * Create a new command instance. |
| 52 | */ |
| 53 | public function __construct( |
| 54 | private readonly PresupuestosService $presupuestosService, |
| 55 | private readonly GestionaService $gestionaService, |
| 56 | ) { |
| 57 | parent::__construct(); |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Execute the console command. |
| 62 | */ |
| 63 | public function handle(): int |
| 64 | { |
| 65 | $regionOption = $this->option('region'); |
| 66 | $verbose = (bool) $this->option('verbose-errors'); |
| 67 | $regions = $regionOption ? [$regionOption] : self::REGIONS; |
| 68 | |
| 69 | // FIRE-1025 / FIRE-977 follow-up: cap the work each invocation does |
| 70 | // so a single run can't pile up on top of the next one. Pre-fix this |
| 71 | // command had no limit and no time-window, so it iterated EVERY |
| 72 | // failed ID ever logged across all 5 regions × all companies on |
| 73 | // every fire — taking hours per run while the cron fired again every |
| 74 | // 5 minutes (`routes/console.php` 5-59/5 1-5 * * *), producing |
| 75 | // 30+ overlapping artisan processes that pinned MySQL at 270% CPU. |
| 76 | $remainingBudget = max(1, (int) $this->option('limit')); |
| 77 | $daysWindow = max(1, (int) $this->option('days')); |
| 78 | |
| 79 | $totalPending = 0; |
| 80 | $totalProcessed = 0; |
| 81 | |
| 82 | foreach ($regions as $region) { |
| 83 | if ($remainingBudget <= 0) { |
| 84 | Log::channel('g3w')->info("quotations:retry-failed - Hit per-run budget ({$totalProcessed} processed); deferring remaining regions to next run."); |
| 85 | break; |
| 86 | } |
| 87 | |
| 88 | try { |
| 89 | [$pending, $processed] = $this->processRegion($region, $verbose, $remainingBudget, $daysWindow); |
| 90 | $totalPending += $pending; |
| 91 | $totalProcessed += $processed; |
| 92 | $remainingBudget -= $processed; |
| 93 | } catch (\Exception $e) { |
| 94 | Log::channel('g3w')->error("quotations:retry-failed - Error processing region {$region}: " . $e->getMessage()); |
| 95 | $this->error("Error processing region {$region}: " . $e->getMessage()); |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | if ($totalPending === 0) { |
| 100 | $this->info('No pending quotations to resync across any region. Exiting.'); |
| 101 | } else { |
| 102 | $this->info("Total processed this run: {$totalProcessed} (pending across regions: {$totalPending})"); |
| 103 | } |
| 104 | |
| 105 | return Command::SUCCESS; |
| 106 | } |
| 107 | |
| 108 | /** |
| 109 | * Process a single region: find all companies, gather failed + zero-amount IDs, resync. |
| 110 | * |
| 111 | * @param string $region |
| 112 | * @param bool $verbose When true, print each failed ID + error message to CLI. |
| 113 | * @param int $remainingBudget Max IDs left in this run's budget across all regions. |
| 114 | * @param int $daysWindow Only consider failed-sync logs newer than this. |
| 115 | * |
| 116 | * @return array{0:int,1:int} [pending_count_across_companies, processed_count] |
| 117 | */ |
| 118 | private function processRegion(string $region, bool $verbose, int $remainingBudget, int $daysWindow): array |
| 119 | { |
| 120 | $companies = TblCompanies::where('region', $region)->get(); |
| 121 | $regionPendingTotal = 0; |
| 122 | $regionProcessedTotal = 0; |
| 123 | |
| 124 | foreach ($companies as $company) { |
| 125 | if ($remainingBudget <= 0) { |
| 126 | break; |
| 127 | } |
| 128 | |
| 129 | $companyId = $company->company_id; |
| 130 | |
| 131 | // 1. Gather failed IDs from sync_error_ids logs (time-bounded) |
| 132 | $failedIds = $this->getFailedIds($companyId, $daysWindow); |
| 133 | |
| 134 | // 2. Gather zero-amount quotation IDs (use internal_quote_id for G3W sync) |
| 135 | $zeroAmountQuotations = TblQuotations::where('company_id', $companyId) |
| 136 | ->where('sync_import', 1) |
| 137 | ->where(function ($q) { |
| 138 | $q->where('amount', 0)->orWhereNull('amount'); |
| 139 | }) |
| 140 | ->whereIn('budget_status_id', self::ACTIVE_STATUS_IDS) |
| 141 | ->pluck('internal_quote_id') |
| 142 | ->filter() |
| 143 | ->toArray(); |
| 144 | |
| 145 | $zeroAmountCount = count($zeroAmountQuotations); |
| 146 | |
| 147 | // 3. Merge both lists (unique) |
| 148 | $allIds = array_values(array_unique(array_merge($failedIds, $zeroAmountQuotations))); |
| 149 | |
| 150 | if (empty($allIds)) { |
| 151 | continue; |
| 152 | } |
| 153 | |
| 154 | // 4. Check sync lock — if running, skip this region (don't block) |
| 155 | if ($this->gestionaService->getSyncStatus($region) === 1) { |
| 156 | Log::channel('g3w')->info("quotations:retry-failed - Skipping region {$region} (company {$companyId}): sync already in progress."); |
| 157 | $this->warn("Skipping region {$region} (company {$companyId}): sync already in progress."); |
| 158 | continue; |
| 159 | } |
| 160 | |
| 161 | $pendingCount = count($allIds); |
| 162 | $regionPendingTotal += $pendingCount; |
| 163 | |
| 164 | // FIRE-1025: cap per-company processing at the remaining budget |
| 165 | // so a single backlog of e.g. 4000 IDs can't blow past the |
| 166 | // intended ~200 IDs/run and starve the box. |
| 167 | $idsToProcess = array_slice($allIds, 0, $remainingBudget); |
| 168 | $skippedThisRun = $pendingCount - count($idsToProcess); |
| 169 | |
| 170 | $msg = "Region {$region}, company {$companyId}: {$pendingCount} pending ({$zeroAmountCount} zero-amount)"; |
| 171 | if ($skippedThisRun > 0) { |
| 172 | $msg .= " — processing first " . count($idsToProcess) . ", deferring {$skippedThisRun} to the next run"; |
| 173 | } |
| 174 | $this->info($msg); |
| 175 | |
| 176 | // 5. Resync each quotation (within the slice) |
| 177 | $successCount = 0; |
| 178 | $failedCount = 0; |
| 179 | $terminalCount = 0; |
| 180 | $stillFailedIds = []; |
| 181 | $failedErrors = []; // map: id => error_message |
| 182 | $terminalIds = []; // FIRE-982: IDs that are permanently dead (not in G3W) |
| 183 | |
| 184 | foreach ($idsToProcess as $id) { |
| 185 | $errorMessage = null; |
| 186 | |
| 187 | try { |
| 188 | $result = $this->presupuestosService->syncById($id, $region); |
| 189 | |
| 190 | if (!empty($result['success'])) { |
| 191 | $successCount++; |
| 192 | } else { |
| 193 | $errorMessage = $result['error'] ?? 'Unknown error'; |
| 194 | if ($this->isTerminalError($errorMessage)) { |
| 195 | $terminalIds[] = (int) $id; |
| 196 | $terminalCount++; |
| 197 | } else { |
| 198 | $failedCount++; |
| 199 | $stillFailedIds[] = $id; |
| 200 | $failedErrors[(string) $id] = $errorMessage; |
| 201 | Log::channel('g3w')->warning("quotations:retry-failed - Failed to resync ID {$id} in region {$region}: " . $errorMessage); |
| 202 | } |
| 203 | } |
| 204 | } catch (\Exception $e) { |
| 205 | $errorMessage = $e->getMessage(); |
| 206 | if ($this->isTerminalError($errorMessage)) { |
| 207 | $terminalIds[] = (int) $id; |
| 208 | $terminalCount++; |
| 209 | } else { |
| 210 | $failedCount++; |
| 211 | $stillFailedIds[] = $id; |
| 212 | $failedErrors[(string) $id] = $errorMessage; |
| 213 | Log::channel('g3w')->warning("quotations:retry-failed - Exception resyncing ID {$id} in region {$region}: " . $errorMessage); |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | if ($verbose && $errorMessage !== null) { |
| 218 | $this->line(" <fg=red>ID {$id}</>: {$errorMessage}"); |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | $processedThisCompany = count($idsToProcess); |
| 223 | $regionProcessedTotal += $processedThisCompany; |
| 224 | $remainingBudget -= $processedThisCompany; |
| 225 | |
| 226 | // FIRE-982: drop terminal IDs from the source pool so they stop |
| 227 | // coming back into the retry loop on every fire. Without this the |
| 228 | // same not-found IDs would re-fail every 5 min for the entire |
| 229 | // 7-day window. |
| 230 | if (!empty($terminalIds)) { |
| 231 | $this->scrubTerminalIdsFromPool($companyId, $terminalIds); |
| 232 | } |
| 233 | |
| 234 | // 6. Log run to tbl_g3w_resync_runs |
| 235 | TblG3WResyncRuns::create([ |
| 236 | 'company_id' => $companyId, |
| 237 | 'region' => $region, |
| 238 | 'run_at' => now(), |
| 239 | 'pending_count' => $pendingCount, |
| 240 | 'success_count' => $successCount, |
| 241 | 'failed_count' => $failedCount, |
| 242 | 'zero_amount_count' => $zeroAmountCount, |
| 243 | 'failed_ids_json' => !empty($stillFailedIds) ? json_encode($stillFailedIds) : null, |
| 244 | 'failed_errors_json' => !empty($failedErrors) ? json_encode($failedErrors, JSON_UNESCAPED_UNICODE) : null, |
| 245 | ]); |
| 246 | |
| 247 | $msg = " -> Done: {$successCount} success, {$failedCount} failed"; |
| 248 | if ($terminalCount > 0) { |
| 249 | $msg .= ", {$terminalCount} dropped (not in G3W)"; |
| 250 | } |
| 251 | $this->info($msg); |
| 252 | } |
| 253 | |
| 254 | return [$regionPendingTotal, $regionProcessedTotal]; |
| 255 | } |
| 256 | |
| 257 | /** |
| 258 | * Get all unique failed sync IDs for a company from the update logs. |
| 259 | * |
| 260 | * @param int $companyId |
| 261 | * @param int $daysWindow Only consider logs whose created_at is within this many days. |
| 262 | * |
| 263 | * @return array<int> |
| 264 | */ |
| 265 | private function getFailedIds(int $companyId, int $daysWindow): array |
| 266 | { |
| 267 | // FIRE-1025: time-bound the failed-IDs scan. Pre-fix this method |
| 268 | // unioned every sync_error_ids JSON ever recorded for the company, |
| 269 | // so an ID that failed once 18 months ago and was later resolved |
| 270 | // by hand kept coming back into the retry pool every 5 minutes — |
| 271 | // contributing to the runaway we saw on 2026-04-27. |
| 272 | // |
| 273 | // FIRE-982 follow-up (28/04): tbl_g3w_orders_update_logs has |
| 274 | // $timestamps = false and no `created_at` column — it stores |
| 275 | // `started_at` / `ended_at` written manually by `updateLogs()`. |
| 276 | // The original FIRE-1025 patch filtered on `created_at` which |
| 277 | // silently threw "Unknown column" at every retry tick, swallowed |
| 278 | // by the outer catch and therefore invisible. Use `started_at`. |
| 279 | $logs = TblG3WOrdersUpdateLogs::whereNotNull('sync_error_ids') |
| 280 | ->where('company_id', $companyId) |
| 281 | ->where('started_at', '>=', now()->subDays($daysWindow)) |
| 282 | ->get(['sync_error_ids']); |
| 283 | |
| 284 | $allIds = []; |
| 285 | |
| 286 | foreach ($logs as $log) { |
| 287 | if (is_string($log->sync_error_ids)) { |
| 288 | $decoded = json_decode($log->sync_error_ids, true); |
| 289 | if (is_array($decoded)) { |
| 290 | $allIds = array_merge($allIds, $decoded); |
| 291 | } |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | return array_values(array_unique($allIds)); |
| 296 | } |
| 297 | |
| 298 | /** |
| 299 | * FIRE-982: classify an error as terminal (the ID will never sync, so |
| 300 | * stop retrying it) vs. transient (504 timeouts, network errors, etc. |
| 301 | * that may succeed on the next attempt). |
| 302 | * |
| 303 | * Terminal cases: |
| 304 | * - "No se ha encontrado el presupuesto" — G3W returned 404 for the ID |
| 305 | * - "No se encuentra el presupuesto con ID en G3W" — created pre-FST |
| 306 | * integration, never going to be in G3W's index |
| 307 | * |
| 308 | * Anything else (including 504 Gateway Time-out, file-permission errors, |
| 309 | * unexpected exceptions) is treated as transient and stays in the pool. |
| 310 | */ |
| 311 | private function isTerminalError(?string $error): bool |
| 312 | { |
| 313 | if ($error === null || $error === '') { |
| 314 | return false; |
| 315 | } |
| 316 | |
| 317 | return str_contains($error, 'No se ha encontrado el presupuesto') |
| 318 | || str_contains($error, 'No se encuentra el presupuesto con ID en G3W'); |
| 319 | } |
| 320 | |
| 321 | /** |
| 322 | * FIRE-982: rewrite `tbl_g3w_orders_update_logs.sync_error_ids` JSON |
| 323 | * arrays for this company to drop the given terminal IDs, re-derive the |
| 324 | * `sync_error` count, AND rebuild `sync_error_message` so the surviving |
| 325 | * IDs still pair correctly with their segments — `Quotations::list_g3w_orders_failed` |
| 326 | * pairs the two columns by index, so any drift here misattributes |
| 327 | * messages to surviving IDs in the UI's "Detalle de errores" drilldown. |
| 328 | * Without this the next `getFailedIds()` call pulls them right back into |
| 329 | * the retry pool. |
| 330 | * |
| 331 | * @param list<int> $terminalIds |
| 332 | */ |
| 333 | private function scrubTerminalIdsFromPool(int $companyId, array $terminalIds): void |
| 334 | { |
| 335 | $terminalSet = array_values(array_unique(array_map('intval', $terminalIds))); |
| 336 | |
| 337 | $logs = TblG3WOrdersUpdateLogs::whereNotNull('sync_error_ids') |
| 338 | ->where('company_id', $companyId) |
| 339 | ->get(['id', 'sync_error_ids', 'sync_error_message']); |
| 340 | |
| 341 | foreach ($logs as $log) { |
| 342 | $ids = is_string($log->sync_error_ids) |
| 343 | ? json_decode($log->sync_error_ids, true) |
| 344 | : $log->sync_error_ids; |
| 345 | |
| 346 | if (!is_array($ids) || empty($ids)) { |
| 347 | continue; |
| 348 | } |
| 349 | |
| 350 | $intIds = array_map('intval', $ids); |
| 351 | $cleaned = array_values(array_diff($intIds, $terminalSet)); |
| 352 | |
| 353 | if (count($cleaned) === count($intIds)) { |
| 354 | continue; // unchanged |
| 355 | } |
| 356 | |
| 357 | $log->update([ |
| 358 | 'sync_error_ids' => empty($cleaned) ? null : json_encode($cleaned), |
| 359 | 'sync_error' => count($cleaned), |
| 360 | 'sync_error_message' => $this->rebuildErrorMessage($log->sync_error_message, $cleaned), |
| 361 | ]); |
| 362 | } |
| 363 | } |
| 364 | |
| 365 | /** |
| 366 | * FIRE-982: pick out the per-ID segments from `sync_error_message` whose |
| 367 | * embedded "el presupuesto N:" matches a surviving ID, and re-join them |
| 368 | * in source order. Returns null if no segment survives (matches the |
| 369 | * NULL we set on `sync_error_ids` in that case). |
| 370 | * |
| 371 | * @param list<int> $survivingIds |
| 372 | */ |
| 373 | private function rebuildErrorMessage(?string $original, array $survivingIds): ?string |
| 374 | { |
| 375 | if ($original === null || $original === '') { |
| 376 | return null; |
| 377 | } |
| 378 | |
| 379 | $survivingSet = array_flip(array_map('intval', $survivingIds)); |
| 380 | |
| 381 | $segments = preg_split( |
| 382 | '/(?=Error (?:sincronizando|actualizando) el presupuesto)/u', |
| 383 | $original, |
| 384 | -1, |
| 385 | PREG_SPLIT_NO_EMPTY |
| 386 | ); |
| 387 | |
| 388 | if (!is_array($segments) || empty($segments)) { |
| 389 | return $original; |
| 390 | } |
| 391 | |
| 392 | $kept = []; |
| 393 | foreach ($segments as $segment) { |
| 394 | if (preg_match('/el presupuesto\s+(\d+)\s*:/u', $segment, $m)) { |
| 395 | if (isset($survivingSet[(int) $m[1]])) { |
| 396 | $kept[] = rtrim($segment, ", \t\n\r\0\x0B"); |
| 397 | } |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | return empty($kept) ? null : implode(', ', $kept); |
| 402 | } |
| 403 | } |