Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 302
0.00% covered (danger)
0.00%
0 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
FreshDeskTicketSync
0.00% covered (danger)
0.00%
0 / 302
0.00% covered (danger)
0.00%
0 / 15
7832
0.00% covered (danger)
0.00%
0 / 1
 handle
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
72
 syncRegion
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
156
 httpJson
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getWithRateLimit
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 isAddressedToBajas
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 isConfirmedBaja
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 upsertTicket
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
42
 fetchClientFinder
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 fetchClosureTemplate
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
210
 parseClosureTemplate
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
182
 snap
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 normalizeRegion
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 normalizeYesNo
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 customField
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 cleanHtml
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace App\Console\Commands;
4
5use App\Models\TblLeave;
6use Carbon\Carbon;
7use Illuminate\Console\Command;
8use Illuminate\Http\Client\ConnectionException;
9use Illuminate\Http\Client\PendingRequest;
10use Illuminate\Http\Client\Response;
11use Illuminate\Support\Facades\Http;
12use Illuminate\Support\Facades\Log;
13
14class FreshDeskTicketSync extends Command
15{
16    protected $signature = 'sync:freshdesk';
17
18    protected $description = 'Sync confirmed Bajas tickets from each regional Freshdesk instance into tbl_leave (TITAN Bajas tracker).';
19
20    /**
21     * Custom field key in the Freshdesk ticket payload that confirms a baja.
22     * If your account uses a different internal name (e.g. cf_es_baja), change this constant.
23     */
24    private const BAJA_CUSTOM_FIELD = 'cf_baja';
25
26    /**
27     * Confirmation values that count as "Sí" (Spanish accents may be stripped by Freshdesk).
28     */
29    private const BAJA_CONFIRM_VALUES = ['Sí', 'Si', 'SI', 'YES', 'Yes', 'true', '1'];
30
31    /**
32     * Per-region configuration. company_id is the umbrella TITAN company for that region;
33     * BAL / VAL fall back to null until the team creates the matching tbl_companies rows.
34     */
35    private const REGIONS = [
36        'CAT' => ['domain' => 'firebusinesssl-help.freshdesk.com',  'company_id' => 19],
37        'MAD' => ['domain' => 'firebusinessmadrid.freshdesk.com',   'company_id' => 18],
38        'ALM' => ['domain' => 'firebusinessalmeria.freshdesk.com',  'company_id' => 21],
39        'BAL' => ['domain' => null,                                 'company_id' => null],
40        'VAL' => ['domain' => null,                                 'company_id' => null],
41    ];
42
43    /**
44     * 3-letter region code → full Spanish name stored in tbl_leave.region.
45     * Codes are used for integration payloads (Client Finder webhook); the full
46     * name is used for display / DB persistence.
47     */
48    private const REGION_NAMES = [
49        'CAT' => 'Cataluña',
50        'MAD' => 'Madrid',
51        'ALM' => 'Andalucía',
52        'BAL' => 'Baleares',
53        'VAL' => 'Valencia',
54    ];
55
56    private const BAJAS_INTAKE_EMAIL = 'bajas@fire.es';
57
58    /**
59     * Only sync tickets that belong to this Freshdesk group. Freshdesk group ids
60     * are scoped to a single subdomain, so this will match in one region and
61     * filter out every ticket in the others.
62     */
63    private const BAJAS_GROUP_ID = 203000117233;
64
65    /**
66     * HTTP retry: how many attempts and base delay (ms). Laravel's HTTP client retries on
67     * ConnectionException by default, which covers cURL DNS / TCP / TLS failures.
68     */
69    private const HTTP_RETRY_TIMES = 3;
70
71    private const HTTP_RETRY_DELAY_MS = 500;
72
73    private const HTTP_TIMEOUT_S = 30;
74
75    public function handle(): int
76    {
77        // Conversations endpoint returns full HTML email bodies (sometimes with inline
78        // images). The default 128M PHP limit isn't enough when fetching dozens.
79        // ini_set may be locked by php.ini on some installs — verify and warn.
80        @ini_set('memory_limit', '512M');
81        $effective = ini_get('memory_limit');
82        if ($effective !== '512M' && $effective !== '-1') {
83            $this->warn("memory_limit is {$effective} — ini_set was overridden. If you hit OOM, re-run with: php -d memory_limit=1G artisan sync:freshdesk");
84        }
85
86        foreach (self::REGIONS as $region => $cfg) {
87            $domain = env('FRESHDESK_DOMAIN_'.$region, $cfg['domain']);
88            $token = env('FRESHDESK_TOKEN_'.$region);
89
90            if (! $domain || ! $token) {
91                $this->info("Region {$region}: skipped (missing FRESHDESK_DOMAIN_{$region} or FRESHDESK_TOKEN_{$region}).");
92
93                continue;
94            }
95
96            $this->info("Region {$region}: syncing {$domain}");
97
98            try {
99                $this->syncRegion($region, $domain, $token, $cfg['company_id']);
100            } catch (ConnectionException $e) {
101                $this->warn("Region {$region}: network error — {$e->getMessage()}. Skipping to next region.");
102                Log::warning('Freshdesk sync: region network error', [
103                    'region' => $region,
104                    'domain' => $domain,
105                    'error' => $e->getMessage(),
106                ]);
107            } catch (\Throwable $e) {
108                $this->error("Region {$region}: failed — {$e->getMessage()}");
109                Log::error('Freshdesk sync: region failed', [
110                    'region' => $region,
111                    'error' => $e->getMessage(),
112                ]);
113            }
114        }
115
116        return self::SUCCESS;
117    }
118
119    private function syncRegion(string $region, string $domain, string $token, ?int $defaultCompanyId): void
120    {
121        // Freshdesk Filter/Search API caps results at 30 per page and 30 pages
122        // (~900 records). Per-page size is fixed by the API — there is no
123        // `per_page` parameter on /search/tickets.
124        $page = 1;
125        $maxPage = 30;
126        $expectedPerPage = 30;
127
128        // Ticket attributes that the Search API natively supports as query
129        // operands. Filtering server-side trims the response volume and
130        // dodges the per-page cap. cf_baja stays client-side because the
131        // SOP treats it as the definitive gate, and it's cheap to recheck.
132        $query = sprintf('"group_id:%d"', self::BAJAS_GROUP_ID);
133
134        while ($page <= $maxPage) {
135            $url = sprintf(
136                'https://%s/api/v2/search/tickets?query=%s&page=%d',
137                $domain,
138                rawurlencode($query),
139                $page
140            );
141
142            $response = $this->getWithRateLimit(fn () => $this->httpJson($token)->get($url));
143
144            if (! $response->successful()) {
145                Log::warning('Freshdesk sync: non-success response', [
146                    'region' => $region,
147                    'page' => $page,
148                    'status' => $response->status(),
149                ]);
150                break;
151            }
152
153            $body = $response->json();
154            // Free the raw response body now that we have the parsed array.
155            unset($response);
156
157            // Search API shape: {"total": int, "results": [ticket, ...]}
158            $tickets = is_array($body) ? ($body['results'] ?? []) : [];
159            unset($body);
160
161            if (count($tickets) === 0) {
162                break;
163            }
164
165            $ticketCount = count($tickets);
166
167            foreach ($tickets as $ticket) {
168                // group_id is guaranteed by the search query, but keep the
169                // existing email criterion as a belt-and-braces initial
170                // filter and re-check the cf_baja gate.
171                if (($ticket['group_id'] ?? null) == self::BAJAS_GROUP_ID || $this->isAddressedToBajas($ticket)) {
172                    if (! $this->isConfirmedBaja($ticket)) {
173                        continue;
174                    }
175                } else {
176                    continue;
177                }
178
179                try {
180                    $this->upsertTicket($ticket, $region, $domain, $token, $defaultCompanyId);
181                } catch (\Throwable $e) {
182                    Log::error('Freshdesk sync: failed to upsert ticket', [
183                        'region' => $region,
184                        'ticket_id' => $ticket['id'] ?? null,
185                        'error' => $e->getMessage(),
186                    ]);
187                }
188            }
189
190            // Free the page array and reclaim PHP's internal arena before the next fetch.
191            unset($tickets);
192            gc_collect_cycles();
193
194            // Stop early on a partial final page.
195            if ($ticketCount < $expectedPerPage) {
196                break;
197            }
198
199            $page++;
200        }
201
202        if ($page > $maxPage) {
203            Log::warning('Freshdesk sync: hit search pagination cap', [
204                'region' => $region,
205                'cap' => $maxPage * $expectedPerPage,
206            ]);
207        }
208    }
209
210    /**
211     * Authenticated Freshdesk HTTP client. Retries are handled by getWithRateLimit
212     * — we don't use Laravel's ->retry() here because it auto-throws on non-2xx,
213     * which would prevent us from inspecting 429 responses.
214     */
215    private function httpJson(string $token): PendingRequest
216    {
217        return Http::withBasicAuth($token, 'X')
218            ->acceptJson()
219            ->timeout(self::HTTP_TIMEOUT_S);
220    }
221
222    /**
223     * Run an HTTP request with retries:
224     *  - 429 (rate limit): wait Retry-After header (clamped 1..120s)
225     *  - ConnectionException (DNS / TCP / TLS): exponential backoff (2..60s)
226     * Up to 5 attempts. After exhaustion, returns the last response or rethrows.
227     */
228    private function getWithRateLimit(callable $request): Response
229    {
230        $maxAttempts = 5;
231        $response = null;
232        $lastException = null;
233
234        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
235            try {
236                $response = $request();
237            } catch (ConnectionException $e) {
238                $lastException = $e;
239                $delay = min(60, 2 ** $attempt);
240                $this->warn("  Connection error: {$e->getMessage()} — retrying in {$delay}s ({$attempt}/{$maxAttempts}).");
241                sleep($delay);
242
243                continue;
244            }
245
246            if ($response->status() !== 429) {
247                return $response;
248            }
249
250            $retryAfter = (int) ($response->header('Retry-After') ?: 30);
251            $retryAfter = max(1, min(120, $retryAfter));
252
253            $this->warn("  Rate-limited (429); waiting {$retryAfter}s before retry ({$attempt}/{$maxAttempts}).");
254            sleep($retryAfter);
255        }
256
257        if ($response !== null) {
258            return $response;
259        }
260
261        throw $lastException ?? new \RuntimeException('Freshdesk request failed without exception or response');
262    }
263
264    /**
265     * Definitive ingestion gate per SOP §2: the Baja custom field must be "Sí".
266     * Group / intake-email act as initial filter only.
267     */
268    /**
269     * True when the ticket's to_emails list contains the bajas intake address.
270     * Freshdesk returns entries either bare ("bajas@fire.es") or RFC-formatted
271     * ("\"Bajas Grupo Fire\" <bajas@fire.es>"), so we substring-match.
272     */
273    private function isAddressedToBajas(array $ticket): bool
274    {
275        $toEmails = $ticket['to_emails'] ?? [];
276        if (! is_array($toEmails)) {
277            return false;
278        }
279
280        $needle = strtolower(self::BAJAS_INTAKE_EMAIL);
281        foreach ($toEmails as $entry) {
282            if (is_string($entry) && str_contains(strtolower($entry), $needle)) {
283                return true;
284            }
285        }
286
287        return false;
288    }
289
290    private function isConfirmedBaja(array $ticket): bool
291    {
292        $customFields = $ticket['custom_fields'] ?? [];
293        $bajaValue = $customFields[self::BAJA_CUSTOM_FIELD] ?? null;
294
295        if ($bajaValue === null) {
296            return false;
297        }
298
299        if (is_bool($bajaValue)) {
300            return $bajaValue === true;
301        }
302
303        return in_array(trim((string) $bajaValue), self::BAJA_CONFIRM_VALUES, true);
304    }
305
306    private function upsertTicket(
307        array $ticket,
308        string $region,
309        string $domain,
310        string $token,
311        ?int $defaultCompanyId
312    ): void {
313        $ticketId = (int) $ticket['id'];
314        $cifFromTicket = $this->customField($ticket, 'cf_cif')
315            ?? $this->customField($ticket, 'cf_cif_nif')
316            ?? $this->customField($ticket, 'cf_nif');
317
318        $clientFinder = $this->fetchClientFinder($ticketId, $region);
319        $dataCrm = $clientFinder['data_crm'] ?? [];
320        $crm = $dataCrm['crm_record'] ?? [];
321        $ai = $clientFinder['data_ai']['output'] ?? [];
322
323        $closureTemplate = $this->fetchClosureTemplate($ticketId, $domain, $token);
324
325        // Region from the agent template overrides the Freshdesk-instance region —
326        // SOP §3.3: tickets to bajas@fire.es land in MAD but the client may belong elsewhere.
327        // parseClosureTemplate() already normalizes "Región: CAT" → "CAT".
328        $templateRegion = $closureTemplate['region'] ?? null;
329        $effectiveRegion = ($templateRegion && isset(self::REGIONS[$templateRegion])) ? $templateRegion : $region;
330        $companyId = self::REGIONS[$effectiveRegion]['company_id'] ?? $defaultCompanyId;
331
332        $description = $this->cleanHtml($ticket['description'] ?? null);
333
334        $payload = [
335            'company_id' => $companyId,
336            // DB stores the full Spanish region name; integration payloads keep the 3-letter code.
337            'region' => self::REGION_NAMES[$effectiveRegion] ?? $effectiveRegion,
338            'freshdesk_ticket_id' => $ticketId,
339            'freshdesk_ticket_link' => "https://{$domain}/a/tickets/{$ticketId}",
340            // CIF/NIF: Freshdesk ticket field first (SOP §3.1), then Client Finder fallbacks.
341            'cif_nif' => $cifFromTicket
342                ?? ($crm['service_cif'] ?? null)
343                ?? ($dataCrm['company_cif'] ?? null)
344                ?? ($ai['tax_id'] ?? null),
345            'date_of_receipt' => isset($ticket['created_at']) ? Carbon::parse($ticket['created_at'])->toDateTimeString() : null,
346
347            // From Client Finder (data_crm.crm_record / data_crm.* / data_ai.output)
348            'customer_name' => $crm['service_name'] ?? $dataCrm['company_name'] ?? $ai['company_name'] ?? null,
349            'customer_email' => $crm['email'] ?? $ai['requester_email'] ?? null,
350            'customer_telephone_number' => $crm['phone'] ?? $ai['phone'] ?? null,
351            'gestiona_customer_id' => $crm['g3w_client_id'] ?? $dataCrm['g3w_client_id'] ?? null,
352            'service_type' => $crm['service_type'] ?? $crm['g3w_service_code'] ?? null,
353            'existing_teams' => $crm['existing_teams'] ?? null,
354            'postal_code' => $crm['postal_code'] ?? null,
355            // Branch/Sucursal: prefer explicit field, fall back to population (city) from CRM record.
356            'branch' => $crm['branch'] ?? $crm['population'] ?? null,
357            'comment' => $crm['comment'] ?? null,
358            'private_comment' => $crm['private_comment'] ?? null,
359            // Store the full Client Finder response (data_crm + data_ai + flags) as
360            // pretty-printed JSON so admins can see exactly what the webhook returned.
361            'comment_for_administration' => ! empty($clientFinder)
362                ? json_encode($clientFinder, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
363                : null,
364
365            // From the agent closure template (parser returns English DB-column keys)
366            'billing_client_code' => $closureTemplate['billing_client_code'] ?? null,
367            'reason_for_cancellation' => $closureTemplate['reason_for_cancellation'] ?? null,
368            'reason_details' => $closureTemplate['reason_details'] ?? null,
369            'reason_details_2' => $closureTemplate['reason_details_2'] ?? null,
370            'change_of_maintenance_provider' => $closureTemplate['change_of_maintenance_provider'] ?? 'No',
371            'action' => $closureTemplate['action'] ?? null,
372            'customer_recovery' => $closureTemplate['customer_recovery'] ?? null,
373
374            'description' => $description,
375            'created_by' => 'System',
376            'updated_by' => 'System',
377            'updated_at' => now(),
378        ];
379
380        // Drop nulls so an upsert doesn't blank existing manually-edited fields,
381        // but always overwrite link / region / dates.
382        $alwaysOverwrite = ['freshdesk_ticket_link', 'region', 'company_id', 'date_of_receipt', 'updated_at', 'updated_by'];
383        $update = array_filter($payload, fn ($v, $k) => $v !== null || in_array($k, $alwaysOverwrite, true), ARRAY_FILTER_USE_BOTH);
384
385        TblLeave::updateOrCreate(
386            ['freshdesk_ticket_id' => $ticketId],
387            $update
388        );
389    }
390
391    private function fetchClientFinder(int $ticketId, string $region): array
392    {
393        try {
394            $response = Http::acceptJson()
395                ->timeout(self::HTTP_TIMEOUT_S)
396                ->retry(self::HTTP_RETRY_TIMES, self::HTTP_RETRY_DELAY_MS, fn ($e) => $e instanceof ConnectionException)
397                ->post('https://aiwf.ibvgroup.com/webhook/client-finder', [
398                    'ticket_id' => $ticketId,
399                    'region' => strtolower($region),
400                ]);
401        } catch (ConnectionException $e) {
402            Log::warning('Freshdesk sync: client-finder unreachable', [
403                'ticket_id' => $ticketId,
404                'region' => $region,
405                'error' => $e->getMessage(),
406            ]);
407
408            return [];
409        }
410
411        if (! $response->successful()) {
412            return [];
413        }
414
415        $data = $response->json();
416
417        return is_array($data) ? $data : [];
418    }
419
420    /**
421     * Fetch ticket conversations and parse the closure-template comment.
422     * Returns an array with the SOP §3.3 fields, or [] if no template found.
423     */
424    private function fetchClosureTemplate(int $ticketId, string $domain, string $token): array
425    {
426        // Paginate conversations 10 at a time. Freshdesk returns them oldest-first
427        // and the default un-paginated response caps at 30, so for tickets with
428        // many comments the closure template would be missed. Track the latest
429        // marker across pages and free each page from memory before fetching
430        // the next.
431        $perPage = 10;
432        $page = 1;
433        $maxPages = 10;
434        $latestBody = null;
435        $latestAt = '';
436
437        while ($page <= $maxPages) {
438            try {
439                $url = "https://{$domain}/api/v2/tickets/{$ticketId}/conversations?per_page={$perPage}&page={$page}";
440                $response = $this->getWithRateLimit(fn () => $this->httpJson($token)->get($url));
441            } catch (ConnectionException $e) {
442                Log::warning('Freshdesk sync: conversations fetch failed', [
443                    'ticket_id' => $ticketId,
444                    'domain' => $domain,
445                    'error' => $e->getMessage(),
446                ]);
447                break;
448            }
449
450            if (! $response->successful()) {
451                break;
452            }
453
454            $conversations = $response->json();
455            unset($response);
456
457            if (! is_array($conversations) || count($conversations) === 0) {
458                break;
459            }
460
461            $count = count($conversations);
462
463            foreach ($conversations as $conv) {
464                $body = $conv['body_text'] ?? strip_tags($conv['body'] ?? '');
465                if ($body && stripos($body, '=== CIERRE DE BAJA ===') !== false) {
466                    $createdAt = $conv['created_at'] ?? '';
467                    if ($latestBody === null || ($createdAt && strcmp($createdAt, $latestAt) > 0)) {
468                        $latestBody = $body;
469                        $latestAt = $createdAt;
470                    }
471                }
472            }
473
474            unset($conversations);
475            gc_collect_cycles();
476
477            if ($count < $perPage) {
478                break;
479            }
480
481            $page++;
482        }
483
484        if ($latestBody === null) {
485            return [];
486        }
487
488        return $this->parseClosureTemplate($latestBody);
489    }
490
491    /**
492     * Parse the structured closure block. Tolerant of:
493     *   - the comment being on a single line (no real newlines)
494     *   - extra whitespace around colons
495     *   - case / accent variations on the labels
496     *   - free-text values that may contain newlines (e.g. "Acción")
497     *
498     * Spanish field labels in the comment are mapped to English DB-column keys.
499     * Format defined in SOP §4.
500     */
501    private function parseClosureTemplate(string $body): array
502    {
503        // Spanish field label → English DB column. ORDER MATTERS: longest first
504        // so regex alternation prefers "Detalle Motivo 2" over "Detalle Motivo".
505        $fieldMap = [
506            'Código de cliente facturación' => 'billing_client_code',
507            'Cambio de mantenedor' => 'change_of_maintenance_provider',
508            'Recuperación cliente' => 'customer_recovery',
509            'Detalle Motivo 2' => 'reason_details_2',
510            'Motivo Principal' => 'reason_for_cancellation',
511            'Detalle Motivo' => 'reason_details',
512            'Acción' => 'action',
513            'Región' => 'region',
514        ];
515
516        $marker = '=== CIERRE DE BAJA ===';
517        $pos = stripos($body, $marker);
518        $block = $pos === false ? $body : substr($body, $pos + strlen($marker));
519
520        $labels = array_keys($fieldMap);
521        $alt = implode('|', array_map(fn ($l) => preg_quote($l, '/'), $labels));
522
523        // Match each label and capture its value up to the next label, a stray
524        // '===' marker, or end of block. iu = case + unicode, s = . spans newlines.
525        $pattern = '/('.$alt.')\s*:\s*(.*?)\s*(?=(?:'.$alt.')\s*:|===|$)/ius';
526
527        $out = [];
528        if (preg_match_all($pattern, $block, $matches, PREG_SET_ORDER)) {
529            foreach ($matches as $m) {
530                $matchedLabel = $m[1];
531                $value = trim($m[2]);
532
533                foreach ($fieldMap as $label => $dbCol) {
534                    if (mb_strtolower($label, 'UTF-8') === mb_strtolower($matchedLabel, 'UTF-8')) {
535                        $out[$dbCol] = $value;
536                        break;
537                    }
538                }
539            }
540        }
541
542        if (isset($out['region'])) {
543            $code = $this->normalizeRegion($out['region']);
544            if ($code !== null) {
545                $out['region'] = $code;
546            } else {
547                unset($out['region']);
548            }
549        }
550
551        if (isset($out['change_of_maintenance_provider'])) {
552            $v = $this->normalizeYesNo($out['change_of_maintenance_provider']);
553            $out['change_of_maintenance_provider'] = $v ?? 'No'; // SOP §4.2: unknown → No
554        }
555
556        if (isset($out['customer_recovery'])) {
557            $out['customer_recovery'] = $this->normalizeYesNo($out['customer_recovery']);
558        }
559
560        // Snap controlled-list values to canonical case so the modal dropdowns
561        // match what's stored (e.g. agent types "EXTERNO" → store "Externo").
562        if (isset($out['reason_for_cancellation'])) {
563            $out['reason_for_cancellation'] = $this->snap($out['reason_for_cancellation'], ['Interno', 'Externo']);
564        }
565        if (isset($out['reason_details'])) {
566            // Values mirror what's stored in tbl_leave.reason_details — see modal.component.ts
567            // reasonDetailsCanonical. Mixed-case is intentional (legacy DB content).
568            $out['reason_details'] = $this->snap($out['reason_details'], [
569                'ATENCION_CLIENTE', 'Cese', 'GRUPO_FIRE', 'LICITACION',
570                'Otros', 'PARTICULAR', 'Precio', 'Servicio', 'TRASPASO',
571            ]);
572        }
573        if (isset($out['reason_details_2'])) {
574            $out['reason_details_2'] = $this->snap($out['reason_details_2'], [
575                'Precio - Desacuerdo con tarifa', 'Otros',
576            ]);
577        }
578
579        return $out;
580    }
581
582    /**
583     * Case-insensitive match against a canonical list. Returns the canonical
584     * spelling on hit; otherwise returns the input unchanged so unknown values
585     * are still preserved (just not normalized).
586     */
587    private function snap(string $value, array $canonical): string
588    {
589        $needle = mb_strtolower(trim($value), 'UTF-8');
590        foreach ($canonical as $option) {
591            if (mb_strtolower($option, 'UTF-8') === $needle) {
592                return $option;
593            }
594        }
595
596        return $value;
597    }
598
599    /**
600     * Map a region string to its 3-letter code. Accepts the code itself or the
601     * Spanish region name in any common form (with/without accents, mixed case).
602     * Returns null if the input doesn't resolve to a known region.
603     */
604    private function normalizeRegion(string $value): ?string
605    {
606        $aliases = [
607            'CAT' => ['cat', 'cataluna', 'catalunya', 'catalonia', 'catalan'],
608            'MAD' => ['mad', 'madrid', 'comunidad de madrid'],
609            'ALM' => ['alm', 'almeria', 'andalucia'],
610            'BAL' => ['bal', 'baleares', 'islas baleares', 'balearic islands', 'illes balears'],
611            'VAL' => ['val', 'valencia', 'comunidad valenciana', 'valenciana', 'comunitat valenciana'],
612        ];
613
614        $norm = strtr(mb_strtolower(trim($value), 'UTF-8'), [
615            'á' => 'a', 'é' => 'e', 'í' => 'i', 'ó' => 'o', 'ú' => 'u', 'ñ' => 'n', 'ü' => 'u',
616        ]);
617        $norm = preg_replace('/\s+/', ' ', $norm);
618
619        foreach ($aliases as $code => $list) {
620            if (in_array($norm, $list, true)) {
621                return $code;
622            }
623        }
624
625        return null;
626    }
627
628    private function normalizeYesNo(string $value): ?string
629    {
630        $v = strtolower(trim($value));
631        $v = strtr($v, ['í' => 'i', 'Í' => 'i']);
632
633        if (in_array($v, ['si', 'yes', 'true', '1'], true)) {
634            return 'Sí';
635        }
636        if (in_array($v, ['no', 'false', '0'], true)) {
637            return 'No';
638        }
639
640        return null;
641    }
642
643    private function customField(array $ticket, string $key): ?string
644    {
645        $val = $ticket['custom_fields'][$key] ?? null;
646        if ($val === null || $val === '') {
647            return null;
648        }
649
650        return is_string($val) ? trim($val) : (string) $val;
651    }
652
653    private function cleanHtml(?string $html): ?string
654    {
655        if ($html === null || $html === '') {
656            return null;
657        }
658
659        $clean = str_replace(["\n", "\r"], '', $html);
660
661        return preg_replace('/\s+/', ' ', $clean);
662    }
663}