Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.63% covered (danger)
0.63%
1 / 158
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
QuotationsRetryFailed
0.63% covered (danger)
0.63%
1 / 158
14.29% covered (danger)
14.29%
1 / 7
2308.53
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
42
 processRegion
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 1
306
 getFailedIds
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 isTerminalError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 scrubTerminalIdsFromPool
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 rebuildErrorMessage
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
90
1<?php
2
3namespace App\Console\Commands;
4
5use App\Models\TblCompanies;
6use App\Models\TblG3WOrdersUpdateLogs;
7use App\Models\TblG3WResyncRuns;
8use App\Models\TblQuotations;
9use App\Services\GestionaService;
10use App\Services\PresupuestosService;
11use Illuminate\Console\Command;
12use Illuminate\Support\Facades\Log;
13
14class 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}