Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
0.00% |
0 / 302 |
|
0.00% |
0 / 15 |
CRAP | |
0.00% |
0 / 1 |
| FreshDeskTicketSync | |
0.00% |
0 / 302 |
|
0.00% |
0 / 15 |
7832 | |
0.00% |
0 / 1 |
| handle | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
72 | |||
| syncRegion | |
0.00% |
0 / 48 |
|
0.00% |
0 / 1 |
156 | |||
| httpJson | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
| getWithRateLimit | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
42 | |||
| isAddressedToBajas | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
30 | |||
| isConfirmedBaja | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
| upsertTicket | |
0.00% |
0 / 53 |
|
0.00% |
0 / 1 |
42 | |||
| fetchClientFinder | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
20 | |||
| fetchClosureTemplate | |
0.00% |
0 / 37 |
|
0.00% |
0 / 1 |
210 | |||
| parseClosureTemplate | |
0.00% |
0 / 47 |
|
0.00% |
0 / 1 |
182 | |||
| snap | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
12 | |||
| normalizeRegion | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 | |||
| normalizeYesNo | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
| customField | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
20 | |||
| cleanHtml | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace App\Console\Commands; |
| 4 | |
| 5 | use App\Models\TblLeave; |
| 6 | use Carbon\Carbon; |
| 7 | use Illuminate\Console\Command; |
| 8 | use Illuminate\Http\Client\ConnectionException; |
| 9 | use Illuminate\Http\Client\PendingRequest; |
| 10 | use Illuminate\Http\Client\Response; |
| 11 | use Illuminate\Support\Facades\Http; |
| 12 | use Illuminate\Support\Facades\Log; |
| 13 | |
| 14 | class 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 | } |