Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.08% covered (danger)
0.08%
1 / 1307
3.33% covered (danger)
3.33%
1 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
PresupuestosService
0.08% covered (danger)
0.08%
1 / 1307
3.33% covered (danger)
3.33%
1 / 30
166490.20
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
 normalizeG3wAcceptance
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 syncByDate
0.00% covered (danger)
0.00%
0 / 103
0.00% covered (danger)
0.00%
0 / 1
870
 fetchFromGestiona
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 syncById
0.00% covered (danger)
0.00%
0 / 294
0.00% covered (danger)
0.00%
0 / 1
10302
 syncModifiedBudgetById
0.00% covered (danger)
0.00%
0 / 285
0.00% covered (danger)
0.00%
0 / 1
8010
 classifyLineAsLabor
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
56
 isBlankClientField
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 calculateBudgetMargin
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
380
 syncErrorBudgets
0.00% covered (danger)
0.00%
0 / 55
0.00% covered (danger)
0.00%
0 / 1
182
 syncBudgetsWorks
0.00% covered (danger)
0.00%
0 / 66
0.00% covered (danger)
0.00%
0 / 1
240
 notifyErrors
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 generateQuoteId
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
90
 normalizeStatus
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 normalizeSegment
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 normalizeType
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 normalizeSource
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
56
 saveDocument
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
110
 formatBytes
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 updateLogs
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
20
 checkEmailInvalid
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 checkRequiredFields
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
420
 isBlacklistedEmail
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 syncExistingDataWithWarnings
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 syncByIds
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 1
240
 syncNullBudget
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 getAlternativeClientData
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
132
 checkAproval
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 computeForApprovalLevel
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
72
 sendApprovalNotificationOnly
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace App\Services;
4
5use App\Http\Controllers\Quotations;
6use App\Models\TblBudgetStatus;
7use App\Models\TblBudgetTypes;
8use App\Models\TblCcAcceptanceNotifications;
9use App\Models\TblCompanies;
10use App\Models\TblFiles;
11use App\Models\TblG3wLastUpdate;
12use App\Models\TblG3WOrdersUpdateLogs;
13use App\Models\TblInvoicesExceptions;
14use App\Models\TblLastFollowUpDate;
15use App\Models\TblProjectTypes;
16use App\Models\TblQuotations;
17use App\Models\TblSegmentG3wMapping;
18use App\Models\TblSegments;
19use App\Models\TblSourceG3wMapping;
20use App\Models\TblSources;
21use App\Models\TblStatusG3wMapping;
22use App\Models\TblToAcceptanceNotifications;
23use App\Models\TblTypeG3wMapping;
24use App\Models\TblUserG3wMapping;
25use App\Models\TblUsers;
26use App\Models\TblVipClients;
27use App\Services\ResultCache;
28use Google\Service\AdMob\Date;
29use Illuminate\Contracts\Routing\ResponseFactory;
30use Illuminate\Database\QueryException;
31use Illuminate\Http\JsonResponse;
32use Illuminate\Http\Response;
33use Illuminate\Support\Carbon;
34use Illuminate\Support\Facades\DB;
35use Illuminate\Support\Facades\Log;
36use Illuminate\Support\Facades\Storage;
37use Mockery\Exception;
38use SendGrid\Mail\Mail;
39
40class PresupuestosService extends GestionaService
41{
42    public function __construct(private readonly Quotations $quotationsController, private readonly WorkService $workSevice)
43    {
44        parent::__construct();
45    }
46
47    /**
48     * FIRE-1024 (2026-04-30): G3W returns acceptance dates in a half-dozen
49     * empty representations — null, empty string, '0000-00-00', '0000/00/00',
50     * '0000-00-00 00:00:00', sometimes whitespace. Normalise all of them to
51     * PHP null so MySQL stores NULL, regardless of strict mode.
52     *
53     * Pre-fix the creation path at line 656 only caught two of these forms;
54     * the modification path at line 864 already used `empty()` + array but
55     * had its own logic bug (see FIRE-1024 investigation Bug 2). Centralise
56     * here so every site reads the same source of truth.
57     */
58    private function normalizeG3wAcceptance(mixed $raw): ?string
59    {
60        if ($raw === null) {
61            return null;
62        }
63        if (! is_string($raw)) {
64            return null;
65        }
66        $trimmed = trim($raw);
67        if ($trimmed === '') {
68            return null;
69        }
70        if (in_array($trimmed, ['0000-00-00', '0000/00/00', '0000-00-00 00:00:00'], true)) {
71            return null;
72        }
73
74        return $trimmed;
75    }
76
77    /**
78     * Synchronize budgets as of a specific date.
79     * It also manages the last synchronized ID.
80     *
81     * @param  string  $date  Default today's date
82     * @param  string  $name  Who's launch the function
83     */
84    public function syncByDate($date = null, $name = null, $region = 'Cataluña'): array
85    {
86        try {
87            if ($region === 'Catalunya') {
88                $region = 'Cataluña';
89            }
90
91            $g3wActive = TblCompanies::where('region', $region)->first()->g3W_active;
92
93            if (! $g3wActive) {
94                $startCronDateTime = date('Y-m-d H:i:s');
95                $this->updateLogs(['id' => 0, 'error' => 'La sincronización esta desactivada en la region '.$region.'.'], 0, [], $startCronDateTime, 'System', $region);
96                throw new Exception("La sincronización con G3W debe estar desactivada en la region '$region'.");
97            }
98
99            $this->setSyncStatus(1, $region);
100            $this->syncExistingDataWithWarnings();
101
102            $successfulSyncs = 0;
103            $failedSyncs = [];
104            $successIdSyncs = [];
105
106            $startCronDateTime = date('Y-m-d H:i:s');
107
108            $date ??= date('Y-m-d');
109
110            $budgets = $this->request('get', 'presupuesto?fecha='.$date, $region, []);
111
112            if (! $budgets) {
113                TblG3wLastUpdate::where('region', $region)->first()?->update(['updatingNow' => 0]);
114                $startCronDateTime = date('Y-m-d H:i:s');
115                $this->updateLogs(['id' => 0, 'error' => 'No hay presupuestos que sincronizar para la region '.$region.'.'], 0, [], $startCronDateTime, 'System', $region);
116
117                return [
118                    'success' => true,
119                    'message' => 'No budgets to upload.',
120                ];
121            }
122
123            if (is_string($budgets)) {
124                $budgets = json_decode($budgets, true);
125            }
126
127            if (! is_array($budgets) || empty($budgets)) {
128                throw new \Exception('No budgets to process.');
129            }
130
131            $company = TblCompanies::where('region', $region)->first();
132
133            foreach ($budgets as $budget) {
134                if (! is_array($budget) || ! isset($budget['ID'])) {
135                    continue;
136                }
137
138                if (
139                    TblQuotations::where('internal_quote_id', $budget['ID'])
140                        ->where('company_id', $company->company_id)
141                        ->exists()
142                ) {
143                    continue;
144                }
145
146                if (
147                    in_array($company->company_id, [18, 22])
148                &&
149                    TblQuotations::where('internal_quote_id', $budget['ID'])
150                        ->whereIn('company_id', [18, 22])
151                        ->exists()
152                ) {
153                    continue;
154                }
155
156                $result = DB::transaction(fn () => $this->syncById($budget['ID'], $region));
157
158                if ($result['success']) {
159                    $successfulSyncs++;
160                    $successIdSyncs[] = [
161                        'id' => $budget['ID'],
162                    ];
163                } else {
164                    if (! str_contains((string) $result['error'], 'No se ha encontrado el presupuesto')) {
165                        $failedSyncs[] = [
166                            'id' => $budget['ID'],
167                            'error' => $result['error'] ?? 'Unknown error',
168                        ];
169                    }
170                }
171            }
172
173            $ids = array_filter(array_column($budgets, 'ID'), is_numeric(...));
174            if (empty($ids)) {
175                throw new \Exception('No valid IDs found in budgets.');
176            }
177            $lastId = max($ids);
178
179            TblG3wLastUpdate::where('region', $region)->first()->update([
180                'g3w_id' => $lastId,
181                'updatingNow' => 0,
182            ]);
183
184            $lastUpdate = TblG3wLastUpdate::where('region', $region)->first();
185
186            $time = '';
187
188            if ($lastUpdate) {
189                $updatedAt = Carbon::parse($lastUpdate->updated_at)->subHour();
190                $time = $updatedAt->format('H:i');
191            }
192
193            $modifiedBudgets = (str_contains($time, ':')) ?
194                $this->request('get', 'presupuesto/fechamodificacion/'.$date.'/'.$time, $region, []) :
195                $this->request('get', 'presupuesto/fechamodificacion/'.$date, $region, []);
196
197            if (is_array($modifiedBudgets) && ! empty($modifiedBudgets)) {
198                foreach ($modifiedBudgets as $budget) {
199                    // FIRE-1029: catch QueryException at the loop level so
200                    // a single deadlock victim rolls back its own
201                    // transaction (now that syncModifiedBudgetById rethrows
202                    // DB faults) without aborting the whole sync run.
203                    try {
204                        $result = DB::transaction(fn () => $this->syncModifiedBudgetById($budget['ID'], $region));
205                    } catch (QueryException $qe) {
206                        $result = ['success' => false, 'error' => "Error actualizando el presupuesto {$budget['ID']}".$qe->getMessage()];
207                    }
208
209                    if ($result['success']) {
210                        $successfulSyncs++;
211                        $successIdSyncs[] = [
212                            'id' => $budget['ID'],
213                        ];
214                    } else {
215                        if ($result['error']) {
216                            if (! str_contains((string) $result['error'], 'No se encuentra el presupuesto con ID en G3W') &&
217                                ! str_contains((string) $result['error'], 'No se ha encontrado el presupuesto')) {
218                                $failedSyncs[] = [
219                                    'id' => $budget['ID'],
220                                    'error' => $result['error'] ?? 'Unknown error',
221                                ];
222                            }
223                        }
224                    }
225                }
226            }
227
228            // FIRE-1025: zero-date sweep moved to the daily
229            // `quotations:cleanup-zero-dates` artisan command. Pre-fix this
230            // UPDATE ran at the end of every G3W sync cycle (5 call sites
231            // here) and deadlocked on `idx_acceptance_date` when concurrent
232            // syncs piled up (see InnoDB MONITOR OUTPUT on 2026-04-27 with
233            // multiple "WE ROLL BACK TRANSACTION (1)" entries on this exact
234            // statement). FIRE-1001 normalises `aceptacion` on write so no
235            // new zero-dates can be created — the daily cleanup handles
236            // legacy rows.
237
238            $this->updateLogs($failedSyncs, $successfulSyncs, $successIdSyncs, $startCronDateTime, $name, $region);
239
240            // FIRE-1XXX: Refresh per-commercial counters after the batch sync.
241            // Manual acceptances trigger this via Quotations::update_quotation,
242            // but G3W-driven acceptances bypass that path — without this call,
243            // dashboards lag until the next manual touch on each commercial.
244            // Called once per region (per syncByDate run), not per row.
245            try {
246                $this->quotationsController->update_commercial_numbers($company->company_id);
247            } catch (\Throwable $countException) {
248                Log::channel('allInfoQuotationsG3w')->error(
249                    'G3W sync: update_commercial_numbers failed for company '.
250                    $company->company_id.': '.$countException->getMessage()
251                );
252            }
253
254            // FIRE-1145 follow-up: the sync just rewrote tbl_quotations rows.
255            // Without this, list_quotations + analytics caches stay stale for
256            // up to the 10-min TTL — commercials don't see freshly-synced data.
257            ResultCache::forgetDomain('quotations');
258
259            return [
260                'success' => true,
261                'message' => 'Synchronization completed.',
262            ];
263
264        } catch (\Exception $e) {
265            Log::channel('g3w')->error('Error sincronizando los presupuestos: '.$e->getMessage());
266
267            if (TblG3wLastUpdate::where('region', $region)->first()->updatingNow === 1) {
268                TblG3wLastUpdate::where('region', $region)->first()->update(['updatingNow' => 0]);
269
270            }
271
272            $startCronDateTime = date('Y-m-d H:i:s');
273            $this->updateLogs(['id' => 0, 'error' => $e->getMessage()], 0, [], $startCronDateTime, 'System', $region);
274
275            return ['success' => false, 'error' => $e->getMessage()];
276        }
277    }
278
279    /**
280     * FIRE-976: Fetch a budget from Gestiona and return normalized status + amount
281     * without creating/updating anything in Titan.
282     *
283     * @return array{success: bool, data?: array, error?: string}
284     */
285    public function fetchFromGestiona($id, string $region): array
286    {
287        try {
288            $budget = $this->request('get', "presupuesto/{$id}", $region, []);
289
290            if (! isset($budget['presupuesto']) || ! is_array($budget['presupuesto'])) {
291                return ['success' => false, 'error' => 'No se ha encontrado el presupuesto en Gestiona'];
292            }
293
294            $presupuesto = $budget['presupuesto'];
295
296            // Resolve status name from Gestiona status list
297            $statusList = $this->request('get', 'presupuesto/tiposestado', $region, []);
298            $nameStatus = collect($statusList)->firstWhere('ID', $presupuesto['estado'])['nombre'] ?? null;
299
300            // Normalize to FST status
301            if (! $nameStatus) {
302                $statusId = TblBudgetStatus::where('name', 'Sin estado en G3W')->first()->budget_status_id;
303            } else {
304                $statusId = $this->normalizeStatus($nameStatus);
305                if ($statusId === TblBudgetStatus::where('name', 'Sin estado en G3W')->first()->budget_status_id) {
306                    $statusId = TblBudgetStatus::where('name', 'Estado no reconocido en FST')->first()->budget_status_id;
307                }
308            }
309
310            $statusName = TblBudgetStatus::where('budget_status_id', $statusId)->value('name');
311
312            return [
313                'success' => true,
314                'data' => [
315                    'internal_quote_id' => $presupuesto['cod_presupuesto'],
316                    'amount' => $presupuesto['importe'] ?? null,
317                    'budget_status_id' => $statusId,
318                    'budget_status' => $statusName,
319                ],
320            ];
321        } catch (\Exception $e) {
322            return ['success' => false, 'error' => $e->getMessage()];
323        }
324    }
325
326    /**
327     * Synchronizes a budget by its ID.
328     *
329     * @param  $id  int ID to search in G3W
330     */
331    public function syncById($id, $region): array
332    {
333        try {
334            Log::channel('allInfoQuotationsG3w')->info("Sincronizando presupuesto {$id}");
335            $g3wWarning = 0;
336            $g3wWarningFields = null;
337            $budget = $this->request('get', "presupuesto/{$id}", $region, []);
338
339            if (! isset($budget['presupuesto']) || ! is_array($budget['presupuesto'])) {
340                Log::channel('allInfoQuotationsG3w')->info('El presupuesto no contiene los datos esperados.');
341                throw new \Exception('El presupuesto no contiene los datos esperados.');
342            }
343
344            $companyId = TblCompanies::where('region', $region)->first()->company_id;
345
346            $existsQuery = TblQuotations::where('internal_quote_id', $budget['presupuesto']['cod_presupuesto']);
347            if (in_array($companyId, [18, 22])) {
348                $existsQuery->whereIn('company_id', [18, 22]);
349            } else {
350                $existsQuery->where('company_id', $companyId);
351            }
352            if ($existsQuery->exists()) {
353                Log::channel('allInfoQuotationsG3w')->info('El presupuesto ya existe, procedemos a modificarlo.');
354                $resultEdit = $this->syncModifiedBudgetById($id, $region);
355
356                return $resultEdit;
357                Log::channel('allInfoQuotationsG3w')->info('El presupuesto ya existe, procedemos a modificarlo.');
358
359                return $this->syncModifiedBudgetById($id, $region);
360            }
361
362            $statusList = $this->request('get', 'presupuesto/tiposestado', $region, []);
363
364            $collection = collect($statusList);
365
366            Log::channel('allInfoQuotationsG3w')->info('Estado del presupuesto: '.$budget['presupuesto']['estado']);
367
368            $nameStatus = $collection->firstWhere('ID', $budget['presupuesto']['estado'])['nombre'] ?? null;
369
370            Log::channel('allInfoQuotationsG3w')->info('Estado del presupuesto en FST: '.$nameStatus);
371
372            if (! $nameStatus) {
373                $statusID = TblBudgetStatus::where('name', 'Sin estado en G3W')->first()->budget_status_id;
374            } else {
375                $statusID = $this->normalizeStatus($nameStatus);
376
377                if ($statusID === TblBudgetStatus::where('name', 'Sin estado en G3W')->first()->budget_status_id) {
378                    $statusID = TblBudgetStatus::where('name', 'Estado no reconocido en FST')->first()->budget_status_id;
379                }
380            }
381
382            Log::channel('allInfoQuotationsG3w')->info('ID del estado del presupuesto en FST: '.$statusID);
383
384            $company = $this->request('get', "servicio/{$budget['presupuesto']['cod_servicio']}", $region, []);
385
386            if (! $company || (! isset($company['servicio']) && ! isset($company['cliente']))) {
387                $company = $this->request('get', "cliente/{$budget['presupuesto']['cod_cliente']}", $region, []);
388            }
389
390            $companyName = $company['servicio']['nombre_servicio'] ?? $company['cliente']['empresa'] ?? null;
391            $companyTelephone = $company['servicio']['telefono'] ?? $company['cliente']['telefono'] ?? null;
392            $companyEmail = $company['servicio']['email'] ?? $company['cliente']['email'] ?? null;
393
394            Log::channel('allInfoQuotationsG3w')->info('Nombre de la empresa: '.$companyName);
395
396            if ($companyEmail) {
397                // Gestiona returns multiple addresses joined by ";" (and
398                // sometimes a trailing one with nothing after it). Normalize
399                // to comma-separated AND drop empty fragments — a trailing
400                // ";" was leaving us with "foo@bar.com," which downstream
401                // explode(',', email) turned into an empty slot, flagging
402                // the quote in "Emails con error" with no real bounce.
403                $parts = preg_split('/\s*[;,]\s*/', (string) $companyEmail, -1, PREG_SPLIT_NO_EMPTY);
404                $companyEmail = $parts ? implode(',', $parts) : null;
405            }
406
407            Log::channel('allInfoQuotationsG3w')->info('Email de la empresa: '.$companyEmail);
408
409            if (! $companyName || ($budget['presupuesto']['cod_cliente'] === 9818 && in_array($companyId, [18, 22]))) {
410                $companyName = $this->getAlternativeClientData($budget['presupuesto']['datos_cliente_alternativo'])['name'];
411            }
412
413            if (! $companyTelephone || ($budget['presupuesto']['cod_cliente'] === 9818 && in_array($companyId, [18, 22]))) {
414                $companyTelephone = $this->getAlternativeClientData($budget['presupuesto']['datos_cliente_alternativo'])['number'];
415            }
416
417            if (! $companyEmail || ($budget['presupuesto']['cod_cliente'] === 9818 && in_array($companyId, [18, 22]))) {
418                $companyEmail = $this->getAlternativeClientData($budget['presupuesto']['datos_cliente_alternativo'])['email'];
419            }
420
421            if ($statusID === 2 && $this->checkEmailInvalid((string) $companyEmail)) {
422                Log::channel('allInfoQuotationsG3w')->info('FIRE-864: G3W "Enviado" en creación con email inválido ('.$companyEmail.') → Correo erroneo (22).');
423                $statusID = 22;
424            }
425
426            Log::channel('allInfoQuotationsG3w')->info('Nombre de la empresa tras alternative client data: '.$companyName);
427
428            $segmentID = $this->normalizeSegment($company['servicio']['tipo_servicio'] ?? 'Otro');
429
430            Log::channel('allInfoQuotationsG3w')->info('Segmento de la empresa: '.$segmentID);
431
432            $companyId = TblCompanies::where('region', $region)->first()->company_id;
433
434            if (! isset($budget['presupuesto']['documento']) || empty($budget['presupuesto']['documento'])) {
435                Log::channel('allInfoQuotationsG3w')->info('El presupuesto no tiene documento asociado.');
436                throw new \Exception('El presupuesto no tiene documento asociado. Creelo y vuelva a intentarlo.');
437            }
438
439            $companyNameFormatted = '';
440
441            if (! empty($companyName)) {
442                $companyNameFormatted = str_replace(' ', '_', $companyName);
443            }
444
445            $nameDocument = $companyNameFormatted
446                ? $budget['presupuesto']['cod_presupuesto'].'_'.$companyNameFormatted
447                : $budget['presupuesto']['cod_presupuesto'];
448
449            Log::channel('allInfoQuotationsG3w')->info('Nombre del documento: '.$nameDocument);
450
451            $typeId = $this->normalizeType($budget['presupuesto']['origen_presupuesto'] ?? null, $region);
452
453            Log::channel('allInfoQuotationsG3w')->info('Tipo de presupuesto: '.$typeId);
454
455            if (empty($budget['presupuesto']['fecha_creacion'])) {
456                $budget['presupuesto']['fecha_creacion'] = Carbon::now()->format('Y-m-d H:i:s');
457            }
458
459            $work = $this->request('get', "presupuesto/trabajos/{$id}", $region, []);
460
461            $workIds = [];
462
463            if (is_array($work)) {
464                foreach ($work as $item) {
465                    if (isset($item['ID'])) {
466                        $workIds[] = $item['ID'];
467                    }
468                }
469            }
470
471            $idsConcatenados = implode('/', $workIds);
472
473            Log::channel('allInfoQuotationsG3w')->info('Trabajos: '.$idsConcatenados);
474
475            $createdByUser = TblUserG3wMapping::where('name_g3w', $budget['presupuesto']['usuario'])->first();
476            $createdBy = null;
477            $comercial = null;
478
479            if (! $createdByUser) {
480                TblUserG3wMapping::create([
481                    'name_g3w' => $budget['presupuesto']['usuario'] ?? null,
482                ]);
483                // FIRE-1110: do NOT store the raw G3W username as the
484                // commercial. The frontend ng-select can't bind to a
485                // value that isn't in userNamesList, so the form control
486                // resolves to JS `undefined`, and a save round-trip
487                // corrupts the row to the literal string "undefined".
488                // Leaving commercial NULL keeps it clean — the order
489                // form falls back to user_create_by_g3w / user_commercial_by_g3w
490                // for display, and saves don't overwrite an already-NULL
491                // commercial with a broken value. The raw G3W username is
492                // preserved in user_create_by_g3w / user_commercial_by_g3w
493                // for traceability and for the future mapping that resolves it.
494                $g3wWarning = 1;
495            } else {
496                $createdBy = TblUsers::where('id', $createdByUser->id_fst)->value('name')
497                    ?? null;
498
499                $comercial = $createdBy;
500            }
501
502            Log::channel('allInfoQuotationsG3w')->info('Comercial: '.$comercial);
503
504            $comercialUser = TblUserG3wMapping::where('name_g3w', $budget['presupuesto']['cod_comercial_presupuesto'])->first();
505
506            if (! $comercialUser && $budget['presupuesto']['cod_comercial_presupuesto']) {
507                TblUserG3wMapping::create([
508                    'name_g3w' => $budget['presupuesto']['cod_comercial_presupuesto'] ?? null,
509                ]);
510            } elseif ($comercialUser) {
511                // FIRE-1110: only override the commercial when the mapping
512                // actually resolves to a real FST user. A placeholder
513                // mapping (row exists but id_fst IS NULL — created by a
514                // previous sync that hadn't yet been matched by a human)
515                // used to overwrite an already-good $comercial (the
516                // mapped creator name from the block above) with NULL.
517                // That's how quote 19713 ended up with commercial=NULL
518                // even though created_by was correctly "Marta Valderrama".
519                $resolvedCommercial = TblUsers::where('id', $comercialUser->id_fst)->value('name');
520                if ($resolvedCommercial !== null) {
521                    $comercial = $resolvedCommercial;
522                }
523                // else: keep $comercial as set by the createdByUser block.
524            }
525
526            // Final safety net: per Berta's rule "if G3W doesn't assign a
527            // commercial, use the created_by user as commercial". Catches
528            // the case where cod_comercial_presupuesto is empty/null AND
529            // the elseif above didn't fire — $comercial may still be
530            // NULL if usuario also had no mapping, but if usuario DID
531            // resolve we want $createdBy to win as the commercial.
532            if ($comercial === null && $createdBy !== null) {
533                $comercial = $createdBy;
534            }
535
536            Log::channel('allInfoQuotationsG3w')->info('Comercial limpio: '.$comercial);
537
538            $source = $budget['presupuesto']['cod_empresa_presupuesto'] ?? null;
539            $sourceId = ($source) ? $this->normalizeSource($source, $region) : null;
540
541            // Per-user source overrides: certain G3W users aren't real
542            // commercials but mark the budget's source. "dig" routes to
543            // Diggening (source 73) and "flu" routes to Fluency Labs
544            // (source 72). The check runs after normalizeSource() so it
545            // wins over whatever cod_empresa_presupuesto resolved to.
546            $usuario = strtolower(trim((string) ($budget['presupuesto']['usuario'] ?? '')));
547            if ($usuario === 'dig') {
548                $sourceId = 73;
549            } elseif ($usuario === 'flu') {
550                $sourceId = 72;
551            }
552
553            Log::channel('allInfoQuotationsG3w')->info('Source: '.$source.' (sourceId='.$sourceId.', usuario='.$usuario.')');
554
555            if ($statusID == 12) {
556                $companyLimit = TblCompanies::where('company_id', $companyId)->first();
557                $inProgressCount = TblQuotations::where('budget_status_id', 12)->where('company_id', $companyId)->where('commercial', $comercial)->count();
558                if ($companyLimit->process_limit <= $inProgressCount) {
559                    Log::channel('allInfoQuotationsG3w')->info("Se ha alcanzado el número máximo de pedidos en curso ({$companyLimit->process_limit}) permitido por usuario para esta empresa.");
560                    throw new \Exception("No se pudo guardar el documento del presupuesto {$id} al crearlo. Error: Se ha alcanzado el número máximo de pedidos en curso (5) permitido por usuario para esta empresa.");
561                }
562            }
563
564            // Jomar
565            if ($companyId == 18 && $source == 9) {
566                $companyId = 22;
567            }
568
569            $lastFollowUp = TblLastFollowUpDate::where('company_id', $companyId)->where('budget_type_id', $typeId)->first();
570            $workingDays = 10;
571            if ($lastFollowUp && $lastFollowUp->last_follow_up_date) {
572                $workingDays = $lastFollowUp->last_follow_up_date;
573            }
574
575            $date = Carbon::now();
576
577            $daysAdded = 0;
578            while ($daysAdded < $workingDays) {
579                $date->addDay();
580                if ($date->isWeekday()) {
581                    $daysAdded++;
582                }
583            }
584
585            $lastFollowUpDate = $date;
586
587            if (
588                in_array($statusID, [13, 14]) ||
589                ! $sourceId ||
590                (! $typeId || $typeId == 0 || $typeId == 16) ||
591                empty(trim((string) $companyName)) ||
592                empty(trim((string) $companyEmail))
593                // || $this->checkEmailInvalid($companyEmail)
594            ) {
595                $g3wWarning = 1;
596            }
597
598            $row = (object) [
599                'client' => $companyName,
600                'email' => $companyEmail,
601                'budget_status_id' => $statusID,
602                'commercial' => $comercial,
603                'source_id' => $sourceId,
604                'budget_type_id' => $typeId,
605                'amount' => $budget['presupuesto']['importe'] ?? null,
606            ];
607
608            $g3wWarningFields = $this->checkRequiredFields($row);
609
610            if ($g3wWarningFields != null && $g3wWarningFields != '') {
611                $g3wWarning = 1;
612            }
613
614            if ($g3wWarningFields !== null && str_contains($g3wWarningFields, 'Email')) {
615                $statusID = 22;
616            }
617
618            Log::channel('allInfoQuotationsG3w')->info('G3W warning: '.$g3wWarning);
619
620            $reasonForNotFollowingUp = null;
621
622            // iker chacori and juan carlos
623            $excludingUsers = ['igc', '030', '031', 'fcl', '018', '021', 'jcsp', '026', '029', 'EXMOF', 'MGF'];
624
625            $facilityUser = false;
626
627            if (
628                (in_array($budget['presupuesto']['usuario'], $excludingUsers) || in_array($budget['presupuesto']['cod_comercial_presupuesto'], $excludingUsers))
629                && $region == 'Cataluña'
630            ) {
631                $reasonForNotFollowingUp = 1;
632                $facilityUser = true;
633            }
634
635            if ($segmentID == 3) {
636                $reasonForNotFollowingUp = 2;
637            } elseif ($segmentID == 2) {
638                $reasonForNotFollowingUp = 1;
639            }
640
641            $client = $this->request('get', "cliente/{$budget['presupuesto']['cod_cliente']}", $region, []);
642
643            // Guard against missing `cliente` key — G3W sometimes returns a
644            // response without it (empty cod_cliente, deleted client, or
645            // unexpected error shape). Pre-fix this threw
646            // "Undefined array key 'cliente'" and the whole sync 500'd.
647            $tipoCliente = $client['cliente']['tipo_cliente'] ?? null;
648            $clienteCif = $client['cliente']['cliente_cif'] ?? null;
649
650            if (
651                $tipoCliente === 'Grandes Cuentas' ||
652                $tipoCliente === 'Grandes Clientes' ||
653                ($clienteCif !== null && TblInvoicesExceptions::where('cif', $clienteCif)->exists())
654            ) {
655                $reasonForNotFollowingUp = 2;
656            }
657
658            $isVip = false;
659
660            $cData = $client['cliente'] ?? [];
661            $isVip = TblVipClients::whereIn('company_id', [$companyId, 0])
662                ->where(function ($query) use ($cData) {
663                    $query->whereRaw('1 = 0');
664                    if (! empty($cData['cod_cliente'])) {
665                        $query->orWhere('id_client', $cData['cod_cliente']);
666                    }
667                    if (! empty($cData['empresa'])) {
668                        $query->orWhere('name', $cData['empresa']);
669                    }
670                    if (! empty($cData['cliente_cif'])) {
671                        $query->orWhere('cif', $cData['cliente_cif']);
672                    }
673                    if (! empty($cData['email'])) {
674                        $query->orWhere('email', $cData['email']);
675                    }
676                    if (! empty($cData['telefono'])) {
677                        $query->orWhere('phone', $cData['telefono']);
678                    }
679                })
680                ->exists();
681
682            if (! $isVip) {
683                $sData = $company['servicio'] ?? [];
684                $isVip = TblVipClients::whereIn('company_id', [$companyId, 0])
685                    ->where(function ($query) use ($sData) {
686                        $query->whereRaw('1 = 0');
687                        if (! empty($sData['cod_servicio'])) {
688                            $query->orWhere('id_client', $sData['cod_servicio']);
689                        }
690                        if (! empty($sData['nombre_servicio'])) {
691                            $query->orWhere('name', $sData['nombre_servicio']);
692                        }
693                        if (! empty($sData['servicio_cif'])) {
694                            $query->orWhere('cif', $sData['servicio_cif']);
695                        }
696                        if (! empty($sData['email'])) {
697                            $query->orWhere('email', $sData['email']);
698                        }
699                        if (! empty($sData['telefono'])) {
700                            $query->orWhere('phone', $sData['telefono']);
701                        }
702                    })
703                    ->exists();
704            }
705
706            if ($isVip) {
707                $reasonForNotFollowingUp = 2;
708            }
709
710            Log::channel('allInfoQuotationsG3w')->info('Reason for not following up: '.$reasonForNotFollowingUp);
711
712            $defaultUser = 0;
713
714            if (! $comercialUser) {
715                Log::channel('allInfoQuotationsG3w')->info('Default user');
716                if ($companyId == 18) {
717                    $defaultUser = 94;
718                }
719
720                if ($companyId == 19) {
721                    $defaultUser = 68;
722                }
723
724                if ($companyId == 22) {
725                    $defaultUser = 153;
726                }
727
728                if ($companyId == 30) {
729                    $defaultUser = 124;
730                }
731                Log::channel('allInfoQuotationsG3w')->info('Default user: '.$defaultUser);
732            }
733
734            if (config('app.env') === 'local') {
735                if ($defaultUser == 0) {
736                    $defaultUser = 92;
737                }
738            }
739
740            $generateNumber = $this->generateQuoteId($companyId);
741            Log::channel('allInfoQuotationsG3w')->info('Generate number: '.$generateNumber['id']);
742
743            $g3wNewId = $generateNumber['id'];
744            $newQuoteId = $generateNumber['number'];
745
746            $forApproval = self::checkAproval($companyId, $typeId, 2, $budget['presupuesto']['importe'], $newQuoteId, $g3wNewId, $comercialUser?->id_fst ?? $defaultUser, $comercial);
747
748            Log::channel('allInfoQuotationsG3w')->info('For approval: '.$forApproval);
749
750            // FIRE-1024: normalise once and reuse across acceptance_date /
751            // accepted_at / accepted_by. The pre-fix inline ternary only
752            // caught '0000-00-00' and '0000/00/00' — empty string and the
753            // datetime form '0000-00-00 00:00:00' both leaked through.
754            $g3wAcceptance = $this->normalizeG3wAcceptance($budget['presupuesto']['aceptacion'] ?? null);
755
756            // If G3W returns the "unmapped" sentinel (16), fall back to any
757            // existing tbl_quotations row that already carries a
758            // budget_type_id for this internal_quote_id (defensive against
759            // TOCTOU / re-import races where the existsQuery check above
760            // missed a sibling row). Normal create path: returns null when
761            // no prior row exists, which is the correct default.
762            $preservedTypeId = $typeId == 16
763                ? TblQuotations::where('internal_quote_id', $budget['presupuesto']['cod_presupuesto'])->value('budget_type_id')
764                : $typeId;
765
766            $g3wArray = [
767                'internal_quote_id' => $budget['presupuesto']['cod_presupuesto'],
768                'quote_id' => $newQuoteId,
769                'company_id' => $companyId,
770                'customer_type_id' => 2,
771                'segment_id' => $segmentID,
772                'budget_type_id' => $preservedTypeId,
773                'budget_status_id' => $statusID,
774                'source_id' => $sourceId,
775                'client' => $companyName,
776                'phone_number' => $companyTelephone,
777                'email' => $companyEmail,
778                // FIRE-1001: G3W returns '0000-00-00' (dashes) for an absent
779                // date; older payloads used slashes — accept both. Same
780                // rationale as the update branch.
781                'issue_date' => in_array(($budget['presupuesto']['fecha_creacion'] ?? null), ['0000-00-00', '0000/00/00'], true) ? null : ($budget['presupuesto']['fecha_creacion'] ?? null),
782                'request_date' => in_array(($budget['presupuesto']['fecha_creacion'] ?? null), ['0000-00-00', '0000/00/00'], true) ? null : ($budget['presupuesto']['fecha_creacion'] ?? null),
783                // FIRE-1001 + FIRE-1024: pull acceptance date from G3W's
784                // `aceptacion` field, normalised via
785                // `normalizeG3wAcceptance()` so every empty representation
786                // (null, '', '0000-00-00', '0000/00/00',
787                // '0000-00-00 00:00:00', whitespace) lands as PHP null.
788                // accepted_by is only stamped when there's a real date —
789                // "System accepted on (no date)" is meaningless.
790                'acceptance_date' => $g3wAcceptance,
791                'accepted_at' => ($nameStatus === 'Aceptado') ? $g3wAcceptance : null,
792                'accepted_by' => ($nameStatus === 'Aceptado' && $g3wAcceptance !== null) ? 'System' : null,
793                'amount' => $budget['presupuesto']['importe'] ?? null,
794                'last_follow_up_date' => $facilityUser ? null : $lastFollowUpDate,
795                'commercial' => $comercial,
796                'created_at' => $budget['presupuesto']['fecha_creacion'] ?? null,
797                'created_by' => $createdBy,
798                'has_attachment' => 1,
799                'cost_of_labor' => 0,
800                'total_cost_of_job' => 0,
801                'invoice_margin' => 0,
802                'margin_for_the_company' => 0,
803                'revenue_per_date_per_worked' => 0,
804                'gross_margin' => 100,
805                'labor_percentage' => 0,
806                'sync_import' => 1,
807                'box_work_g3w' => $idsConcatenados,
808                'segment_by_g3w' => $company['servicio']['tipo_servicio'] ?? null,
809                'source_by_g3w' => $budget['presupuesto']['cod_empresa_presupuesto'] ?? null,
810                'status_by_g3w' => $nameStatus,
811                'type_by_g3w' => $budget['presupuesto']['origen_presupuesto'] ?? null,
812                'user_create_by_g3w' => $budget['presupuesto']['usuario'],
813                'user_commercial_by_g3w' => ! empty($budget['presupuesto']['cod_comercial_presupuesto']) ? $budget['presupuesto']['cod_comercial_presupuesto'] : null,
814                'g3w_warning' => $g3wWarning,
815                'g3w_warning_fields' => $g3wWarningFields,
816                'reason_for_not_following_up_id' => $reasonForNotFollowingUp,
817                'for_add' => 0,
818                'for_approval' => $forApproval,
819                // G3W only ever flags order-size approval, which is always
820                // technical — mirror it so the orders grid can show the type.
821                // (computeForApprovalLevel returns 1 or null; never the
822                // financial/margin (2) or dual (3) levels.)
823                'approval_type' => $forApproval,
824            ];
825
826            TblQuotations::where('id', $g3wNewId)->update($g3wArray);
827
828            $this->quotationsController->addUpdateLog($g3wNewId, 'G3W', null, null, null, 2);
829
830            Log::channel('allInfoQuotationsG3w')->info('Pasamos el insert.');
831
832            $companyNameFormatted = '';
833
834            if (! empty($companyName)) {
835                $companyNameFormatted = str_replace(' ', '_', $companyName);
836            }
837
838            $nameDocument = $companyNameFormatted
839                ? $budget['presupuesto']['cod_presupuesto'].'_'.$companyNameFormatted
840                : $budget['presupuesto']['cod_presupuesto'];
841
842            Log::channel('allInfoQuotationsG3w')->info('Nombre del documento: '.$nameDocument);
843
844            $responseSaveDocument = $this->saveDocument($budget['presupuesto']['documento'], $nameDocument, $g3wNewId, $newQuoteId, 'G3W');
845            $responseDataSaveDocument = $responseSaveDocument->getData();
846
847            if (! isset($responseDataSaveDocument->success) || ! $responseDataSaveDocument->success) {
848                Log::channel('allInfoQuotationsG3w')->info("No se pudo guardar el documento del presupuesto {$id} al crearlo. Error: ".($responseDataSaveDocument->error ?? 'Desconocido'));
849                throw new \Exception("No se pudo guardar el documento del presupuesto {$id} al crearlo. Error: ".($responseDataSaveDocument->error ?? 'Desconocido'));
850            }
851
852            /** @phpstan-ignore-next-line */
853            $documentName = $responseDataSaveDocument->documentName;
854
855            // FIRE-1025: zero-date sweep moved to the daily
856            // `quotations:cleanup-zero-dates` artisan command. Pre-fix this
857            // UPDATE ran at the end of every G3W sync cycle (5 call sites
858            // here) and deadlocked on `idx_acceptance_date` when concurrent
859            // syncs piled up (see InnoDB MONITOR OUTPUT on 2026-04-27 with
860            // multiple "WE ROLL BACK TRANSACTION (1)" entries on this exact
861            // statement). FIRE-1001 normalises `aceptacion` on write so no
862            // new zero-dates can be created — the daily cleanup handles
863            // legacy rows.
864
865            // FIRE-1145 follow-up: syncById just inserted/updated a quotation
866            // row in the DB — forget the quotations bucket so the next read
867            // doesn't serve a 10-min-stale list.
868            ResultCache::forgetDomain('quotations');
869
870            return [
871                'success' => true,
872                'id' => $g3wNewId,
873            ];
874
875        } catch (\Exception $e) {
876            $errorMessage = $e->getMessage();
877
878            return ['success' => false, 'error' => "Error sincronizando el presupuesto {$id}".$errorMessage];
879        }
880    }
881
882    public function syncModifiedBudgetById(string $id, $region = null): array
883    {
884        try {
885            Log::channel('allInfoQuotationsG3w')->info('Modificando presupuesto: '.$id);
886            $statusID = null;
887            $materialId = [
888                'Cataluña' => [102561, 102562, 102568, 102569],
889                'Madrid' => [562, 100026, 100027],
890                'Comunidad Valenciana' => [562],
891            ];
892
893            $statusToChange = [
894                'Enviado',
895                'Aceptado',
896                'Rechazado',
897                'Anulado',
898            ];
899
900            $g3wWarning = 0;
901            $g3wWarningFields = null;
902            $companyId = TblCompanies::where('region', $region)->first()->company_id;
903
904            $budget = $this->request('get', "presupuesto/{$id}", $region, []);
905
906            $existsQuery = TblQuotations::where('internal_quote_id', $budget['presupuesto']['cod_presupuesto']);
907            if (in_array($companyId, [18, 22])) {
908                $existsQuery->whereIn('company_id', [18, 22]);
909            } else {
910                $existsQuery->where('company_id', $companyId);
911            }
912            if (! $existsQuery->exists()) {
913                Log::channel('allInfoQuotationsG3w')->info("No se encuentra el presupuesto con ID en G3W $id. Es posible que sea debido a que se creo antes de la integracion de FST con G3W.");
914                throw new \Exception("No se encuentra el presupuesto con ID en G3W $id. Es posible que sea debido a que se creo antes de la integracion de FST con G3W.");
915            }
916
917            $company = $this->request('get', "servicio/{$budget['presupuesto']['cod_servicio']}", $region, []);
918
919            $companyName = $company['servicio']['nombre_servicio'] ?? $company['cliente']['empresa'] ?? null;
920
921            // $company = $this->request('get', "servicio/{$budget["presupuesto"]["cod_servicio"]}", $region, []);
922
923            /*if(!$company || !isset($company['servicio'])){
924                $company = $this->request('get', "cliente/{$budget["presupuesto"]["cod_cliente"]}", $region, []);
925            }*/
926
927            // $companyName = (isset($company['servicio'])) ? $company["servicio"]["nombre_servicio"] : $company["cliente"]["empresa"];
928            // $companyTelephone = (isset($company['servicio'])) ? $company["servicio"]["telefono"] : $company["cliente"]["telefono"] ;
929
930            // $segmentID = $this->normalizeSegment($company["servicio"]["tipo_servicio"]?? "Otro");
931
932            if (! isset($budget['presupuesto']['documento']) || ! $budget['presupuesto']['documento']) {
933                Log::channel('allInfoQuotationsG3w')->info('El presupuesto no tiene documento asociado. Creelo y vuelva a intentarlo.');
934                throw new \Exception('El presupuesto no tiene documento asociado. Creelo y vuelva a intentarlo.');
935            }
936
937            $typeId = $this->normalizeType($budget['presupuesto']['origen_presupuesto'], $region);
938
939            Log::channel('allInfoQuotationsG3w')->info('Tipo de presupuesto: '.$typeId);
940
941            $statusList = $this->request('get', 'presupuesto/tiposestado', $region, []);
942
943            $collection = collect($statusList);
944
945            $nameStatus = $collection->firstWhere('ID', $budget['presupuesto']['estado'])['nombre'] ?? null;
946
947            Log::channel('allInfoQuotationsG3w')->info('Estado de presupuesto: '.$nameStatus);
948
949            if (! $nameStatus) {
950                $statusID = TblBudgetStatus::where('name', 'Sin estado en G3W')->first()->budget_status_id;
951            } else {
952                $statusID = $this->normalizeStatus($nameStatus);
953
954                if ($statusID === TblBudgetStatus::where('name', 'Sin estado en G3W')->first()->budget_status_id) {
955                    $statusID = TblBudgetStatus::where('name', 'Estado no reconocido en FST')->first()->budget_status_id;
956                }
957            }
958
959            Log::channel('allInfoQuotationsG3w')->info('ID de estado: '.$statusID);
960
961            $rowQuery = TblQuotations::where('internal_quote_id', $budget['presupuesto']['cod_presupuesto']);
962            if (in_array($companyId, [18, 22])) {
963                $rowQuery->whereIn('company_id', [18, 22]);
964            } else {
965                $rowQuery->where('company_id', $companyId);
966            }
967            $row = $rowQuery->first();
968
969            if ($row) {
970                // FIRE-864 (c): G3W says "Enviado" (2) but Titan has no record
971                // of sending (no x_message_id). Decision by email validity:
972                //   - email valid    → keep "Enviado" (2). We can't verify
973                //                      Titan-side, but the address is plausible.
974                //   - email invalid  → "Correo erroneo" (22). G3W claims sent
975                //                      but the address can't actually receive.
976                // If Titan HAS sent it (x_message_id set), leave 2 — the
977                // SendGrid webhook/backfill will flip to 22 on bounce anyway.
978                if ($statusID === 2 && empty($row->x_message_id) && $this->checkEmailInvalid((string) $row->email)) {
979                    Log::channel('allInfoQuotationsG3w')->info("FIRE-864: G3W Enviado en edición sin envío Titan y email inválido (quote {$row->id}, email {$row->email}) → Correo erroneo (22).");
980                    $statusID = 22;
981                }
982
983                if (
984                    in_array($row->budget_status_id, [13, 14]) ||
985                    ! $row->source_id ||
986                    (! $typeId || $typeId == 0 || $typeId == 16) ||
987                    empty(trim((string) $row->client)) || // change for $companyName when we implement
988                    empty(trim((string) $row->email))
989                    // || $this->checkEmailInvalid($row->email)
990                ) {
991                    $g3wWarning = 1;
992                }
993
994                $g3wWarningFields = $this->checkRequiredFields($row);
995
996                if ($g3wWarningFields != null && $g3wWarningFields != '') {
997                    $g3wWarning = 1;
998                }
999
1000                // FIRE-1001 + FIRE-1024: G3W is the source of truth for the
1001                // acceptance date on G3W-imported quotations. The original
1002                // FIRE-1001 logic preserved Titan's previous value when G3W
1003                // came back empty ("Titan remembers what G3W forgot"), but
1004                // Berta filed FIRE-1024 because that hides intentional
1005                // un-acceptances done in G3W: the user clears `aceptacion`
1006                // upstream and Titan keeps showing the old date.
1007                //
1008                // Reversal: always mirror G3W. If G3W is empty, Titan goes
1009                // null. If status moved away from Aceptado, accepted_at also
1010                // goes null (the row is no longer accepted, so the audit
1011                // metadata shouldn't claim otherwise).
1012                //
1013                // The audit log entries at addUpdateLog() below (and the
1014                // FIRE-982 audit infra) capture the before/after, so any
1015                // unintended clearing is recoverable.
1016                $g3wAcceptanceDate = $this->normalizeG3wAcceptance($budget['presupuesto']['aceptacion'] ?? null);
1017                $newAcceptanceDate = $g3wAcceptanceDate;
1018                $newAcceptedAt = ($nameStatus === 'Aceptado') ? $g3wAcceptanceDate : null;
1019
1020                Log::channel('allInfoQuotationsG3w')->info('G3W Warning: '.$g3wWarning);
1021
1022                if ($row->budget_type_id !== $typeId) {
1023                    $this->quotationsController->addUpdateLog($row->id, 'G3W', 'budget_type_id', $row->budget_type_id, $typeId, 4);
1024                }
1025
1026                if (in_array($nameStatus, $statusToChange) || $row->budget_status_id == 6) {
1027                    $this->quotationsController->addUpdateLog($row->id, 'G3W', 'budget_status_id', $row->budget_status_id, $statusID, 4);
1028                }
1029
1030                if ($row->amount !== $budget['presupuesto']['importe']) {
1031                    $this->quotationsController->addUpdateLog($row->id, 'G3W', 'amount', $row->amount, $budget['presupuesto']['importe'], 4);
1032                }
1033
1034                if ($row->updated_by !== 'System') {
1035                    $this->quotationsController->addUpdateLog($row->id, 'G3W', 'updated_by', $row->updated_by, 'G3W', 4);
1036                }
1037
1038                if ($row->has_attachment !== 1) {
1039                    $this->quotationsController->addUpdateLog($row->id, 'G3W', 'has_attachment', $row->has_attachment, 1, 4);
1040                }
1041
1042                if ($row->sync_import_edited !== 1) {
1043                    $this->quotationsController->addUpdateLog($row->id, 'G3W', 'sync_import_edited', $row->sync_import_edited, 1, 4);
1044                }
1045
1046                if ($nameStatus === 'Aceptado') {
1047                    $this->quotationsController->addUpdateLog($row->id, 'G3W', null, $row->acceptance_date, $newAcceptanceDate, 4);
1048                    $this->quotationsController->addUpdateLog($row->id, 'G3W', null, $row->accepted_at, $newAcceptedAt, 4);
1049                    $this->quotationsController->addUpdateLog($row->id, 'G3W', null, $row->accepted_by, 'G3W', 4);
1050                }
1051
1052                Log::channel('allInfoQuotationsG3w')->info('Pasamos el add update log');
1053
1054                // FIRE-1001: classify each G3W line as labor or material.
1055                // Logic lives in a static helper so it can be unit-tested
1056                // without spinning up the full sync. See
1057                // `classifyLineAsLabor` below for the rationale.
1058                $totalCostOfMaterial = 0;
1059                $totalLabor = 0;
1060                foreach ($budget['presupuesto']['lineas'] as $linea) {
1061                    $precioCompra = (float) ($linea['precio_compra'] ?? 0);
1062                    $unidades = (float) ($linea['unidades'] ?? 0);
1063
1064                    if (self::classifyLineAsLabor($linea, $materialId[$region] ?? [])) {
1065                        $totalLabor += $precioCompra * $unidades;
1066                    } else {
1067                        $totalCostOfMaterial += $precioCompra * $unidades;
1068                    }
1069                }
1070
1071                $companyInfo = TblCompanies::where('region', $region)->first();
1072
1073                $hoursPerDay = (float) ($companyInfo->hours_per_worker_per_day ?? 0);
1074                $costOfHour = (float) ($companyInfo->cost_of_hour ?? 0);
1075                $dailyRate = $hoursPerDay * $costOfHour;
1076                $numberOfDays = $dailyRate > 0 ? $totalLabor / $dailyRate : 0;
1077
1078                // FIRE-1001: don't clobber user-edited people/days on every
1079                // re-sync. Sync seeds defaults only when the row hasn't been
1080                // configured yet — once Berta has set "2 técnicos × 20 días"
1081                // we keep them. (`calculateBudgetMargin` then derives
1082                // `cost_of_labor` from those preserved values, and the
1083                // frontend recompute on save stays consistent.)
1084                $peopleAssigned = (int) ($row->people_assigned_to_the_job ?? 0) > 0
1085                    ? (int) $row->people_assigned_to_the_job
1086                    : 1;
1087                $durationInDays = (float) ($row->duration_of_job_in_days ?? 0) > 0
1088                    ? (float) $row->duration_of_job_in_days
1089                    : $numberOfDays;
1090
1091                $resultMargin = $this->calculateBudgetMargin(
1092                    $budget['presupuesto']['importe'] ?? 0,
1093                    $row->commission_pct ?? 0,
1094                    $peopleAssigned,
1095                    $durationInDays,
1096                    $totalCostOfMaterial,
1097                    $hoursPerDay,
1098                    $costOfHour,
1099                    $row->segment_id ?? 0,
1100                    $companyInfo->general_costs ?? 0
1101                );
1102
1103                Log::channel('allInfoQuotationsG3w')->info('Pasamos el calculo de margen.');
1104
1105                $forApproval = $row->for_approval;
1106                // Preserve the existing approval_type by default so a sync
1107                // that doesn't genuinely re-flag (no amount/type change)
1108                // can't clobber a manually-set financial (2) or dual (3)
1109                // type back to technical. Only reassigned on a real re-flag.
1110                $approvalType = $row->approval_type;
1111                $clearPriorApproval = false;
1112
1113                $isWorkAccepted = false;
1114
1115                try {
1116                    $work = $this->request('get', "presupuesto/trabajos/{$id}", $region, []);
1117
1118                    if (! empty($work)) {
1119                        $dataToSend = [
1120                            'ids' => array_column($work, 'ID'),
1121                        ];
1122
1123                        $worksStatus = $this->request('post', 'trabajo/estados', $region, $dataToSend);
1124
1125                        if (is_array($worksStatus)) {
1126                            foreach ($worksStatus as $item) {
1127                                if (($item['estado'] ?? null) === 'Aceptado') {
1128                                    $isWorkAccepted = true;
1129                                    break;
1130                                }
1131                            }
1132                        }
1133                    }
1134                } catch (\Exception $worksErr) {
1135                    // G3W's trabajo/estados endpoint is known to return raw SQL
1136                    // in the error body for some quotes (their internal query
1137                    // is malformed). Treat as "no accepted works" and continue
1138                    // the sync so the quotation still updates.
1139                    Log::channel('allInfoQuotationsG3w')->warning(
1140                        "G3W trabajos/estados falló para presupuesto {$id} (region {$region}). Se continúa con isWorkAccepted=false. Error: ".$worksErr->getMessage()
1141                    );
1142                }
1143
1144                // FIRE-1092: detect approval-input changes reliably and
1145                // only re-flag when the recomputed level differs from the
1146                // current one. Two defects fixed here vs. the old code:
1147                //   1. The change-check used strict `!==` between an
1148                //      Eloquent decimal (string "1500.0000") and the G3W
1149                //      JSON value (float 1500.0), so it tripped on every
1150                //      sync — re-flagging approval and re-sending the
1151                //      approval email even when nothing changed.
1152                //   2. Inside the block, `for_approval` was overwritten
1153                //      unconditionally — including with the same value —
1154                //      and `approved_at`/`approved_by` were never cleared
1155                //      on a genuine re-flag, leaving inconsistent state.
1156                //
1157                // Note on `customer_type_id`: the original code had a
1158                // commented-out comparison for it. We leave it out: G3W
1159                // does not push customer_type_id (it's an FST-owned
1160                // field set by the user), so it never changes via this
1161                // sync path. Manual edits to customer_type_id flow
1162                // through `update_quotation` which has its own approval
1163                // recompute at :1320-1422.
1164                //
1165                // The right shape lives in `Quotations::update_quotation`
1166                // at :1422 (`$result->for_approval != $n` guard). We
1167                // mirror it here.
1168                $g3wAmount = (float) ($budget['presupuesto']['importe'] ?? 0);
1169                $amountChanged = abs((float) $row->amount - $g3wAmount) > 0.005;
1170                $typeChanged = (int) $row->budget_type_id !== (int) $typeId;
1171
1172                // FIRE-1092: budgets in a terminal FST status (Aceptado=3,
1173                // Rechazado=4, Rechazado automático=7, Rechazado-duplicado=20)
1174                // are closed deals. Don't drag them back into the approval
1175                // queue from a G3W sync, even if G3W updates the amount.
1176                // `$isWorkAccepted` already catches G3W-side accepted work
1177                // (a related but distinct concept), but this guard also
1178                // catches budgets accepted/rejected directly in FST.
1179                $terminalStatuses = [3, 4, 7, 20];
1180                $isTerminalStatus = in_array((int) $row->budget_status_id, $terminalStatuses, true);
1181
1182                if (
1183                    ($amountChanged || $typeChanged)
1184                    && ! $isWorkAccepted
1185                    && ! $isTerminalStatus
1186                ) {
1187                    // Commercial lookup tolerates a missing user (G3W name not in
1188                    // tbl_users). sendApprovalNotificationOnly handles a null
1189                    // commercialId by warning + notifying just the approver list,
1190                    // not crashing the sync.
1191                    $comercialUser = TblUsers::where('name', $row->commercial)->first();
1192                    if (! $comercialUser) {
1193                        $mapping = TblUserG3wMapping::where('name_g3w', $row->commercial)->first();
1194                        if ($mapping && $mapping->id_fst) {
1195                            $comercialUser = TblUsers::where('id', $mapping->id_fst)->first();
1196                        }
1197                    }
1198
1199                    $newForApproval = self::computeForApprovalLevel(
1200                        $companyId, $typeId, $row->customer_type_id, $g3wAmount
1201                    );
1202
1203                    if ($newForApproval != $row->for_approval) {
1204                        $forApproval = $newForApproval;
1205                        // Genuine re-flag (or drop below threshold): G3W is
1206                        // order-size only, so a non-null level is technical (1).
1207                        $approvalType = $newForApproval;
1208
1209                        if ($newForApproval !== null) {
1210                            // Genuine re-flag: send the new notification
1211                            // and clear prior approval columns so the audit
1212                            // trail reflects that the previous sign-off is
1213                            // no longer valid for the new amount/type.
1214                            self::sendApprovalNotificationOnly(
1215                                $companyId, $typeId, $row->customer_type_id,
1216                                $g3wAmount, $row->quote_id, $row->id,
1217                                $comercialUser?->id, $row->commercial
1218                            );
1219                            $clearPriorApproval = true;
1220                        }
1221                        // else (newForApproval === null): the new amount
1222                        // dropped below the threshold. Leave approved_at /
1223                        // approved_by intact — past approvals remain
1224                        // historically true; the row simply leaves the
1225                        // "Requiere aprobación" bucket via for_approval=null.
1226                    }
1227                }
1228
1229                // FIRE-1063: when an existing solicitud row is upgraded
1230                // by G3W sync, prioritise the solicitud's existing
1231                // client info but fill empties from G3W. The
1232                // `companyName/Telephone/Email` resolved at :366-368
1233                // already follow the `servicio → cliente` order, so the
1234                // resulting per-field cascade is solicitud → G3W servicio
1235                // → G3W cliente. Pre-fix the three fields were silently
1236                // omitted (commented out below), which preserved
1237                // populated solicitudes correctly but stranded empty
1238                // ones with no fallback even though G3W had the data.
1239                $clientFallbacks = [];
1240                if (self::isBlankClientField($row->client) && ! empty($companyName)) {
1241                    $clientFallbacks['client'] = $companyName;
1242                }
1243                if (self::isBlankClientField($row->phone_number) && ! empty($companyTelephone)) {
1244                    $clientFallbacks['phone_number'] = $companyTelephone;
1245                }
1246                if (self::isBlankClientField($row->email) && ! empty($companyEmail)) {
1247                    $clientFallbacks['email'] = $companyEmail;
1248                }
1249
1250                $approvalReset = $clearPriorApproval ? [
1251                    // FIRE-1092: re-flag is a genuine change of approval
1252                    // state — invalidate the prior sign-off. The audit row
1253                    // in tbl_quotations_log preserves who/when before this
1254                    // wipe.
1255                    'approved_at' => null,
1256                    'approved_by' => null,
1257                    'approved_at_v2' => null,
1258                    'approved_by_v2' => null,
1259                    'rejected_at' => null,
1260                    'rejected_by' => null,
1261                    'rejected_at_v2' => null,
1262                    'rejected_by_v2' => null,
1263                ] : [];
1264
1265                $row->update(
1266                    array_merge($clientFallbacks, $approvalReset, [
1267                        // 'segment_id' => $segmentID,
1268                        // If G3W returns the "unmapped" sentinel (16),
1269                        // preserve the existing row's budget_type_id
1270                        // instead of wiping it. Same protective intent as
1271                        // the IfexController fix — a transient mapping
1272                        // gap shouldn't destroy known-good data.
1273                        'budget_type_id' => $typeId == 16 ? $row->budget_type_id : $typeId,
1274                        // 'client' / 'phone_number' / 'email' filled
1275                        // above only when the existing row is empty —
1276                        // see FIRE-1063 cascade comment.
1277                        'budget_status_id' => (in_array($nameStatus, $statusToChange) || $row->budget_status_id == 6) ? $statusID : $row->budget_status_id,
1278                        'acceptance_date' => $newAcceptanceDate,
1279                        'accepted_at' => $newAcceptedAt,
1280                        // FIRE-1024: don't claim "System" accepted the quote
1281                        // when there's no real acceptance date. If status
1282                        // moved away from Aceptado, also clear accepted_by.
1283                        'accepted_by' => ($nameStatus === 'Aceptado' && $newAcceptanceDate !== null) ? 'System' : null,
1284                        'amount' => $budget['presupuesto']['importe'] ?? null,
1285                        'updated_by' => 'System',
1286                        'updated_at' => Carbon::now()->format('Y-m-d H:i:s'),
1287                        'has_attachment' => 1,
1288                        'sync_import_edited' => 1,
1289                        'segment_by_g3w' => $company['servicio']['tipo_servicio'] ?? null,
1290                        'source_by_g3w' => $budget['presupuesto']['cod_empresa_presupuesto'] ?? null,
1291                        'status_by_g3w' => $nameStatus,
1292                        'type_by_g3w' => $budget['presupuesto']['origen_presupuesto'] ?? null,
1293                        'user_create_by_g3w' => $budget['presupuesto']['usuario'] ?? null,
1294                        'user_commercial_by_g3w' => ! empty($budget['presupuesto']['cod_comercial_presupuesto']) ? $budget['presupuesto']['cod_comercial_presupuesto'] : null,
1295                        'g3w_warning' => $g3wWarning,
1296                        'g3w_warning_fields' => $g3wWarningFields,
1297                        'cost_of_labor' => $resultMargin['cost_of_labor'],
1298                        'total_cost_of_job' => $resultMargin['total_cost_of_job'],
1299                        'invoice_margin' => $resultMargin['invoice_margin'],
1300                        'margin_for_the_company' => $resultMargin['margin_for_the_company'],
1301                        'margin_on_invoice_per_day_per_worker' => $resultMargin['margin_on_invoice_per_day_per_worker'],
1302                        'commission_cost' => $resultMargin['commission_cost'],
1303                        'revenue_per_date_per_worked' => $resultMargin['revenue_per_date_per_worked'],
1304                        'gross_margin' => $resultMargin['gross_margin'],
1305                        'labor_percentage' => $resultMargin['labor_percentage'],
1306                        'estimated_cost_of_materials' => $totalCostOfMaterial,
1307                        'people_assigned_to_the_job' => $peopleAssigned,
1308                        'duration_of_job_in_days' => $durationInDays,
1309                        'for_approval' => $forApproval,
1310                        'approval_type' => $approvalType,
1311                    ])
1312                );
1313            }
1314
1315            Log::channel('allInfoQuotationsG3w')->info('Pasamos el update');
1316
1317            // FIRE-1XXX: Fire send_acceptance_notification when a G3W sync
1318            // transitions a budget into "Aceptado" status (3). Three gates:
1319            //   1. The row was NOT already Aceptado (avoid re-emails on
1320            //      re-syncs of already-accepted budgets — would flood the
1321            //      inbox of every commercial on the first deploy otherwise).
1322            //   2. G3W is reporting the new status as Aceptado.
1323            //   3. The status field was actually written (in_array check +
1324            //      not blocked by the terminal-status guard at line 1149).
1325            // The notification function itself routes to the company's
1326            // "Destinatarios" (tbl_to_acceptance_notifications) + CC list
1327            // (tbl_cc_acceptance_notifications) + the assigned commercial
1328            // — see Quotations::send_acceptance_notification:8366.
1329            $statusWillUpdate = in_array($nameStatus, $statusToChange) || $row->budget_status_id == 6;
1330            $isTransitionToAccepted = (int) $row->budget_status_id !== 3
1331                && $nameStatus === 'Aceptado'
1332                && $statusWillUpdate
1333                && ! $isTerminalStatus;
1334
1335            if ($isTransitionToAccepted) {
1336                // Resolve the commercial's user_id so the notification can
1337                // CC them. Reuse the same name-then-mapping pattern as the
1338                // approval-notification path above (FIRE-1063 / FIRE-1092).
1339                $acceptanceCommercialUser = TblUsers::where('name', $row->commercial)->first();
1340                if (! $acceptanceCommercialUser) {
1341                    $mapping = TblUserG3wMapping::where('name_g3w', $row->commercial)->first();
1342                    if ($mapping && $mapping->id_fst) {
1343                        $acceptanceCommercialUser = TblUsers::where('id', $mapping->id_fst)->first();
1344                    }
1345                }
1346
1347                if ($acceptanceCommercialUser) {
1348                    // Defensive: company must have both TO and CC lists
1349                    // configured (the notification function aborts silently
1350                    // otherwise). Surface the gap so we can fix it instead
1351                    // of losing emails quietly.
1352                    $hasTo = TblToAcceptanceNotifications::where('company_id', $companyId)->exists();
1353                    $hasCc = TblCcAcceptanceNotifications::where('company_id', $companyId)->exists();
1354                    if (! $hasTo || ! $hasCc) {
1355                        Log::channel('allInfoQuotationsG3w')->warning(
1356                            'G3W-driven Aceptado transition for budget '.$row->id.
1357                            ' (company '.$companyId.') skipped acceptance notification: '.
1358                            'tbl_to_acceptance_notifications='.($hasTo ? 'ok' : 'EMPTY').
1359                            ' tbl_cc_acceptance_notifications='.($hasCc ? 'ok' : 'EMPTY').
1360                            '. Configure both lists in /notification-settings.'
1361                        );
1362                    } else {
1363                        try {
1364                            $this->quotationsController->send_acceptance_notification(
1365                                $row->id,
1366                                $companyId,
1367                                $acceptanceCommercialUser->id,
1368                                'G3W'
1369                            );
1370                        } catch (\Throwable $notifyException) {
1371                            // Notification failure must not abort the sync.
1372                            Log::channel('allInfoQuotationsG3w')->error(
1373                                'G3W-driven Aceptado transition: notification failed for budget '.
1374                                $row->id.': '.$notifyException->getMessage()
1375                            );
1376                        }
1377                    }
1378                } else {
1379                    Log::channel('allInfoQuotationsG3w')->warning(
1380                        'G3W-driven Aceptado transition for budget '.$row->id.
1381                        ' has no resolvable commercial user (commercial="'.($row->commercial ?? 'null').'"). '.
1382                        'Acceptance notification skipped.'
1383                    );
1384                }
1385            }
1386
1387            $companyNameFormatted = '';
1388
1389            if (! empty($companyName)) {
1390                $companyNameFormatted = str_replace(' ', '_', $companyName);
1391            }
1392
1393            $nameDocument = $companyNameFormatted
1394                ? $budget['presupuesto']['cod_presupuesto'].'_'.$companyNameFormatted
1395                : $budget['presupuesto']['cod_presupuesto'];
1396
1397            Log::channel('allInfoQuotationsG3w')->info('Nombre del documento: '.$nameDocument);
1398
1399            if (! $row) {
1400                Log::channel('allInfoQuotationsG3w')->info('No se encontró la cotización con internal_quote_id: '.$budget['presupuesto']['cod_presupuesto']);
1401                throw new \Exception("No se encontró la cotización con internal_quote_id: {$budget['presupuesto']['cod_presupuesto']}");
1402            }
1403
1404            $response = $this->saveDocument($budget['presupuesto']['documento'], $nameDocument, $row->id, $row->quote_id, 'G3W');
1405            $responseData = $response->getData();
1406
1407            Log::info('Response data: '.json_encode($responseData));
1408
1409            if (! isset($responseData->success) || ! $responseData->success) {
1410                Log::channel('allInfoQuotationsG3w')->info("No se pudo guardar el documento del presupuesto {$id} al editar. Error: ".($responseData->error ?? 'Desconocido'));
1411                throw new \Exception("No se pudo guardar el documento del presupuesto {$id} al editar. Error: ".($responseData->error ?? 'Desconocido'));
1412            }
1413
1414            $work = $this->request('get', "presupuesto/trabajos/{$id}", $region, []);
1415
1416            if (! empty($work) && isset($work[0]['ID'])) {
1417                $workId = $work[0]['ID'];
1418                $albaran = $this->request('get', "albaran/{$workId}", $region, []);
1419
1420                if (isset($albaran['albaran']) && isset($albaran['albaran']['certificado'])) {
1421                    $certificado = $albaran['albaran']['certificado'];
1422                    $nameCertificado = $id.'_certificado';
1423
1424                    $response = $this->saveDocument($certificado, $nameCertificado, $row->id, $row->quote_id, 'G3W');
1425                    $responseData = $response->getData();
1426
1427                    if (! isset($responseData->success) || ! $responseData->success) {
1428                        Log::channel('allInfoQuotationsG3w')->info("No se pudo guardar el certificado del presupuesto {$id} al editar. Error: ".($responseData->error ?? 'Desconocido'));
1429                        throw new \Exception("No se pudo guardar el certificado del presupuesto {$id} al editar. Error: ".($responseData->error ?? 'Desconocido'));
1430                    }
1431
1432                }
1433            }
1434
1435            Log::channel('allInfoQuotationsG3w')->info('Pasamos la subida de albaran.');
1436
1437            // FIRE-1025: zero-date sweep moved to the daily
1438            // `quotations:cleanup-zero-dates` artisan command. Pre-fix this
1439            // UPDATE ran at the end of every G3W sync cycle (5 call sites
1440            // here) and deadlocked on `idx_acceptance_date` when concurrent
1441            // syncs piled up (see InnoDB MONITOR OUTPUT on 2026-04-27 with
1442            // multiple "WE ROLL BACK TRANSACTION (1)" entries on this exact
1443            // statement). FIRE-1001 normalises `aceptacion` on write so no
1444            // new zero-dates can be created — the daily cleanup handles
1445            // legacy rows.
1446
1447            // FIRE-1145 follow-up: this method rewrote at least one tbl_quotations
1448            // row. Without forgetting the quotations bucket, list_quotations +
1449            // analytics caches return stale data for up to the 10-min TTL.
1450            ResultCache::forgetDomain('quotations');
1451
1452            return [
1453                'success' => true,
1454            ];
1455
1456        } catch (QueryException $e) {
1457            // FIRE-1029 (quote 160286 / 17939, 26-27/04): let DB-layer
1458            // failures bubble up to the caller's `DB::transaction(...)`
1459            // wrapper so the whole sync — including the `addUpdateLog`
1460            // INSERTs earlier in this method — rolls back atomically.
1461            //
1462            // Pre-fix this catch swallowed every QueryException and
1463            // returned `success: false`. From DB::transaction's view the
1464            // closure returned normally, so it issued COMMIT instead of
1465            // ROLLBACK, which during the FIRE-1025 deadlock storm
1466            // produced 100+ ghost change-log rows on quote 17939 while
1467            // `$row->update([...])` never landed (row's `updated_at`
1468            // stayed frozen at 2026-04-14 throughout). syncByDate also
1469            // saw `success: false` and routed the error correctly into
1470            // tbl_g3w_orders_update_logs — but only when the txn was
1471            // already dead, so the error trace was incomplete.
1472            //
1473            // Logical errors (G3W returned no documento, status not in
1474            // map, etc.) still throw plain `\Exception` and continue to
1475            // be returned gracefully via the catch below.
1476            throw $e;
1477        } catch (\Exception $e) {
1478            return ['success' => false, 'error' => "Error actualizando el presupuesto {$id}".$e->getMessage()];
1479        }
1480    }
1481
1482    /**
1483     * FIRE-1001: classify a G3W presupuesto line as labor (true) or
1484     * material (false). The original sync code only checked the
1485     * per-region `cod_material` allow-list (`$materialId` in
1486     * `syncModifiedBudgetById`). That list is incomplete — it only
1487     * covers Cataluña / Madrid / Comunidad Valenciana, and even there
1488     * it doesn't enumerate every labor code G3W actually emits. Labor
1489     * lines that fell through were summed into materials, and when the
1490     * frontend later recomputed `cost_of_labor = people × days × hours
1491     * × cost`, the labor hiding inside materials got added a second
1492     * time → "se duplica la mano de obra".
1493     *
1494     * Description-based detection covers the gap: any line whose
1495     * descripcion mentions "mano de obra" / "horas de trabajo" /
1496     * "horas técnico|oficial|peón" is treated as labor regardless of
1497     * its `cod_material`.
1498     *
1499     * @param  array<string,mixed>  $linea  Single line from $budget['presupuesto']['lineas']
1500     * @param  list<int|string>  $laborCodes  Per-region cod_material allow-list (may be empty)
1501     */
1502    public static function classifyLineAsLabor(array $linea, array $laborCodes): bool
1503    {
1504        $code = $linea['cod_material'] ?? null;
1505        if ($code !== null && in_array($code, $laborCodes, true)) {
1506            return true;
1507        }
1508        $desc = mb_strtolower((string) ($linea['descripcion'] ?? ''));
1509        if ($desc === '') {
1510            return false;
1511        }
1512
1513        return str_contains($desc, 'mano de obra')
1514            || str_contains($desc, 'mano obra')
1515            || preg_match('/\bhoras?\s+(de\s+)?trabajo\b/u', $desc) === 1
1516            || preg_match('/\bhoras?\s+(de\s+)?(t[eé]cnico|oficial|peon|peón)\b/u', $desc) === 1;
1517    }
1518
1519    /**
1520     * FIRE-1063 — treat NULL, empty string, and the literal string
1521     * 'null' (lowercase or otherwise) all as "blank" when deciding
1522     * whether to fall back to G3W servicio/cliente data on sync. The
1523     * 'null' literal shows up in legacy rows from a pre-FIRE-864 import
1524     * path that string-cast NULL on its way into the column.
1525     */
1526    private static function isBlankClientField(?string $value): bool
1527    {
1528        if ($value === null) {
1529            return true;
1530        }
1531        $trimmed = trim($value);
1532
1533        return $trimmed === '' || strtolower($trimmed) === 'null';
1534    }
1535
1536    /**
1537     * @return float[]|int[]|null[]
1538     */
1539    public function calculateBudgetMargin($amount, $commission_pct, $people_assigned_to_the_job, $duration_of_job_in_days, $estimated_cost_of_materials, $hours_per_worker_per_day, $cost_of_hour, $segmentId, $generalCosts): array
1540    {
1541        $results = [
1542            'cost_of_labor' => 0,
1543            'total_cost_of_job' => 0,
1544            'invoice_margin' => null,
1545            'margin_for_the_company' => null,
1546            'margin_on_invoice_per_day_per_worker' => null,
1547            'commission_cost' => null,
1548            'revenue_per_date_per_worked' => 0,
1549            'gross_margin' => 0,
1550            'labor_percentage' => 0,
1551        ];
1552
1553        $parseFloat = function ($value): int|float {
1554            if ($value === null || $value === '') {
1555                return 0;
1556            }
1557            $cleanValue = str_replace(',', '.', (string) $value);
1558
1559            return is_numeric($cleanValue) ? (float) $cleanValue : 0;
1560        };
1561
1562        $amount = $parseFloat($amount ?? 0);
1563        $commissionPct = $parseFloat($commission_pct ?? 0);
1564        $peopleAssigned = $parseFloat($people_assigned_to_the_job ?? 0);
1565        $durationInDays = $parseFloat($duration_of_job_in_days ?? 0);
1566        $estimatedMaterials = $parseFloat($estimated_cost_of_materials ?? 0);
1567
1568        if ($amount >= 0 && $peopleAssigned >= 0 && $durationInDays >= 0) {
1569
1570            $costOfLabor = $durationInDays * $peopleAssigned * ($hours_per_worker_per_day ?? 0) * ($cost_of_hour ?? 0);
1571
1572            $totalCostOfJob = $costOfLabor + $estimatedMaterials;
1573
1574            if ($commissionPct > 0 && $amount > 0) {
1575                $commissionCost = ($commissionPct / 100) * $amount;
1576            } else {
1577                $commissionCost = 0;
1578            }
1579
1580            if ($totalCostOfJob > 0 && $amount > 0) {
1581                if ($segmentId == 7) {
1582                    $invoiceMargin = (($amount - $totalCostOfJob - $commissionCost) / $amount) * 100;
1583                } else {
1584                    $invoiceMargin = (($amount - $totalCostOfJob) / $amount) * 100;
1585                }
1586
1587                $marginForTheCompany = $invoiceMargin - $generalCosts;
1588
1589                $marginOnInvoicePerDayPerWorker = ($amount - $estimatedMaterials - $costOfLabor) /
1590                                                ($durationInDays ?: 1) /
1591                                                ($peopleAssigned ?: 1);
1592            } else {
1593                $invoiceMargin = 0;
1594                $marginForTheCompany = 0;
1595                $marginOnInvoicePerDayPerWorker = 0;
1596            }
1597
1598            if ($costOfLabor == 0) {
1599                $revenuePerDayWorked = 0;
1600                $laborPercentage = 0;
1601            } else {
1602                $revenuePerDayWorked = ($amount / ($peopleAssigned ?: 1) / ($durationInDays ?: 1));
1603                $laborPercentage = ($costOfLabor / $amount) * 100;
1604            }
1605
1606            $grossMargin = $amount > 0 ? (($amount - $estimatedMaterials) / $amount) * 100 : 0;
1607
1608            $results['cost_of_labor'] = $costOfLabor;
1609            $results['total_cost_of_job'] = $totalCostOfJob;
1610            $results['invoice_margin'] = $invoiceMargin;
1611            $results['margin_for_the_company'] = $marginForTheCompany;
1612            $results['margin_on_invoice_per_day_per_worker'] = $marginOnInvoicePerDayPerWorker;
1613            $results['commission_cost'] = $commissionCost;
1614            $results['revenue_per_date_per_worked'] = $revenuePerDayWorked;
1615            $results['gross_margin'] = $grossMargin;
1616            $results['labor_percentage'] = $laborPercentage;
1617
1618        } else {
1619            foreach ($results as $key => $val) {
1620                $results[$key] = null;
1621            }
1622        }
1623
1624        return $results;
1625    }
1626
1627    /**
1628     * Synchronize budgets that gave us an error.
1629     *
1630     * @param  string  $name  Who's launch the function
1631     */
1632    public function syncErrorBudgets($name = null, $region = null): array
1633    {
1634        try {
1635            if ($region === 'Catalunya') {
1636                $region = 'Cataluña';
1637            }
1638
1639            $g3wActive = TblCompanies::where('region', $region)->first()->g3W_active;
1640
1641            if (! $g3wActive) {
1642                throw new Exception("La sincronización con G3W debe estar desactivada en la region '$region'.");
1643            }
1644
1645            $this->setSyncStatus(1, $region);
1646
1647            $successfulSyncs = 0;
1648            $failedSyncs = [];
1649            $successIdSyncs = [];
1650
1651            $startCronDateTime = date('Y-m-d H:i:s');
1652
1653            $company = TblCompanies::where('region', $region)->first();
1654
1655            if (! $company) {
1656                throw new \Exception('No company found for region: '.$region);
1657            }
1658            $company_id = $company->company_id;
1659            $logs = TblG3WOrdersUpdateLogs::whereNotNull('sync_error_ids')
1660                ->where('company_id', $company_id)
1661                ->get(['sync_error_ids']);
1662
1663            $allSyncErrorIds = [];
1664
1665            foreach ($logs as $log) {
1666                if (is_string($log->sync_error_ids)) {
1667                    $decodedIds = json_decode($log->sync_error_ids, true);
1668                    if (is_array($decodedIds)) {
1669                        $log->sync_error_ids = json_encode($decodedIds); // Fixed: Encode array back to JSON string
1670                        $allSyncErrorIds = array_merge($allSyncErrorIds, $decodedIds); // Fixed: Merge the decoded array
1671                    }
1672                }
1673            }
1674
1675            $allSyncErrorIds = array_unique($allSyncErrorIds);
1676
1677            foreach ($allSyncErrorIds as $idSyncError) {
1678                $result = DB::transaction(fn () => $this->syncById($idSyncError, $region));
1679
1680                if ($result['success']) {
1681                    $successfulSyncs++;
1682                    $successIdSyncs[] = [
1683                        'id' => $idSyncError,
1684                    ];
1685                } else {
1686                    if (! str_contains((string) $result['error'], 'No se ha encontrado el presupuesto')) {
1687                        $failedSyncs[] = [
1688                            'id' => $idSyncError,
1689                            'error' => $result['error'] ?? 'Unknown error',
1690                        ];
1691                    }
1692                }
1693            }
1694
1695            TblG3wLastUpdate::where('region', $region)->first()->update(['updatingNow' => 0]);
1696
1697            $company = TblCompanies::where('region', $region)->first();
1698            if (! $company) {
1699                throw new \Exception('No company found for region: '.$region);
1700            }
1701            $company_id = $company->company_id;
1702
1703            $logs = TblG3WOrdersUpdateLogs::whereNotNull('sync_error_ids')
1704                ->where('company_id', $company_id)
1705                ->update(['sync_error_ids' => []]);
1706
1707            $this->updateLogs($failedSyncs, $successfulSyncs, $successIdSyncs, $startCronDateTime, $name, $region);
1708
1709            return [
1710                'success' => true,
1711                'message' => 'Synchronization of failed budgets completed.',
1712            ];
1713
1714        } catch (\Exception $e) {
1715            Log::channel('g3w')->error('Error when synchronizing error budgets: '.$e->getMessage());
1716
1717            if (TblG3wLastUpdate::where('region', $region)->first()->updatingNow === 1) {
1718                TblG3wLastUpdate::where('region', $region)->first()->update(['updatingNow' => 0]);
1719            }
1720
1721            return ['success' => false, 'error' => $e->getMessage()];
1722        }
1723    }
1724
1725    public function syncBudgetsWorks($name = null, $region = null, ?int $limit = null): array
1726    {
1727        try {
1728            if ($region === 'Catalunya') {
1729                $region = 'Cataluña';
1730            }
1731
1732            $company = TblCompanies::where('region', $region)->first();
1733
1734            if (! $company) {
1735                throw new \Exception("No se encontró la compañía para la región '$region'.");
1736            }
1737            $g3wActive = $company->g3W_active;
1738
1739            if (! $g3wActive) {
1740                throw new Exception("La sincronización con G3W debe estar activada en la región '$region'.");
1741            }
1742
1743            $this->workSevice->getG3wTasksExecuted($region, 1);
1744
1745            $this->setSyncStatus(1, $region);
1746
1747            $successfulSyncs = 0;
1748            $failedSyncs = [];
1749            $successIdSyncs = [];
1750
1751            $startCronDateTime = date('Y-m-d H:i:s');
1752
1753            $quotesIdsQuery = TblQuotations::where(function ($query): void {
1754                $query->where('sync_import', 1)
1755                    ->orWhere('sync_import_edited', 1);
1756            })
1757                ->where('budget_type_id', 1)
1758                ->where(function ($query): void {
1759                    $query->whereNull('box_work_g3w')
1760                        ->orWhereIn('box_work_g3w', ['', '0']);
1761                });
1762
1763            if ($limit !== null) {
1764                $quotesIdsQuery->limit($limit);
1765            }
1766
1767            $quotesIds = $quotesIdsQuery->get();
1768
1769            foreach ($quotesIds as $quoteId) {
1770                try {
1771                    $work = $this->request('get', "presupuesto/trabajos/{$quoteId->internal_quote_id}", $region, []);
1772
1773                    sleep(2);
1774
1775                    $workIds = [];
1776
1777                    if (is_array($work)) {
1778                        foreach ($work as $item) {
1779                            if (isset($item['ID'])) {
1780                                $workIds[] = $item['ID'];
1781                            }
1782                        }
1783                    }
1784
1785                    // FIRE-1097: skip the write when G3W has no trabajo yet.
1786                    // Pre-fix, implode('/', []) wrote '' and locked the row out
1787                    // of future runs (the WHERE only matched NULL/'0').
1788                    if (empty($workIds)) {
1789                        continue;
1790                    }
1791
1792                    $idsConcatenados = implode('/', $workIds);
1793
1794                    $wasUpdated = $quoteId->update(['box_work_g3w' => $idsConcatenados]);
1795
1796                    if ($wasUpdated) {
1797                        $successfulSyncs++;
1798                        $successIdSyncs[] = [
1799                            'id' => $quoteId->internal_quote_id,
1800                        ];
1801                    } else {
1802                        $failedSyncs[] = [
1803                            'id' => $quoteId->internal_quote_id,
1804                            'error' => "Error updating the internal quote id $quoteId, work $idsConcatenados.",
1805                        ];
1806                    }
1807                } catch (\Throwable $e) {
1808                    $failedSyncs[] = [
1809                        'id' => $quoteId->internal_quote_id,
1810                        'error' => $e->getMessage(),
1811                    ];
1812                    Log::channel('g3w')->warning("sync-work failed for {$quoteId->internal_quote_id} in {$region}".$e->getMessage());
1813                }
1814            }
1815
1816            $g3wUpdate = TblG3wLastUpdate::where('region', $region)->first();
1817            if ($g3wUpdate) {
1818                $g3wUpdate->update(['updatingNow' => 0]);
1819            }
1820
1821            // FIRE-1025: zero-date sweep moved to the daily
1822            // `quotations:cleanup-zero-dates` artisan command. Pre-fix this
1823            // UPDATE ran at the end of every G3W sync cycle (5 call sites
1824            // here) and deadlocked on `idx_acceptance_date` when concurrent
1825            // syncs piled up (see InnoDB MONITOR OUTPUT on 2026-04-27 with
1826            // multiple "WE ROLL BACK TRANSACTION (1)" entries on this exact
1827            // statement). FIRE-1001 normalises `aceptacion` on write so no
1828            // new zero-dates can be created — the daily cleanup handles
1829            // legacy rows.
1830
1831            $this->updateLogs($failedSyncs, $successfulSyncs, $successIdSyncs, $startCronDateTime, $name, $region, 'Orders Works');
1832
1833            return [
1834                'success' => true,
1835                'message' => 'Synchronization of budgets works completed.',
1836            ];
1837
1838        } catch (\Exception $e) {
1839            Log::channel('g3w')->error('Error when synchronizing budgets works: '.$e->getMessage());
1840
1841            if (TblG3wLastUpdate::where('region', $region)->first()->updatingNow === 1) {
1842                TblG3wLastUpdate::where('region', $region)->first()->update(['updatingNow' => 0]);
1843            }
1844
1845            return ['success' => false, 'error' => $e->getMessage()];
1846        }
1847    }
1848
1849    public function notifyErrors($failedSyncs): void
1850    {
1851        $errorDetails = array_map(fn (array $failure) => "Budget ID: {$failure['id']}, Error: {$failure['error']}", $failedSyncs);
1852        $message = implode("\n", $errorDetails);
1853        /* Mail::luis, rick & chris, tech@fire.es */
1854
1855        Log::channel('g3w')->error('Error notification sent to ricardo.alemany@fire.es');
1856    }
1857
1858    /**
1859     * Function to generate the next QuoteID
1860     *
1861     * @return array{id: int, number: string}
1862     */
1863    /*public function generateQuoteId($companyId)
1864    {
1865        if ($companyId == 0) {
1866            $latestQuoteId = TblQuotations::orderBy('id', 'DESC')->first()->quote_id ?? null;
1867        } else {
1868            $latestQuoteId = TblQuotations::where('company_id', $companyId)
1869                ->orderBy('id', 'DESC')
1870                ->first()
1871                ->quote_id ?? null;
1872        }
1873
1874        if (!$latestQuoteId) {
1875            throw new \Exception("Error generando el # en titan");
1876        }
1877
1878        if (is_numeric($latestQuoteId)) {
1879            return (string)((int)$latestQuoteId + 1);
1880        }
1881
1882        preg_match('/([A-Z]+)(\d+)/', $latestQuoteId, $matches);
1883
1884        if (count($matches) < 3) {
1885            throw new \Exception("El formato del Ãºltimo Quote ID no es válido: {$latestQuoteId}");
1886        }
1887
1888        $prefix = $matches[1];
1889        $numericPart = $matches[2];
1890
1891        $incrementedNumber = str_pad((int)$numericPart + 1, strlen($numericPart), '0', STR_PAD_LEFT);
1892
1893        $newQuoteId = $prefix . $incrementedNumber;
1894
1895        return $newQuoteId;
1896    }*/
1897    public function generateQuoteId($companyId): array|ResponseFactory|Response
1898    {
1899        try {
1900
1901            $companyId = addslashes((string) $companyId);
1902            $latestBudget = [];
1903            $number = 0;
1904            $beforeLastId = null;
1905
1906            $x = true;
1907
1908            if ($companyId == 0) {
1909                $latestBudget = TblQuotations::orderByRaw('CAST(quote_id AS DOUBLE) DESC')->value('quote_id');
1910            } else {
1911                $latestBudget = TblCompanies::where('company_id', $companyId)->value('last_id');
1912
1913                if ($latestBudget == null) {
1914                    $latestBudget = TblQuotations::where('company_id', $companyId)->orderByRaw('id DESC')->value('quote_id');
1915                    $beforeLastId = $latestBudget;
1916                }
1917            }
1918
1919            $number = $latestBudget;
1920
1921            while ($x) {
1922
1923                if (is_numeric(substr((string) $number, -1))) {
1924                    $number++;
1925                } else {
1926                    $number .= '1';
1927                }
1928
1929                $check = 0;
1930
1931                if ($companyId == 0) {
1932                    $check = TblQuotations::where('quote_id', (string) $number)->count();
1933                } else {
1934                    $check = TblQuotations::where('company_id', $companyId)->where('quote_id', (string) $number)->count();
1935                }
1936
1937                if ($check == 0) {
1938                    $x = false;
1939                }
1940            }
1941
1942            $result = TblQuotations::create(['quote_id' => $number, 'company_id' => $companyId, 'for_add' => 1]);
1943
1944            if ($beforeLastId == null) {
1945                $beforeLastId = $number;
1946            }
1947
1948            $query = "UPDATE tbl_companies SET last_id = '{$number}', before_last_id = CASE WHEN before_last_id IS NULL THEN '{$beforeLastId}' ELSE before_last_id END WHERE company_id = {$companyId}";
1949            DB::select($query);
1950
1951            return [
1952                'id' => $result->id,
1953                'number' => $number,
1954            ];
1955
1956        } catch (\Exception $e) {
1957            throw $e;
1958        }
1959    }
1960
1961    /**
1962     * Function to normalice the status provided by G3W.
1963     *
1964     * @param  $status  String Row status of G3W
1965     * @return int ID normalized in FST
1966     *
1967     * @throws \Exception
1968     */
1969    private function normalizeStatus($status)
1970    {
1971        if (! $status) {
1972            return TblBudgetStatus::where('name', 'Sin estado en G3W')->first()->budget_status_id;
1973        }
1974
1975        $statusMapping = TblStatusG3wMapping::where('name_g3w', $status)->first();
1976
1977        if (! $statusMapping) {
1978            TblStatusG3wMapping::create([
1979                'name_g3w' => $status,
1980                'budget_status_id' => 0,
1981            ]);
1982
1983            return TblBudgetStatus::where('name', 'Estado no reconocido en FST')->first()->budget_status_id;
1984        }
1985
1986        return $statusMapping->budget_status_id;
1987
1988    }
1989
1990    /**
1991     * Function to normalice the segment provided by G3W.
1992     *
1993     * @param  $status  String Row status of G3W
1994     * @return int ID normalized in FST
1995     *
1996     * @throws \Exception
1997     */
1998    private function normalizeSegment($segment)
1999    {
2000        $segmentMapping = TblSegmentG3wMapping::where('name_g3w', $segment)->first();
2001
2002        if (! $segmentMapping) {
2003            TblSegmentG3wMapping::create([
2004                'name_g3w' => $segment,
2005                'segment_id' => 0,
2006            ]);
2007
2008            return TblSegments::where('name', 'Otro')->first()->segment_id ?? 9;
2009        }
2010
2011        return $segmentMapping->segment_id;
2012    }
2013
2014    /**
2015     * Function to normalize the budget type provided by G3W.
2016     *
2017     * @param  int  $type  ID del tipo proporcionado por la API.
2018     * @param  string  $region  Región (Madrid o Cataluña).
2019     * @return int ID normalizado del presupuesto en el sistema.
2020     *
2021     * @throws \Exception
2022     */
2023    private function normalizeType($type, $region)
2024    {
2025        if ($region === 'Catalunya') {
2026            $region = 'Cataluña';
2027        }
2028
2029        $budgetTypeMapping = TblTypeG3wMapping::where('id_g3w', $type)
2030            ->where('region', $region)
2031            ->first();
2032
2033        if (! $budgetTypeMapping) {
2034            TblTypeG3wMapping::create([
2035                'id_g3w' => $type ?? null,
2036                'budget_type_name' => null,
2037                'region' => $region,
2038            ]);
2039            throw new \Exception("El estado '$type' no existe en la base de datos.");
2040        }
2041
2042        $budgetType = TblBudgetTypes::where('name', $budgetTypeMapping->budget_type_name)->first();
2043
2044        if (! $budgetType) {
2045            return 16;
2046        }
2047
2048        return $budgetType->budget_type_id;
2049    }
2050
2051    private function normalizeSource($id_call, $region)
2052    {
2053        $regionTxt = '';
2054        $region = $region == 'Catalunya' ? 'Cataluña' : $region;
2055
2056        if (! $id_call) {
2057            $sourceDefault = TblSources::where('name', 'G3W/Gestiona')->first();
2058
2059            return $sourceDefault->source_id ?? 20;
2060        }
2061
2062        $sourceMapping = TblSourceG3wMapping::where('id_g3w', $id_call)
2063            ->where('region', $region)
2064            ->first();
2065
2066        if (! $sourceMapping) {
2067            TblSourceG3wMapping::create([
2068                'id_g3w' => $id_call,
2069                'source_name' => null,
2070                'region' => $region,
2071            ]);
2072
2073            return null;
2074        }
2075
2076        $sourceId = TblSources::where('name', $sourceMapping->source_name)->first();
2077
2078        if (! $sourceId && $region == 'Andalucía') {
2079            return 68;
2080        }
2081
2082        if (! $sourceId) {
2083            return null;
2084        }
2085
2086        return $sourceId->source_id;
2087
2088    }
2089
2090    /**
2091     * @return JsonResponse
2092     */
2093    public function saveDocument($document, $nameDocument = null, $quotationId = null, $quoteId = null, $uploadedBy = null, $isInternal = null)
2094    {
2095
2096        try {
2097
2098            $binaryData = base64_decode((string) $document);
2099
2100            if (! $binaryData) {
2101                throw new \Exception('Los datos Base64 no son válidos.');
2102            }
2103
2104            $documentName = $nameDocument
2105                ? preg_replace('/[^A-Za-z0-9_\-.]/', '', (string) $nameDocument)
2106                : 'document_'.time().'.pdf';
2107
2108            if (! preg_match('/\.pdf$/i', $documentName)) {
2109                $documentName .= '.pdf';
2110            }
2111
2112            // FIRE-1110: the S3 key must be unique across regions and across
2113            // concurrent syncs. The old `time()` suffix was second-precision,
2114            // and $documentName collides across Gestiona instances because
2115            // each region issues its own cod_presupuesto sequence. Madrid's
2116            // budget 6815 and Valencia's budget 6815 would land on the same
2117            // S3 path and overwrite each other (confirmed in prod for order
2118            // 50783: S3 LastModified was 5 months after the DB uploaded_at).
2119            //
2120            // We prepend the quotation's company_id and replace time() with
2121            // uniqid('', true) — microsecond + random — so two writes can
2122            // never produce the same key.
2123            $companyPrefix = '';
2124            if ($quotationId !== null) {
2125                $companyId = TblQuotations::where('id', $quotationId)->value('company_id');
2126                if ($companyId !== null) {
2127                    $companyPrefix = 'c'.$companyId.'-';
2128                }
2129            }
2130            $filename = $companyPrefix
2131                .pathinfo($documentName, PATHINFO_FILENAME)
2132                .'_'.uniqid('', true)
2133                .'.pdf';
2134
2135            $fileSize = strlen($binaryData);
2136            $mimeType = 'application/pdf';
2137
2138            $fileDataBase = TblFiles::where('quotation_id', $quotationId)->get();
2139
2140            if ($fileDataBase->isNotEmpty()) {
2141                foreach ($fileDataBase as $fileData) {
2142                    if ($fileData->original_name == $documentName) {
2143                        $fileData->delete();
2144                    }
2145                }
2146            }
2147
2148            $s3path = Storage::disk('s3')->put(
2149                'uploads/'.$filename,
2150                $binaryData,
2151                [
2152                    'ContentType' => $mimeType,
2153                ]
2154            );
2155
2156            $file = TblFiles::create([
2157                'quotation_id' => $quotationId,
2158                'original_name' => $documentName,
2159                'filename' => $filename,
2160                'uploaded_by' => $uploadedBy,
2161                'file_size' => $fileSize,
2162                'mime_type' => $mimeType,
2163                'uploaded_at' => date('Y-m-d H:i:s'),
2164            ]);
2165
2166            $this->quotationsController->addUpdateLog($quotationId, $uploadedBy, 'upload_attachment', null, $filename, 4);
2167
2168            return response()->json([
2169                'success' => true,
2170                'message' => 'Documento guardado correctamente en la base de datos.',
2171                'filename' => $filename,
2172                'fileId' => $file->file_id,
2173                'documentName' => $documentName,
2174            ], 200);
2175
2176        } catch (\Exception $e) {
2177            return response()->json([
2178                'success' => false,
2179                'error' => $e->getMessage(),
2180            ], 500);
2181        }
2182    }
2183
2184    /**
2185     * Formatear bytes a formato legible
2186     */
2187    private function formatBytes($bytes, $precision = 2)
2188    {
2189        $units = ['B', 'KB', 'MB', 'GB', 'TB'];
2190
2191        $bytes = max($bytes, 0);
2192        $pow = floor(($bytes ? log($bytes) : 0) / log(1024));
2193        $pow = min($pow, count($units) - 1);
2194
2195        $bytes /= pow(1024, $pow);
2196
2197        return round($bytes, $precision).' '.$units[$pow];
2198    }
2199
2200    public function updateLogs($failedSyncs, $successfulSyncs, $successIdSyncs, $startCronDateTime, $name, $region, $process = null): void
2201    {
2202        Log::channel('g3w')->error($failedSyncs);
2203
2204        if ($region === 'Catalunya') {
2205            $region = 'Cataluña';
2206        }
2207
2208        $companyId = TblCompanies::where('region', $region)->first()->company_id;
2209        $syncStatus = 'Success';
2210        $failedSyncsTxt = '';
2211
2212        if (! empty($failedSyncs)) {
2213            $syncStatus = ($successfulSyncs > 0) ? 'Partially failed' : 'Failed';
2214            $this->notifyErrors($failedSyncs);
2215            $errorsArray = array_column($failedSyncs, 'error');
2216            $failedSyncsTxt = implode(', ', $errorsArray);
2217        }
2218
2219        $idsError = array_map(intval(...), array_column($failedSyncs, 'id'));
2220        $idsError = array_unique($idsError);
2221        $idsErrorCount = count($idsError);
2222        $idsErrorJson = json_encode($idsError);
2223
2224        $idsSuccess = array_map(intval(...), array_column($successIdSyncs, 'id'));
2225        $idsSuccess = array_unique($idsSuccess);
2226        $idsSuccessCount = count($idsSuccess);
2227        $idsSuccessJson = json_encode($idsSuccess);
2228
2229        TblG3WOrdersUpdateLogs::create(
2230            [
2231                'company_id' => $companyId,
2232                'to_process' => $process ?? 'Orders',
2233                'status' => $syncStatus,
2234                'sync_succesfull' => $idsSuccessCount,
2235                'sync_error' => $idsErrorCount,
2236                'sync_error_message' => $failedSyncsTxt,
2237                'sync_error_ids' => $idsErrorJson,
2238                'sync_success_ids' => $idsSuccessJson,
2239                'processed_by' => $name,
2240                'started_at' => $startCronDateTime,
2241                'ended_at' => TblG3wLastUpdate::where('region', $region)->first()->updated_at->format('Y-m-d H:i:s'),
2242            ]
2243        );
2244    }
2245
2246    private function checkEmailInvalid($email)
2247    {
2248        $emailPattern = "/^[\w\.\-]+@([\w\-]+\.)+[a-zA-Z]{2,}$/";
2249
2250        $emails = explode(',', $email);
2251
2252        $emailInvalid = false;
2253
2254        foreach ($emails as $email) {
2255            if (! preg_match($emailPattern, $email)) {
2256                $emailInvalid = true;
2257                break;
2258            }
2259        }
2260
2261        return $emailInvalid;
2262    }
2263
2264    private function checkRequiredFields($data): ?string
2265    {
2266
2267        $g3wWarningFields = [];
2268
2269        if ($data->budget_status_id == 13 || $data->budget_status_id == 14) {
2270            array_push($g3wWarningFields, 'Estado');
2271        }
2272
2273        if ($data->budget_type_id == null || $data->budget_type_id == '' || $data->budget_type_id == 0 || $data->budget_type_id == 16) {
2274            array_push($g3wWarningFields, 'Tipo');
2275        }
2276
2277        if ($data->commercial == null || $data->commercial == '') {
2278            array_push($g3wWarningFields, 'Comercial');
2279        }
2280
2281        if ($data->source_id == null || $data->source_id == '') {
2282            array_push($g3wWarningFields, 'Fuente');
2283        }
2284
2285        if ($data->email == null || $data->email == '' || $this->isBlacklistedEmail($data->email)) {
2286            array_push($g3wWarningFields, 'Email');
2287        }
2288
2289        if ($data->client == null || $data->client == '') {
2290            array_push($g3wWarningFields, 'Datos cliente');
2291        }
2292
2293        if (($data->amount == null || $data->amount == 0) && in_array($data->budget_status_id, [1, 2, 3, 11, 17])) {
2294            array_push($g3wWarningFields, 'Importe');
2295        }
2296
2297        if (! empty($g3wWarningFields)) {
2298            return implode(', ', $g3wWarningFields);
2299        } else {
2300            return null;
2301        }
2302    }
2303
2304    private function isBlacklistedEmail(?string $email): bool
2305    {
2306        if (! $email || trim($email) === '') {
2307            return true;
2308        }
2309
2310        $pattern = '/^no@|^nomail@nomail|^notiene@notiene|tiene\.email|test\.com|prueba\.com/i';
2311
2312        $emails = explode(',', $email);
2313        foreach ($emails as $e) {
2314            if (preg_match($pattern, trim($e))) {
2315                return true;
2316            }
2317        }
2318
2319        return false;
2320    }
2321
2322    public function syncExistingDataWithWarnings(): void
2323    {
2324
2325        $budgets = TblQuotations::where('g3w_warning', 1)->get();
2326
2327        if (count($budgets) > 0) {
2328            foreach ($budgets as $item) {
2329                $g3wWarning = 0;
2330                $g3wWarningFields = $this->checkRequiredFields($item);
2331
2332                if ($g3wWarningFields != null && $g3wWarningFields != '') {
2333                    $g3wWarning = 1;
2334                }
2335
2336                TblQuotations::where('id', $item->id)->update(
2337                    [
2338                        'g3w_warning' => $g3wWarning,
2339                        'g3w_warning_fields' => $g3wWarningFields,
2340                    ]
2341                );
2342
2343                $g3wWarningFields = null;
2344            }
2345        }
2346    }
2347
2348    public function syncByIds($ids, $region, $date, $user = 'System'): array
2349    {
2350        try {
2351            if (! $ids) {
2352                throw new \Exception('No ids provided');
2353            }
2354
2355            $arrayIds = explode(',', (string) $ids);
2356
2357            $g3wActive = TblCompanies::where('region', $region)->first()->g3W_active;
2358
2359            if (! $g3wActive) {
2360                throw new Exception("La sincronización con G3W debe estar desactivada en la region '$region'.");
2361            }
2362
2363            $this->setSyncStatus(1, $region);
2364            $this->syncExistingDataWithWarnings();
2365
2366            $successfulSyncs = 0;
2367            $failedSyncs = [];
2368            $successIdSyncs = [];
2369
2370            $startCronDateTime = date('Y-m-d H:i:s');
2371
2372            $company = TblCompanies::where('region', $region)->first();
2373
2374            $isNullInDate = TblQuotations::where('company_id', $company->company_id)
2375                ->where(function ($query): void {
2376                    $query->where('sync_import', 1)
2377                        ->orWhere('sync_import_edited', 1);
2378                })
2379                ->whereDate('created_at', $date)
2380                ->where('internal_quote_id', null)
2381                ->exists();
2382
2383            foreach ($arrayIds as $id) {
2384                $result['success'] = false;
2385                $result['error'] = 'Error en sync by Ids';
2386                if ($isNullInDate) {
2387                    $result['success'] = DB::transaction(fn () => $this->syncNullBudget($id, $region, $company->company_id, $date));
2388                }
2389
2390                if (! $result['success']) {
2391                    $result = DB::transaction(fn () => $this->syncById($id, $region));
2392                }
2393
2394                if ($result['success']) {
2395                    $successfulSyncs++;
2396                    $successIdSyncs[] = [
2397                        'id' => $id,
2398                    ];
2399
2400                    $quote = TblQuotations::where('company_id', $company->company_id)
2401                        ->where('internal_quote_id', $id)
2402                        ->first();
2403
2404                    if (! $quote && $company->company_id == 18) {
2405                        $quote = TblQuotations::where('company_id', 22)
2406                            ->where('internal_quote_id', $id)
2407                            ->first();
2408                    }
2409
2410                    if (! $quote && $company->company_id == 22) {
2411                        $quote = TblQuotations::where('company_id', 18)
2412                            ->where('internal_quote_id', $id)
2413                            ->first();
2414                    }
2415
2416                    if ($quote) {
2417                        $quote->update([
2418                            'created_at' => Carbon::parse($date),
2419                        ]);
2420                    }
2421
2422                } else {
2423                    if (! str_contains((string) $result['error'], 'No se ha encontrado el presupuesto')) {
2424                        $failedSyncs[] = [
2425                            'id' => $id,
2426                            'error' => $result['error'] ?? 'Unknown error',
2427                        ];
2428                    }
2429                }
2430            }
2431
2432            $this->setSyncStatus(0, $region);
2433
2434            // FIRE-1025: zero-date sweep moved to the daily
2435            // `quotations:cleanup-zero-dates` artisan command. Pre-fix this
2436            // UPDATE ran at the end of every G3W sync cycle (5 call sites
2437            // here) and deadlocked on `idx_acceptance_date` when concurrent
2438            // syncs piled up (see InnoDB MONITOR OUTPUT on 2026-04-27 with
2439            // multiple "WE ROLL BACK TRANSACTION (1)" entries on this exact
2440            // statement). FIRE-1001 normalises `aceptacion` on write so no
2441            // new zero-dates can be created — the daily cleanup handles
2442            // legacy rows.
2443
2444            $this->updateLogs($failedSyncs, $successfulSyncs, $successIdSyncs, $startCronDateTime, $user, $region);
2445
2446            return [
2447                'success' => true,
2448                'message' => 'Synchronization completed.',
2449            ];
2450        } catch (\Exception $e) {
2451            Log::channel('g3w')->error('Error sincronizando los presupuestos: '.$e->getMessage());
2452
2453            if (TblG3wLastUpdate::where('region', $region)->first()->updatingNow === 1) {
2454                TblG3wLastUpdate::where('region', $region)->first()->update(['updatingNow' => 0]);
2455            }
2456
2457            return ['success' => false, 'error' => $e->getMessage()];
2458        }
2459
2460    }
2461
2462    private function syncNullBudget(string $id, $region, $companyId, $date): bool
2463    {
2464        $budget = $this->request('get', "presupuesto/{$id}", $region, []);
2465
2466        if (! isset($budget['presupuesto']) || ! is_array($budget['presupuesto'])) {
2467            throw new \Exception('El presupuesto no contiene los datos esperados.');
2468        }
2469
2470        $quote = TblQuotations::where('company_id', $companyId)
2471            ->where(function ($query): void {
2472                $query->where('sync_import', 1)
2473                    ->orWhere('sync_import_edited', 1);
2474            })
2475            ->whereDate('created_at', $date)
2476            ->where('internal_quote_id', null)
2477            ->where('source_by_g3w', $budget['presupuesto']['cod_empresa_presupuesto'] ?? null)
2478            ->where('type_by_g3w', $budget['presupuesto']['origen_presupuesto'] ?? null)
2479            ->where('user_create_by_g3w', $budget['presupuesto']['usuario'])
2480            ->where('user_commercial_by_g3w', ! empty($budget['presupuesto']['cod_comercial_presupuesto']) ? $budget['presupuesto']['cod_comercial_presupuesto'] : null)
2481            ->first();
2482
2483        if (! $quote) {
2484            return false;
2485        }
2486
2487        $quote->update([
2488            'internal_quote_id' => $id,
2489        ]);
2490
2491        return true;
2492    }
2493
2494    public function getAlternativeClientData($texto): array
2495    {
2496        preg_match('/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', (string) $texto, $email);
2497        preg_match('/\b\d{9}\b/', (string) $texto, $number);
2498
2499        $nombre = null;
2500        $lineas = explode("\n", (string) $texto);
2501        foreach ($lineas as $linea) {
2502
2503            $linea = trim($linea);
2504
2505            if (empty($linea) ||
2506                preg_match('/@|\b\d{9}\b|C\/|CALLE|AVENIDA|PLAZA|CARRER|\d{5}/i', $linea)) {
2507                continue;
2508            }
2509
2510            if (preg_match('/^[A-Za-zÁÉÍÓÚáéíóúÑñ\s\.]+$/', $linea) &&
2511                ! preg_match('/\d/', $linea) &&
2512                substr_count($linea, ' ') >= 1 &&
2513                strlen($linea) > 5) {
2514
2515                $candidatos[] = $linea;
2516            }
2517        }
2518
2519        if (! empty($candidatos)) {
2520            usort($candidatos, function ($a, $b): int {
2521                $scoreA = (! str_contains($a, '.') ? 2 : 0) + strlen($a);
2522                $scoreB = (! str_contains($b, '.') ? 2 : 0) + strlen($b);
2523
2524                return $scoreB - $scoreA;
2525            });
2526            $nombre = rtrim($candidatos[0], '.');
2527        }
2528
2529        return [
2530            'email' => $email[0] ?? null,
2531            'number' => $number[0] ?? null,
2532            'name' => $nombre,
2533        ];
2534    }
2535
2536    /**
2537     * Backwards-compatible wrapper around the split compute/notify methods.
2538     * Old callers that just want "tell me the level and send the email"
2539     * keep working unchanged. `syncModifiedBudgetById` (the FIRE-1092
2540     * caller) now uses the split methods directly so it can decide whether
2541     * the recomputed level differs from the current one before deciding
2542     * to send a fresh notification.
2543     */
2544    private static function checkAproval($companyId, $budgetTypeId, $customerTypeId, $amount, $quoteId, $id, $commercialId, $comercial): ?int
2545    {
2546        $forApproval = self::computeForApprovalLevel($companyId, $budgetTypeId, $customerTypeId, $amount);
2547
2548        if ($forApproval !== null) {
2549            self::sendApprovalNotificationOnly($companyId, $budgetTypeId, $customerTypeId, $amount, $quoteId, $id, $commercialId, $comercial);
2550        }
2551
2552        return $forApproval;
2553    }
2554
2555    /**
2556     * FIRE-1092: pure computation of the approval level required for a
2557     * quotation given amount + project/company thresholds. Returns 1
2558     * (single-level) or null (no approval needed). Tier-2 (dual-level)
2559     * promotion is not yet wired into the G3W path — when amount also
2560     * crosses `minimum_order_size_v2`, manual edits via update_quotation
2561     * already handle it; the equivalent G3W branch is a known follow-up.
2562     */
2563    private static function computeForApprovalLevel($companyId, $budgetTypeId, $customerTypeId, $amount): ?int
2564    {
2565        $company = TblCompanies::where('company_id', $companyId)->first();
2566        $project = TblProjectTypes::where('company_id', $companyId)
2567            ->where('budget_type_id', $budgetTypeId)
2568            ->first();
2569
2570        $minimumOrderSize = null;
2571        $customerTypeIds = [];
2572
2573        if ($project) {
2574            if (! empty($project->customer_type_ids)) {
2575                $customerTypeIds = array_map(intval(...), explode(',', (string) $project->customer_type_ids));
2576            }
2577            $minimumOrderSize = $project->minimum_order_size;
2578        } elseif ($company) {
2579            if (! empty($company->customer_type_ids)) {
2580                $customerTypeIds = array_map(intval(...), explode(',', (string) $company->customer_type_ids));
2581            }
2582            $minimumOrderSize = $company->minimum_order_size;
2583        }
2584
2585        if ($minimumOrderSize === null) {
2586            return null;
2587        }
2588        if (! in_array((int) $customerTypeId, $customerTypeIds, true)) {
2589            return null;
2590        }
2591        if ((float) $amount < (float) $minimumOrderSize) {
2592            return null;
2593        }
2594
2595        return 1;
2596    }
2597
2598    /**
2599     * FIRE-1092: emit the approval-required email. Extracted from the old
2600     * `checkAproval` so the caller can decide whether the recomputed level
2601     * actually represents a change — `update_quotation:1422` already does
2602     * this, the G3W sync did not.
2603     */
2604    private static function sendApprovalNotificationOnly($companyId, $budgetTypeId, $customerTypeId, $amount, $quoteId, $id, $commercialId, $comercial): void
2605    {
2606        $company = TblCompanies::where('company_id', $companyId)->first();
2607        if (! $company) {
2608            return;
2609        }
2610
2611        $project = TblProjectTypes::where('company_id', $companyId)
2612            ->where('budget_type_id', $budgetTypeId)
2613            ->first();
2614        $minimumOrderSize = $project?->minimum_order_size ?? $company->minimum_order_size;
2615
2616        if (! $commercialId) {
2617            // value('id') returns null if no row matches — avoids crashing
2618            // the whole sync when G3W's commercial name doesn't map to a user.
2619            $commercialId = TblUsers::where('name', $comercial)->value('id');
2620        }
2621
2622        // If we still can't resolve the commercial, DO NOT skip the
2623        // notification — the technical approver still needs to know a
2624        // quote requires review. send_approval_notification is null-safe
2625        // for a missing commercial (no BCC/CC to commercial) and the
2626        // primary recipients (tbl_approvers) still get the email.
2627        if (! $commercialId) {
2628            Log::channel('allInfoQuotationsG3w')->warning(
2629                "checkAproval: no se encontró usuario para el comercial '{$comercial}' (quote_id {$quoteId}, id {$id}). Se notifica solo al aprobador técnico."
2630            );
2631        }
2632
2633        $quotations = new Quotations;
2634        $quotations->send_approval_notification(
2635            $amount,
2636            $budgetTypeId,
2637            $customerTypeId,
2638            $minimumOrderSize,
2639            $quoteId,
2640            $id,
2641            $company->name,
2642            'System',
2643            $commercialId,
2644            0,
2645            null,
2646            $company->company_id,
2647            'orders',
2648            0,
2649            0,
2650            null,
2651            'es'
2652        );
2653    }
2654}