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  bool  $verbose  When true, print each failed ID + error message to CLI.
112     * @param  int  $remainingBudget  Max IDs left in this run's budget across all regions.
113     * @param  int  $daysWindow  Only consider failed-sync logs newer than this.
114     * @return array{0:int,1:int} [pending_count_across_companies, processed_count]
115     */
116    private function processRegion(string $region, bool $verbose, int $remainingBudget, int $daysWindow): array
117    {
118        $companies = TblCompanies::where('region', $region)->get();
119        $regionPendingTotal = 0;
120        $regionProcessedTotal = 0;
121
122        foreach ($companies as $company) {
123            if ($remainingBudget <= 0) {
124                break;
125            }
126
127            $companyId = $company->company_id;
128
129            // 1. Gather failed IDs from sync_error_ids logs (time-bounded)
130            $failedIds = $this->getFailedIds($companyId, $daysWindow);
131
132            // 2. Gather zero-amount quotation IDs (use internal_quote_id for G3W sync)
133            $zeroAmountQuotations = TblQuotations::where('company_id', $companyId)
134                ->where('sync_import', 1)
135                ->where(function ($q) {
136                    $q->where('amount', 0)->orWhereNull('amount');
137                })
138                ->whereIn('budget_status_id', self::ACTIVE_STATUS_IDS)
139                ->pluck('internal_quote_id')
140                ->filter()
141                ->toArray();
142
143            $zeroAmountCount = count($zeroAmountQuotations);
144
145            // 3. Merge both lists (unique)
146            $allIds = array_values(array_unique(array_merge($failedIds, $zeroAmountQuotations)));
147
148            if (empty($allIds)) {
149                continue;
150            }
151
152            // 4. Check sync lock — if running, skip this region (don't block)
153            if ($this->gestionaService->getSyncStatus($region) === 1) {
154                Log::channel('g3w')->info("quotations:retry-failed - Skipping region {$region} (company {$companyId}): sync already in progress.");
155                $this->warn("Skipping region {$region} (company {$companyId}): sync already in progress.");
156
157                continue;
158            }
159
160            $pendingCount = count($allIds);
161            $regionPendingTotal += $pendingCount;
162
163            // FIRE-1025: cap per-company processing at the remaining budget
164            // so a single backlog of e.g. 4000 IDs can't blow past the
165            // intended ~200 IDs/run and starve the box.
166            $idsToProcess = array_slice($allIds, 0, $remainingBudget);
167            $skippedThisRun = $pendingCount - count($idsToProcess);
168
169            $msg = "Region {$region}, company {$companyId}{$pendingCount} pending ({$zeroAmountCount} zero-amount)";
170            if ($skippedThisRun > 0) {
171                $msg .= ' — processing first '.count($idsToProcess).", deferring {$skippedThisRun} to the next run";
172            }
173            $this->info($msg);
174
175            // 5. Resync each quotation (within the slice)
176            $successCount = 0;
177            $failedCount = 0;
178            $terminalCount = 0;
179            $stillFailedIds = [];
180            $failedErrors = []; // map: id => error_message
181            $terminalIds = []; // FIRE-982: IDs that are permanently dead (not in G3W)
182
183            foreach ($idsToProcess as $id) {
184                $errorMessage = null;
185
186                try {
187                    $result = $this->presupuestosService->syncById($id, $region);
188
189                    if (! empty($result['success'])) {
190                        $successCount++;
191                    } else {
192                        $errorMessage = $result['error'] ?? 'Unknown error';
193                        if ($this->isTerminalError($errorMessage)) {
194                            $terminalIds[] = (int) $id;
195                            $terminalCount++;
196                        } else {
197                            $failedCount++;
198                            $stillFailedIds[] = $id;
199                            $failedErrors[(string) $id] = $errorMessage;
200                            Log::channel('g3w')->warning("quotations:retry-failed - Failed to resync ID {$id} in region {$region}".$errorMessage);
201                        }
202                    }
203                } catch (\Exception $e) {
204                    $errorMessage = $e->getMessage();
205                    if ($this->isTerminalError($errorMessage)) {
206                        $terminalIds[] = (int) $id;
207                        $terminalCount++;
208                    } else {
209                        $failedCount++;
210                        $stillFailedIds[] = $id;
211                        $failedErrors[(string) $id] = $errorMessage;
212                        Log::channel('g3w')->warning("quotations:retry-failed - Exception resyncing ID {$id} in region {$region}".$errorMessage);
213                    }
214                }
215
216                if ($verbose && $errorMessage !== null) {
217                    $this->line("    <fg=red>ID {$id}</>: {$errorMessage}");
218                }
219            }
220
221            $processedThisCompany = count($idsToProcess);
222            $regionProcessedTotal += $processedThisCompany;
223            $remainingBudget -= $processedThisCompany;
224
225            // FIRE-982: drop terminal IDs from the source pool so they stop
226            // coming back into the retry loop on every fire. Without this the
227            // same not-found IDs would re-fail every 5 min for the entire
228            // 7-day window.
229            if (! empty($terminalIds)) {
230                $this->scrubTerminalIdsFromPool($companyId, $terminalIds);
231            }
232
233            // 6. Log run to tbl_g3w_resync_runs
234            TblG3WResyncRuns::create([
235                'company_id' => $companyId,
236                'region' => $region,
237                'run_at' => now(),
238                'pending_count' => $pendingCount,
239                'success_count' => $successCount,
240                'failed_count' => $failedCount,
241                'zero_amount_count' => $zeroAmountCount,
242                'failed_ids_json' => ! empty($stillFailedIds) ? json_encode($stillFailedIds) : null,
243                'failed_errors_json' => ! empty($failedErrors) ? json_encode($failedErrors, JSON_UNESCAPED_UNICODE) : null,
244            ]);
245
246            $msg = "  -> Done: {$successCount} success, {$failedCount} failed";
247            if ($terminalCount > 0) {
248                $msg .= "{$terminalCount} dropped (not in G3W)";
249            }
250            $this->info($msg);
251        }
252
253        return [$regionPendingTotal, $regionProcessedTotal];
254    }
255
256    /**
257     * Get all unique failed sync IDs for a company from the update logs.
258     *
259     * @param  int  $daysWindow  Only consider logs whose created_at is within this many days.
260     * @return array<int>
261     */
262    private function getFailedIds(int $companyId, int $daysWindow): array
263    {
264        // FIRE-1025: time-bound the failed-IDs scan. Pre-fix this method
265        // unioned every sync_error_ids JSON ever recorded for the company,
266        // so an ID that failed once 18 months ago and was later resolved
267        // by hand kept coming back into the retry pool every 5 minutes —
268        // contributing to the runaway we saw on 2026-04-27.
269        //
270        // FIRE-982 follow-up (28/04): tbl_g3w_orders_update_logs has
271        // $timestamps = false and no `created_at` column — it stores
272        // `started_at` / `ended_at` written manually by `updateLogs()`.
273        // The original FIRE-1025 patch filtered on `created_at` which
274        // silently threw "Unknown column" at every retry tick, swallowed
275        // by the outer catch and therefore invisible. Use `started_at`.
276        $logs = TblG3WOrdersUpdateLogs::whereNotNull('sync_error_ids')
277            ->where('company_id', $companyId)
278            ->where('started_at', '>=', now()->subDays($daysWindow))
279            ->get(['sync_error_ids']);
280
281        $allIds = [];
282
283        foreach ($logs as $log) {
284            if (is_string($log->sync_error_ids)) {
285                $decoded = json_decode($log->sync_error_ids, true);
286                if (is_array($decoded)) {
287                    $allIds = array_merge($allIds, $decoded);
288                }
289            }
290        }
291
292        return array_values(array_unique($allIds));
293    }
294
295    /**
296     * FIRE-982: classify an error as terminal (the ID will never sync, so
297     * stop retrying it) vs. transient (504 timeouts, network errors, etc.
298     * that may succeed on the next attempt).
299     *
300     * Terminal cases:
301     *   - "No se ha encontrado el presupuesto" — G3W returned 404 for the ID
302     *   - "No se encuentra el presupuesto con ID en G3W" — created pre-FST
303     *     integration, never going to be in G3W's index
304     *
305     * Anything else (including 504 Gateway Time-out, file-permission errors,
306     * unexpected exceptions) is treated as transient and stays in the pool.
307     */
308    private function isTerminalError(?string $error): bool
309    {
310        if ($error === null || $error === '') {
311            return false;
312        }
313
314        return str_contains($error, 'No se ha encontrado el presupuesto')
315            || str_contains($error, 'No se encuentra el presupuesto con ID en G3W');
316    }
317
318    /**
319     * FIRE-982: rewrite `tbl_g3w_orders_update_logs.sync_error_ids` JSON
320     * arrays for this company to drop the given terminal IDs, re-derive the
321     * `sync_error` count, AND rebuild `sync_error_message` so the surviving
322     * IDs still pair correctly with their segments — `Quotations::list_g3w_orders_failed`
323     * pairs the two columns by index, so any drift here misattributes
324     * messages to surviving IDs in the UI's "Detalle de errores" drilldown.
325     * Without this the next `getFailedIds()` call pulls them right back into
326     * the retry pool.
327     *
328     * @param  list<int>  $terminalIds
329     */
330    private function scrubTerminalIdsFromPool(int $companyId, array $terminalIds): void
331    {
332        $terminalSet = array_values(array_unique(array_map('intval', $terminalIds)));
333
334        $logs = TblG3WOrdersUpdateLogs::whereNotNull('sync_error_ids')
335            ->where('company_id', $companyId)
336            ->get(['id', 'sync_error_ids', 'sync_error_message']);
337
338        foreach ($logs as $log) {
339            $ids = is_string($log->sync_error_ids)
340                ? json_decode($log->sync_error_ids, true)
341                : $log->sync_error_ids;
342
343            if (! is_array($ids) || empty($ids)) {
344                continue;
345            }
346
347            $intIds = array_map('intval', $ids);
348            $cleaned = array_values(array_diff($intIds, $terminalSet));
349
350            if (count($cleaned) === count($intIds)) {
351                continue; // unchanged
352            }
353
354            $log->update([
355                'sync_error_ids' => empty($cleaned) ? null : json_encode($cleaned),
356                'sync_error' => count($cleaned),
357                'sync_error_message' => $this->rebuildErrorMessage($log->sync_error_message, $cleaned),
358            ]);
359        }
360    }
361
362    /**
363     * FIRE-982: pick out the per-ID segments from `sync_error_message` whose
364     * embedded "el presupuesto N:" matches a surviving ID, and re-join them
365     * in source order. Returns null if no segment survives (matches the
366     * NULL we set on `sync_error_ids` in that case).
367     *
368     * @param  list<int>  $survivingIds
369     */
370    private function rebuildErrorMessage(?string $original, array $survivingIds): ?string
371    {
372        if ($original === null || $original === '') {
373            return null;
374        }
375
376        $survivingSet = array_flip(array_map('intval', $survivingIds));
377
378        $segments = preg_split(
379            '/(?=Error (?:sincronizando|actualizando) el presupuesto)/u',
380            $original,
381            -1,
382            PREG_SPLIT_NO_EMPTY
383        );
384
385        if (! is_array($segments) || empty($segments)) {
386            return $original;
387        }
388
389        $kept = [];
390        foreach ($segments as $segment) {
391            if (preg_match('/el presupuesto\s+(\d+)\s*:/u', $segment, $m)) {
392                if (isset($survivingSet[(int) $m[1]])) {
393                    $kept[] = rtrim($segment, ", \t\n\r\0\x0B");
394                }
395            }
396        }
397
398        return empty($kept) ? null : implode(', ', $kept);
399    }
400}