Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 315
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
PresupuestosController
0.00% covered (danger)
0.00%
0 / 315
0.00% covered (danger)
0.00%
0 / 13
4556
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 syncByDate
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
20
 syncErrorBudgets
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 syncBudgetsWorks
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 syncById
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getCountFailedBudgets
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 syncByIds
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 listResyncRuns
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 getResyncSummary
0.00% covered (danger)
0.00%
0 / 40
0.00% covered (danger)
0.00%
0 / 1
42
 collectAllFailedIds
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
56
 getLatestErrorPerId
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 isTerminalNotFoundError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 downloadSyncBudgetsWorks
0.00% covered (danger)
0.00%
0 / 94
0.00% covered (danger)
0.00%
0 / 1
240
1<?php
2
3namespace App\Http\Controllers;
4
5use App\Exceptions\AppException;
6use App\Models\TblCompanies;
7use App\Models\TblG3WOrdersUpdateLogs;
8use App\Models\TblG3WResyncRuns;
9use App\Models\TblQuotations;
10use App\Services\GestionaService;
11use App\Services\PresupuestosService;
12use Illuminate\Http\JsonResponse;
13use Illuminate\Http\Request;
14use Illuminate\Support\Carbon;
15use Illuminate\Support\Facades\Log;
16
17class PresupuestosController extends Controller
18{
19    public function __construct(protected PresupuestosService $presupuestosService, protected GestionaService $gestionaService) {}
20
21    /**
22     * Sincroniza presupuestos a partir de una fecha.
23     *
24     * @return JsonResponse
25     */
26    public function syncByDate(Request $request)
27    {
28        $date = escapeshellarg((string) $request->input('fecha'));
29        $name = escapeshellarg((string) request()->header('Name'));
30        $region = escapeshellarg((string) request()->header('Region'));
31
32        if ($region === 'Catalunya') {
33            $region = 'Cataluña';
34        }
35
36        if ($this->gestionaService->getSyncStatus($region) === 1) {
37            $startCronDateTime = date('Y-m-d H:i:s');
38            $this->presupuestosService->updateLogs(['id' => 0, 'error' => 'Synchronization already in progress.'], 0, [], $startCronDateTime, 'System', $region);
39
40            return response()->json([
41                'success' => false,
42                'message' => 'Synchronization already in progress.',
43            ], 400);
44        }
45
46        try {
47            $phpBinary = '/usr/bin/php';
48
49            $artisanPath = escapeshellarg(base_path('artisan'));
50
51            $command = sprintf(
52                '%s %s quotations:sync %s %s %s > /dev/null 2>&1 &',
53                $phpBinary,
54                $artisanPath,
55                $date,
56                $name,
57                $region
58            );
59
60            exec($command, $output, $returnVar);
61            /*$comand = "cd /var/www/html && php artisan quotations:sync $date $name $region > /dev/null 2>&1 &";
62            exec($comand, $output, $returnVar);*/
63
64            return response()->json([
65                'success' => true,
66                'message' => 'Synchronization started in background.',
67            ]);
68        } catch (\Exception $e) {
69            report(AppException::fromException($e, 'SYNC_BY_DATE_EXCEPTION'));
70            $this->gestionaService->setSyncStatus(0, $region);
71            Log::channel('g3w')->error('Failed to start sync: '.$e->getMessage());
72
73            return response()->json([
74                'success' => false,
75                'message' => $e->getMessage(),
76            ], 500);
77        }
78    }
79
80    /**
81     * @return JsonResponse
82     */
83    public function syncErrorBudgets()
84    {
85        $name = escapeshellarg((string) request()->header('Name'));
86        $region = escapeshellarg((string) request()->header('Region'));
87
88        if ($region === 'Catalunya') {
89            $region = 'Cataluña';
90        }
91
92        if ($this->gestionaService->getSyncStatus($region) === 1) {
93            return response()->json([
94                'success' => false,
95                'message' => 'Synchronization already in progress.',
96            ], 400);
97        }
98
99        try {
100            $phpBinary = '/usr/bin/php';
101
102            $artisanPath = escapeshellarg(base_path('artisan'));
103
104            $command = sprintf(
105                '%s %s quotations:retry-failed %s %s > /dev/null 2>&1 &',
106                $phpBinary,
107                $artisanPath,
108                $name,
109                $region
110            );
111
112            exec($command, $output, $returnVar);
113            /*$comand = "cd /var/www/html && php artisan quotations:retry-failed $name $region > /dev/null 2>&1 &";
114            exec($comand, $output, $returnVar);*/
115
116            return response()->json([
117                'success' => true,
118                'message' => 'Synchronization started in background.',
119            ]);
120        } catch (\Exception $e) {
121            report(AppException::fromException($e, 'SYNC_ERROR_BUDGETS_EXCEPTION'));
122            $this->gestionaService->setSyncStatus(0, $region);
123            Log::channel('g3w')->error('Failed to start sync: '.$e->getMessage());
124
125            return response()->json([
126                'success' => false,
127                'message' => $e->getMessage(),
128            ], 500);
129        }
130
131    }
132
133    /**
134     * @return JsonResponse
135     */
136    public function syncBudgetsWorks()
137    {
138        $name = escapeshellarg((string) request()->header('Name'));
139        $region = escapeshellarg((string) request()->header('Region'));
140
141        if ($region === 'Catalunya') {
142            $region = 'Cataluña';
143        }
144
145        if ($this->gestionaService->getSyncStatus($region) === 1) {
146            return response()->json([
147                'success' => false,
148                'message' => 'Synchronization already in progress.',
149            ], 400);
150        }
151
152        try {
153            $phpBinary = '/usr/bin/php';
154
155            $artisanPath = escapeshellarg(base_path('artisan'));
156
157            $command = sprintf(
158                '%s %s quotations:sync-work %s %s > /dev/null 2>&1 &',
159                $phpBinary,
160                $artisanPath,
161                $name,
162                $region
163            );
164
165            exec($command, $output, $returnVar);
166            /*$comand = "cd /var/www/html && php artisan quotations:sync-work $name $region > /dev/null 2>&1 &";
167            exec($comand, $output, $returnVar);*/
168
169            return response()->json([
170                'success' => true,
171                'message' => 'Synchronization started in background.',
172            ]);
173        } catch (\Exception $e) {
174            report(AppException::fromException($e, 'SYNC_BUDGETS_WORKS_EXCEPTION'));
175            $this->gestionaService->setSyncStatus(0, $region);
176            Log::channel('g3w')->error('Failed to start sync: '.$e->getMessage());
177
178            return response()->json([
179                'success' => false,
180                'message' => $e->getMessage(),
181            ], 500);
182        }
183
184    }
185
186    /**
187     * Sincroniza un presupuesto por su ID.
188     *
189     * @return JsonResponse
190     */
191    public function syncById($id)
192    {
193        $region = urldecode((string) request()->header('Region'));
194        if ($region === 'Catalunya') {
195            $region = 'Cataluña';
196        }
197
198        return response()->json($this->presupuestosService->syncById($id, $region));
199    }
200
201    /**
202     * Sincroniza un presupuesto por su ID.
203     *
204     * @param  $id
205     * @return JsonResponse
206     */
207    public function getCountFailedBudgets()
208    {
209        $region = request()->header('Region');
210
211        if ($region === 'Catalunya' || $region === "'Catalunya'") {
212            $region = 'Cataluña';
213        }
214
215        $company = TblCompanies::where('region', $region)->first();
216
217        if (! $company) {
218            return response()->json([
219                'countWarnings' => 0,
220            ], 200);
221        }
222
223        $ids = TblG3WOrdersUpdateLogs::whereNotNull('sync_error_ids')
224            ->where('company_id', $company->company_id)
225            ->get(['sync_error_ids']);
226
227        $allSyncErrorIds = [];
228
229        foreach ($ids as $id) {
230            if (is_string($id->sync_error_ids)) {
231                $id->sync_error_ids = json_decode($id->sync_error_ids, true);
232                $allSyncErrorIds = array_merge($allSyncErrorIds, $id->sync_error_ids);
233            }
234        }
235
236        $allSyncErrorIds = array_unique($allSyncErrorIds);
237
238        return response()->json([
239            'count' => count($allSyncErrorIds),
240        ]);
241    }
242
243    public function syncByIds(Request $request)
244    {
245        $ids = $request['ids'];
246        $date = $request['date'];
247        $region = urldecode($request->header('Region'));
248        $user = urldecode($request->header('Name'));
249
250        if ($region === 'Catalunya') {
251            $region = 'Cataluña';
252        }
253
254        return response()->json($this->presupuestosService->syncByIds($ids, $region, $date, $user));
255    }
256
257    /**
258     * FIRE-977: List resync run logs (for monitoring UI).
259     */
260    public function listResyncRuns(Request $request)
261    {
262        try {
263            $query = TblG3WResyncRuns::query()->orderBy('run_at', 'desc');
264
265            if ($request->query('date')) {
266                $query->whereDate('run_at', $request->query('date'));
267            }
268
269            // Region filter: query param takes priority, then header
270            $region = $request->query('region') ?: $request->header('Region');
271            if ($region) {
272                $region = urldecode((string) $region);
273                if ($region === 'Catalunya') {
274                    $region = 'Cataluña';
275                }
276                $query->where('region', $region);
277            }
278
279            $runs = $query->limit(500)->get();
280
281            return response()->json(['message' => 'OK', 'data' => $runs]);
282        } catch (\Exception $e) {
283            return response()->json(['message' => 'KO', 'error' => $e->getMessage()], 500);
284        }
285    }
286
287    /**
288     * FIRE-977: Get resync summary (total failed, last run info).
289     *
290     * `total_failed` only counts presupuesto IDs whose latest known sync
291     * error is *actionable* — i.e. not a G3W "no se ha encontrado el
292     * presupuesto" 404 (FIRE-982 terminal pattern) and not a quotation
293     * that has already been deleted on the FST side. Without this filter
294     * the card on the sync-budget-monitor page inflates indefinitely as
295     * deleted/missing IDs linger in tbl_g3w_orders_update_logs.sync_error_ids
296     * (the cron only scrubs an ID once it actually retries and hits the
297     * terminal error, which can lag by days when the queue is large).
298     */
299    public function getResyncSummary(Request $request)
300    {
301        try {
302            $region = urldecode((string) $request->header('Region', ''));
303
304            if ($region === 'Catalunya') {
305                $region = 'Cataluña';
306            }
307
308            $allFailedIds = $this->collectAllFailedIds($region);
309
310            if (empty($allFailedIds)) {
311                $totalFailed = 0;
312            } else {
313                // Map ID => most recent error message so we can classify each one.
314                $latestErrorPerId = $this->getLatestErrorPerId($region);
315
316                // Filter 1: drop terminal "presupuesto no encontrado" errors —
317                // G3W will never return data for these IDs, they are not real
318                // sync work for the user to action.
319                $actionableIds = array_values(array_filter(
320                    $allFailedIds,
321                    fn ($id) => ! $this->isTerminalNotFoundError(
322                        $latestErrorPerId[(string) $id] ?? null
323                    )
324                ));
325
326                // Filter 2: drop IDs that no longer exist as active quotations
327                // (moved to tbl_quotations_deleted or hard-deleted on the FST
328                // side). They aren't a sync error a human can act on anymore.
329                if (! empty($actionableIds)) {
330                    $idsInt = array_map('intval', $actionableIds);
331                    $existing = TblQuotations::whereIn('internal_quote_id', $idsInt)
332                        ->pluck('internal_quote_id')
333                        ->map(fn ($id) => (int) $id)
334                        ->all();
335                    $existingSet = array_flip($existing);
336                    $actionableIds = array_values(array_filter(
337                        $actionableIds,
338                        fn ($id) => isset($existingSet[(int) $id])
339                    ));
340                }
341
342                $totalFailed = count($actionableIds);
343            }
344
345            $lastRunQuery = TblG3WResyncRuns::orderBy('run_at', 'desc');
346            if ($region) {
347                $lastRunQuery->where('region', $region);
348            }
349            $lastRun = $lastRunQuery->first();
350
351            return response()->json([
352                'message' => 'OK',
353                'data' => [
354                    'total_failed' => $totalFailed,
355                    'last_run_at' => $lastRun?->run_at,
356                    'last_run_success' => $lastRun?->success_count ?? 0,
357                    'last_run_failed' => $lastRun?->failed_count ?? 0,
358                ],
359            ]);
360        } catch (\Exception $e) {
361            return response()->json(['message' => 'KO', 'error' => $e->getMessage()], 500);
362        }
363    }
364
365    /**
366     * Collect distinct failed presupuesto IDs from
367     * tbl_g3w_orders_update_logs.sync_error_ids, optionally region-filtered
368     * via the company → region join. Returns IDs as strings so they key
369     * cleanly against the per-ID error map.
370     *
371     * @return list<string>
372     */
373    private function collectAllFailedIds(?string $region): array
374    {
375        $companiesQuery = TblCompanies::query();
376        if ($region) {
377            $companiesQuery->where('region', $region);
378        }
379        $companies = $companiesQuery->get();
380
381        $idSet = [];
382        foreach ($companies as $company) {
383            $logs = TblG3WOrdersUpdateLogs::whereNotNull('sync_error_ids')
384                ->where('company_id', $company->company_id)
385                ->get(['sync_error_ids']);
386
387            foreach ($logs as $log) {
388                if (! is_string($log->sync_error_ids)) {
389                    continue;
390                }
391                $decoded = json_decode($log->sync_error_ids, true);
392                if (! is_array($decoded)) {
393                    continue;
394                }
395                foreach ($decoded as $id) {
396                    $idSet[(string) $id] = true;
397                }
398            }
399        }
400
401        return array_keys($idSet);
402    }
403
404    /**
405     * Build a map of presupuesto ID => most recent error message from
406     * tbl_g3w_resync_runs.failed_errors_json. Runs are iterated ascending
407     * by run_at so the latest entry for any given ID wins.
408     *
409     * @return array<string, string>
410     */
411    private function getLatestErrorPerId(?string $region): array
412    {
413        $latestErrorPerId = [];
414
415        $query = TblG3WResyncRuns::query()
416            ->whereNotNull('failed_errors_json')
417            ->orderBy('run_at', 'asc');
418
419        if ($region) {
420            $query->where('region', $region);
421        }
422
423        $query->chunk(200, function ($runs) use (&$latestErrorPerId) {
424            foreach ($runs as $run) {
425                $errors = is_string($run->failed_errors_json)
426                    ? json_decode($run->failed_errors_json, true)
427                    : $run->failed_errors_json;
428
429                if (! is_array($errors)) {
430                    continue;
431                }
432
433                foreach ($errors as $id => $message) {
434                    $latestErrorPerId[(string) $id] = (string) $message;
435                }
436            }
437        });
438
439        return $latestErrorPerId;
440    }
441
442    /**
443     * Mirror of QuotationsRetryFailed::isTerminalError (FIRE-982). If these
444     * patterns ever drift, update BOTH call sites — the cron uses the same
445     * list to scrub terminal IDs out of sync_error_ids when it retries
446     * them, and this summary endpoint uses it to hide them from the count.
447     */
448    private function isTerminalNotFoundError(?string $error): bool
449    {
450        if ($error === null || $error === '') {
451            return false;
452        }
453
454        return str_contains($error, 'No se ha encontrado el presupuesto')
455            || str_contains($error, 'No se encuentra el presupuesto con ID en G3W');
456    }
457
458    public function downloadSyncBudgetsWorks()
459    {
460        try {
461            $region = urldecode((string) request()->header('Region'));
462
463            if ($region === 'Catalunya') {
464                $region = 'Cataluña';
465            }
466
467            $companyId = TblCompanies::where('region', $region)->first()->company_id;
468            $totalDays = 90;
469            $dataResponse = [];
470            $alreadyG3wBudgets = [];
471
472            for ($i = 0; $i < $totalDays; $i++) {
473                $currentDate = Carbon::today()->subDays($i);
474                $dateStr = $currentDate->format('Y-m-d');
475
476                $g3wBudgets = $this->gestionaService->getBudgetsByDay($dateStr, $region);
477                if (is_string($g3wBudgets)) {
478                    $g3wBudgets = json_decode($g3wBudgets, true);
479                }
480
481                $g3wBudgetIds = array_map(fn (array $item) => $item['ID'], $g3wBudgets);
482
483                $newG3wBudgetIds = array_diff($g3wBudgetIds, $alreadyG3wBudgets);
484                $alreadyG3wBudgets = array_merge($alreadyG3wBudgets, $newG3wBudgetIds);
485                $countG3wBudgets = count($newG3wBudgetIds);
486
487                $fstBudgetsQuery = ($companyId == 18 || $companyId == 22)
488                    ? TblQuotations::whereIn('company_id', [18, 22])
489                    : TblQuotations::where('company_id', $companyId);
490
491                $fstBudgets = $fstBudgetsQuery
492                    ->where(function ($query): void {
493                        $query->where('sync_import', 1)
494                            ->orWhere('sync_import_edited', 1);
495                    })
496                    ->whereDate('created_at', $dateStr)
497                    ->pluck('internal_quote_id')
498                    ->toArray();
499
500                $countFstBudgets = count($fstBudgets);
501
502                $missingIds = array_diff($newG3wBudgetIds, $fstBudgets);
503
504                $existingIdsQuery = ($companyId == 18 || $companyId == 22)
505                    ? TblQuotations::where('company_id', $companyId)
506                    : TblQuotations::where('company_id', $companyId);
507
508                $existingIds = $existingIdsQuery
509                    ->where(function ($query): void {
510                        $query->where('sync_import', 1)
511                            ->orWhere('sync_import_edited', 1);
512                    })
513                    ->whereIn('internal_quote_id', $missingIds)
514                    ->pluck('internal_quote_id')
515                    ->toArray();
516
517                $finalMissingIds = array_diff($missingIds, $existingIds);
518
519                $filteredFstBudgets = array_filter($fstBudgets, fn ($value) => $value !== null);
520                $duplicatedFst = [];
521                if (! empty($filteredFstBudgets)) {
522                    $counts = array_count_values($filteredFstBudgets);
523                    $duplicatedFst = array_keys(array_filter($counts, fn ($count) => $count > 1));
524                }
525
526                $deletedIds = array_diff($fstBudgets, $newG3wBudgetIds);
527                foreach ($deletedIds as $key => $id) {
528                    $deleted = $this->gestionaService->checkDeleted($id, $region);
529                    if (! $deleted) {
530                        unset($deletedIds[$key]);
531                    }
532                }
533
534                $g3wOrdersUpdateLogs = TblG3WOrdersUpdateLogs::where('company_id', $companyId)
535                    ->whereDate('ended_at', $dateStr)
536                    ->where('processed_by', 'System')
537                    ->get();
538
539                $syncs = $g3wOrdersUpdateLogs->count();
540
541                $syncErrorIds = $g3wOrdersUpdateLogs->flatMap(function ($item): array {
542                    $ids = $item->sync_error_ids;
543                    if (is_string($ids)) {
544                        $decoded = json_decode($ids, true);
545                        $ids = is_array($decoded) ? $decoded : explode(',', $ids);
546                    }
547
548                    return (array) $ids;
549                })
550                    ->filter()
551                    ->unique()
552                    ->toArray();
553
554                $countSyncErrorIds = count(array_values(array_unique($syncErrorIds)));
555
556                $dataResponse[] = [
557                    'date' => $dateStr,
558                    'g3wBudgets' => array_values($newG3wBudgetIds),
559                    'countG3wBudgets' => $countG3wBudgets,
560                    'fstBudgets' => $fstBudgets,
561                    'countfstBudgets' => $countFstBudgets,
562                    'missingIds' => array_values($finalMissingIds),
563                    'duplicatedFst' => $duplicatedFst,
564                    'deletedIds' => array_values($deletedIds),
565                    'syncs' => $syncs,
566                    'syncErrorIds' => $syncErrorIds,
567                    'countSyncErrorIds' => $countSyncErrorIds,
568                ];
569            }
570
571            usort($dataResponse, fn (array $a, array $b) => strtotime($b['date']) - strtotime($a['date']));
572
573            return response(['data' => $dataResponse]);
574
575        } catch (\Exception $e) {
576            report(AppException::fromException($e, 'DOWNLOAD_SYNC_BUDGETS_WORKS_EXCEPTION'));
577            Log::error('Error en downloadSyncBudgetsWorks: '.$e->getMessage());
578
579            if ($e->getMessage() == 'API URL is not defined.') {
580                return response()->json([
581                    'error' => 'KO',
582                    'message' => 'La conexión con la API de G3W no está configurada. Por favor, configúrala en los ajustes de la empresa.',
583                ], 200);
584            }
585
586            return response()->json([
587                'message' => 'Error interno del servidor',
588            ], 500);
589        }
590    }
591}