format(DATE_ATOM), $cleanText]; } } return [null, $cleanText]; } function normalize_shalom_lookup_text(string $text): string { $normalized = trim((string) preg_replace('/\s+/u', ' ', $text)); if ($normalized === '') { return ''; } $upper = mb_strtoupper($normalized, 'UTF-8'); return strtr($upper, [ 'Á' => 'A', 'É' => 'E', 'Í' => 'I', 'Ó' => 'O', 'Ú' => 'U', 'Ü' => 'U', ]); } function is_shalom_missing_order_message(?string $text): bool { $normalized = normalize_shalom_lookup_text((string) $text); if ($normalized === '') { return false; } if ( str_contains($normalized, 'NO SE ENCONTRO LA ORDEN') || str_contains($normalized, 'NO SE ENCONTRO EL PEDIDO') || str_contains($normalized, 'NO SE ENCONTRO LA GUIA') || str_contains($normalized, 'NO SE PUDO ENCONTRAR LA GUIA') || str_contains($normalized, 'ORDEN NO ENCONTRADA') || str_contains($normalized, 'GUIA NO ENCONTRADA') || str_contains($normalized, 'NO EXISTE LA ORDEN') || str_contains($normalized, 'NO EXISTE LA GUIA') || (str_contains($normalized, 'NO DEVOLVIO UN N') && str_contains($normalized, 'INTERNO VALIDO')) ) { return true; } return false; } function detect_shalom_missing_order_reason(array $payload): ?string { $candidates = []; foreach (['error', 'message'] as $key) { if (isset($payload[$key]) && is_scalar($payload[$key])) { $candidate = trim((string) $payload[$key]); if ($candidate !== '') { $candidates[] = $candidate; } } } if (isset($payload['details'])) { if (is_scalar($payload['details'])) { $candidate = trim((string) $payload['details']); if ($candidate !== '') { $candidates[] = $candidate; } } elseif (is_array($payload['details'])) { foreach (['error', 'message', 'details'] as $key) { if (isset($payload['details'][$key]) && is_scalar($payload['details'][$key])) { $candidate = trim((string) $payload['details'][$key]); if ($candidate !== '') { $candidates[] = $candidate; } } } } } foreach ($candidates as $candidate) { if (is_shalom_missing_order_message($candidate)) { return $candidate; } } return null; } function build_invalid_guide_response(string $orderNumber, string $orderCode, string $rawMessage = ''): array { return [ 'source' => 'shalom_invalid_guide', 'search' => [ 'success' => false, 'data' => [ 'origen' => ['nombre' => 'N/A'], 'destino' => ['nombre' => 'N/A', 'direccion' => ''], 'tracking_url' => build_public_tracking_url($orderNumber, $orderCode), 'internal_order_number' => null, 'external_order_number' => $orderNumber, 'order_code' => $orderCode, 'detail_text' => 'Shalom no encontró la orden. Revisa el N° de orden y el código de orden para corregir la guía.', 'requires_login' => false, ], ], 'statuses' => [ 'message' => 'REVISAR GUIA', 'raw_message' => trim($rawMessage), 'data' => [], ], ]; } function normalize_shalom_status_label(string $statusMessage): string { $normalized = trim(preg_replace('/\s+/u', ' ', $statusMessage)); if ($normalized === '') { return ''; } if (is_shalom_missing_order_message($normalized)) { return 'REVISAR GUIA'; } $upper = mb_strtoupper($normalized, 'UTF-8'); if (str_contains($upper, 'ENTREGA EXITOSA') || str_contains($upper, 'ENTREGAD') || str_contains($upper, 'COMPLET')) { return 'Entregado'; } if (str_contains($upper, 'REPARTO')) { return 'En reparto'; } if (str_contains($upper, 'DESTINO')) { return 'En destino'; } if (str_contains($upper, 'TRANSITO') || str_contains($upper, 'TRÁNSITO')) { return 'En tránsito'; } if (str_contains($upper, 'ORIGEN') || str_contains($upper, 'REGISTRADO')) { return 'En origen'; } return $normalized; } function infer_status_message_from_payload(array $statusPayload): string { $statusData = isset($statusPayload['data']) && is_array($statusPayload['data']) ? $statusPayload['data'] : []; $priority = [ ['key' => 'entregado', 'label' => 'Entregado'], ['key' => 'reparto', 'label' => 'En reparto'], ['key' => 'destino', 'label' => 'En destino'], ['key' => 'transito', 'label' => 'En tránsito'], ['key' => 'origen', 'label' => 'En origen'], ['key' => 'registrado', 'label' => 'En origen'], ['key' => 'demora', 'label' => 'En demora'], ]; foreach ($priority as $candidate) { $key = $candidate['key']; if (!empty($statusData[$key])) { return $candidate['label']; } } $fallbackMessage = trim((string) ($statusPayload['message'] ?? $statusPayload['error'] ?? '')); $normalizedFallback = normalize_shalom_status_label($fallbackMessage); return $normalizedFallback !== '' ? $normalizedFallback : 'No disponible'; } // Load env from executor/.env (helps when Apache/PHP doesn't export .env vars) function load_dotenv_if_needed(array $keys): void { $missing = array_filter($keys, fn($k) => getenv($k) === false || getenv($k) === ''); if (empty($missing)) { return; } static $loaded = false; if ($loaded) return; $envPath = realpath(__DIR__ . '/../.env'); // executor/.env if ($envPath && is_readable($envPath)) { $lines = @file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; foreach ($lines as $line) { $trimmed = trim($line); if ($trimmed === '' || $trimmed[0] === '#') { continue; } if (!str_contains($trimmed, '=')) continue; [$k, $v] = array_map('trim', explode('=', $trimmed, 2)); if ($k === '') continue; // Strip potential surrounding quotes $v = trim($v, "\"' "); // Do not override existing process env if (getenv($k) === false || getenv($k) === '') { putenv("{$k}={$v}"); } } $loaded = true; } } function resolve_binary(array $candidates, string $fallback): string { foreach ($candidates as $candidate) { if (is_executable($candidate)) { return $candidate; } } return $fallback; } function generate_uuid_v4(): string { $bytes = random_bytes(16); $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); $hex = bin2hex($bytes); return sprintf( '%s-%s-%s-%s-%s', substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4), substr($hex, 16, 4), substr($hex, 20, 12) ); } function build_shalom_web_authorization(): string { $token = 'web-' . generate_uuid_v4(); $expiresAt = time() + 300; $payload = $token . '@' . $expiresAt; $signature = hash_hmac('sha256', $payload, '.Ov3rsku112024l4r43l.'); return 'Bearer ' . $payload . '@' . $signature; } function decrypt_shalom_public_payload(array $decoded): array { if (empty($decoded['encrypted'])) { return [ 'success' => true, 'data' => $decoded, ]; } $encodedPayload = trim((string) ($decoded['data'] ?? '')); if ($encodedPayload === '') { return [ 'success' => false, 'error' => 'Shalom devolvió una respuesta cifrada vacía.', ]; } $key = base64_decode('uQn/bQ94PXBEfId70zjN+VE1hSU7kh9VBXTOUd68Ssc=', true); if ($key === false || strlen($key) !== 32) { return [ 'success' => false, 'error' => 'No se pudo preparar la clave de descifrado de Shalom.', ]; } $binaryPayload = base64_decode($encodedPayload, true); if ($binaryPayload === false || strlen($binaryPayload) <= 16) { return [ 'success' => false, 'error' => 'La respuesta cifrada de Shalom es inválida.', ]; } $iv = substr($binaryPayload, 0, 16); $ciphertext = substr($binaryPayload, 16); $plainText = openssl_decrypt($ciphertext, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv); if (!is_string($plainText) || $plainText === '') { return [ 'success' => false, 'error' => 'No se pudo descifrar la respuesta del estado público de Shalom.', ]; } $decodedPlain = json_decode($plainText, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($decodedPlain)) { return [ 'success' => false, 'error' => 'Shalom devolvió un estado público imposible de interpretar.', 'details' => $plainText, ]; } return [ 'success' => true, 'data' => $decodedPlain, ]; } function call_public_shalom_status_api(string $orderNumber): array { $authorization = build_shalom_web_authorization(); $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => 'https://serviceswebapi.shalomcontrol.com/api/v1/web/rastrea/estados', CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => [ 'ose_id' => $orderNumber, ], CURLOPT_HTTPHEADER => [ 'Accept: application/json', 'Authorization: ' . $authorization, 'Origin: https://shalom.com.pe', 'Referer: https://shalom.com.pe/', 'X-Requested-With: XMLHttpRequest', ], CURLOPT_USERAGENT => 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 20, CURLOPT_FOLLOWLOCATION => true, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($curlError) { return [ 'success' => false, 'error' => 'Error consultando el estado público de Shalom.', 'details' => $curlError, ]; } if ($response === false || $response === null || $response === '') { return [ 'success' => false, 'error' => 'Shalom no devolvió respuesta para el estado público.', 'details' => 'HTTP ' . ($httpCode ?: 'desconocido'), ]; } $decodedTransport = json_decode($response, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($decodedTransport)) { return [ 'success' => false, 'error' => 'La respuesta del estado público de Shalom no es JSON válido.', 'details' => $response, ]; } $decrypted = decrypt_shalom_public_payload($decodedTransport); if (empty($decrypted['success'])) { return $decrypted; } $payload = $decrypted['data']; $success = !empty($payload['success']); $message = is_array($payload) ? trim((string) ($payload['message'] ?? $payload['error'] ?? '')) : ''; if ($httpCode >= 400 || !$success) { return [ 'success' => false, 'error' => $message !== '' ? 'Estado público Shalom: ' . $message : 'Shalom no pudo devolver el estado real del envío.', 'details' => $payload, ]; } return [ 'success' => true, 'data' => $payload, ]; } function infer_status_detail_text(string $statusMessage): string { $upper = mb_strtoupper(trim($statusMessage), 'UTF-8'); if ($upper === '') { return 'Estado actualizado en Shalom.'; } if (str_contains($upper, 'REVISAR GUIA') || str_contains($upper, 'VERIFICAR')) { return 'Shalom no encontró la orden. Revisa el N° de orden y el código de orden.'; } if (str_contains($upper, 'ENTREGADO') || str_contains($upper, 'COMPLETADO')) { return 'El pedido ha sido entregado satisfactoriamente.'; } if (str_contains($upper, 'REPARTO')) { return 'El pedido está en reparto final.'; } if (str_contains($upper, 'DESTINO')) { return 'El pedido llegó a la ciudad de destino y está listo para el siguiente paso.'; } if (str_contains($upper, 'TRANSITO') || str_contains($upper, 'TRÁNSITO')) { return 'Rumbo a su destino.'; } if (str_contains($upper, 'ORIGEN') || str_contains($upper, 'REGISTRADO')) { return 'El pedido fue registrado en la agencia de origen.'; } return 'Estado actualizado en Shalom.'; } function build_status_api_response(string $orderNumber, string $orderCode, array $statusPayload, ?array $scraped = null): array { $statusMessage = infer_status_message_from_payload($statusPayload); $statusData = isset($statusPayload['data']) && is_array($statusPayload['data']) ? $statusPayload['data'] : []; if ($scraped !== null) { [$dateIso, $dateText] = parse_shalom_tracking_date($scraped['date_text'] ?? ''); if (!isset($statusData['registrado']) || !is_array($statusData['registrado'])) { $statusData['registrado'] = [ 'fecha' => $dateIso, 'fecha_text' => $dateText, ]; } else { if ($dateIso !== null && empty($statusData['registrado']['fecha'])) { $statusData['registrado']['fecha'] = $dateIso; } if ($dateText !== null && empty($statusData['registrado']['fecha_text'])) { $statusData['registrado']['fecha_text'] = $dateText; } } } $detailText = $scraped['description'] ?? ''; if (trim((string) $detailText) === '') { $detailText = infer_status_detail_text($statusMessage); } return [ 'source' => $scraped !== null ? 'shalom_public_status+web' : 'shalom_public_status', 'search' => [ 'success' => true, 'data' => [ 'origen' => ['nombre' => 'N/A'], 'destino' => ['nombre' => 'N/A', 'direccion' => ''], 'tracking_url' => $scraped['tracking_url'] ?? build_public_tracking_url($orderNumber, $orderCode), 'internal_order_number' => $scraped['internal_order_number'] ?? null, 'external_order_number' => $orderNumber, 'order_code' => $orderCode, 'detail_text' => $detailText, 'requires_login' => !empty($scraped['requires_login']), ], ], 'statuses' => [ 'message' => $statusMessage, 'raw_message' => isset($statusPayload['message']) ? trim((string) $statusPayload['message']) : null, 'data' => $statusData, ], ]; } function run_shalom_web_scraper(string $orderNumber, string $orderCode): array { if (!function_exists('exec')) { return [ 'success' => false, 'error' => 'La función exec está deshabilitada en el servidor.', ]; } $scriptPath = __DIR__ . '/includes/shalom_scraper.js'; if (!is_readable($scriptPath)) { return [ 'success' => false, 'error' => 'No se encontró el scraper local de Shalom.', ]; } $nodeBinary = resolve_binary(['/usr/bin/node', '/usr/local/bin/node'], 'node'); $timeoutBinary = resolve_binary(['/usr/bin/timeout', '/bin/timeout'], 'timeout'); $commandParts = [ escapeshellarg($timeoutBinary), '50s', escapeshellarg($nodeBinary), escapeshellarg($scriptPath), escapeshellarg($orderNumber), escapeshellarg($orderCode), ]; $command = implode(' ', $commandParts) . ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); $rawOutput = trim(implode("\n", $output)); if ($rawOutput === '') { return [ 'success' => false, 'error' => 'El rastreo público de Shalom no devolvió respuesta.', 'details' => 'Proceso sin salida útil.', ]; } $decoded = json_decode($rawOutput, true); if (!is_array($decoded)) { return [ 'success' => false, 'error' => 'Respuesta inválida del rastreo público de Shalom.', 'details' => $rawOutput, ]; } if ($exitCode !== 0 && empty($decoded['success'])) { if (empty($decoded['details'])) { $decoded['details'] = $rawOutput; } return $decoded; } return $decoded; } function run_shalom_search_api_lookup(string $orderNumber, string $orderCode): array { if (!function_exists('exec')) { return [ 'success' => false, 'error' => 'La función exec está deshabilitada en el servidor.', ]; } $scriptPath = __DIR__ . '/includes/shalom_api_lookup.js'; if (!is_readable($scriptPath)) { return [ 'success' => false, 'error' => 'No se encontró el helper local de búsqueda de Shalom.', ]; } $nodeBinary = resolve_binary(['/usr/bin/node', '/usr/local/bin/node'], 'node'); $timeoutBinary = resolve_binary(['/usr/bin/timeout', '/bin/timeout'], 'timeout'); $commandParts = [ escapeshellarg($timeoutBinary), '80s', escapeshellarg($nodeBinary), escapeshellarg($scriptPath), escapeshellarg($orderNumber), escapeshellarg($orderCode), ]; $command = implode(' ', $commandParts) . ' 2>&1'; $output = []; $exitCode = 0; exec($command, $output, $exitCode); $rawOutput = trim(implode("\n", $output)); if ($rawOutput === '') { return [ 'success' => false, 'error' => 'La búsqueda oficial de Shalom no devolvió respuesta.', 'details' => 'Proceso sin salida útil.', ]; } $decoded = json_decode($rawOutput, true); if (!is_array($decoded)) { return [ 'success' => false, 'error' => 'Respuesta inválida del helper oficial de Shalom.', 'details' => $rawOutput, ]; } if ($exitCode !== 0 && empty($decoded['success'])) { if (empty($decoded['details'])) { $decoded['details'] = $rawOutput; } return $decoded; } return $decoded; } function build_scraper_response(string $orderNumber, string $orderCode, array $scraped): array { [$dateIso, $dateText] = parse_shalom_tracking_date($scraped['date_text'] ?? ''); return [ 'source' => 'shalom_web', 'search' => [ 'success' => true, 'data' => [ 'origen' => ['nombre' => 'N/A'], 'destino' => ['nombre' => 'N/A', 'direccion' => ''], 'tracking_url' => $scraped['tracking_url'] ?? build_public_tracking_url($orderNumber, $orderCode), 'internal_order_number' => $scraped['internal_order_number'] ?? null, 'external_order_number' => $orderNumber, 'order_code' => $orderCode, 'detail_text' => $scraped['description'] ?? '', 'requires_login' => !empty($scraped['requires_login']), ], ], 'statuses' => [ 'message' => normalize_shalom_status_label((string) ($scraped['status'] ?? 'No disponible')), 'data' => [ 'registrado' => [ 'fecha' => $dateIso, 'fecha_text' => $dateText, ], ], ], ]; } function call_official_shalom_api(string $orderNumber, string $orderCode, ?string $apiKey): array { if (!$apiKey) { return [ 'success' => false, 'error' => 'Falta SHALOM_API_KEY.', ]; } $url = 'https://shalom-api.lat/api/track'; $postData = [ 'orderNumber' => $orderNumber, 'orderCode' => $orderCode, ]; $jsonData = json_encode($postData, JSON_UNESCAPED_UNICODE); if ($jsonData === false) { return [ 'success' => false, 'error' => 'Error serializando la solicitud a la API de Shalom.', ]; } $ch = curl_init(); curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $jsonData, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', "Authorization: Bearer {$apiKey}", ], CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => 25, CURLOPT_FOLLOWLOCATION => true, ]); $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); $curlError = curl_error($ch); curl_close($ch); if ($curlError) { return [ 'success' => false, 'error' => 'Error en la comunicación con la API de Shalom.', 'details' => $curlError, ]; } if ($response === false || $response === null || $response === '') { return [ 'success' => false, 'error' => 'Shalom no devolvió respuesta por la API.', 'details' => 'HTTP ' . ($httpCode ?: 'desconocido'), ]; } $decoded = json_decode($response, true); if ($httpCode >= 400) { $message = null; if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { $message = $decoded['message'] ?? $decoded['error'] ?? null; } return [ 'success' => false, 'error' => $message ? 'Error API Shalom: ' . $message : 'Error API Shalom HTTP ' . $httpCode . '.', 'details' => is_array($decoded) ? $decoded : $response, ]; } if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) { return [ 'success' => false, 'error' => 'La API de Shalom devolvió una respuesta inválida.', 'details' => $response, ]; } return [ 'success' => true, 'data' => $decoded, ]; } // Inputs $orderNumber = trim($_GET['orderNumber'] ?? ''); $orderCode = trim($_GET['orderCode'] ?? ''); $includeDetailsRaw = trim((string) ($_GET['includeDetails'] ?? '')); $includeDetails = in_array(strtolower($includeDetailsRaw), ['1', 'true', 'yes', 'si', 'sí'], true); if ($orderNumber === '' || $orderCode === '') { respond_json(400, ['error' => 'Número de orden y código de orden son requeridos.'], $jsonOptions); } $attemptErrors = []; // Primero resolvemos el N° interno real con la búsqueda oficial de Shalom. // El endpoint público de estados usa ese identificador interno (ose_id); // si se le pasa el número externo del pedido puede devolver otra guía y // marcar "Entregado" por error. $searchApiAttempt = run_shalom_search_api_lookup($orderNumber, $orderCode); if (!empty($searchApiAttempt['success']) && !empty($searchApiAttempt['search']) && !empty($searchApiAttempt['status']) && is_array($searchApiAttempt['search']) && is_array($searchApiAttempt['status'])) { $resolvedOrderNumber = $searchApiAttempt['search']['data']['ose_id'] ?? null; $resolvedOrderNumber = is_scalar($resolvedOrderNumber) ? (string) $resolvedOrderNumber : null; $trackingUrl = $searchApiAttempt['resolved']['tracking_url'] ?? build_public_tracking_url( $resolvedOrderNumber !== null ? $resolvedOrderNumber : $orderNumber, $orderCode ); $apiResponse = build_status_api_response($orderNumber, $orderCode, $searchApiAttempt['status'], [ 'internal_order_number' => $resolvedOrderNumber, 'tracking_url' => $trackingUrl, 'description' => '', 'requires_login' => false, ]); $apiResponse['source'] = 'shalom_search_api'; respond_json(200, $apiResponse, $jsonOptions); } $missingGuideReason = detect_shalom_missing_order_reason($searchApiAttempt); if ($missingGuideReason !== null) { respond_json(200, build_invalid_guide_response($orderNumber, $orderCode, $missingGuideReason), $jsonOptions); } if (!empty($searchApiAttempt['error'])) { $attemptErrors[] = 'Búsqueda API Shalom: ' . $searchApiAttempt['error']; } if (!empty($searchApiAttempt['details'])) { $details = is_string($searchApiAttempt['details']) ? $searchApiAttempt['details'] : json_encode($searchApiAttempt['details'], $jsonOptions); if ($details) { $attemptErrors[] = 'Detalle búsqueda API: ' . $details; } } // Fallback 1: rastreo web visual (más pesado, pero usa el número + código visibles al usuario). $scraperAttempt = run_shalom_web_scraper($orderNumber, $orderCode); if (!empty($scraperAttempt['success'])) { respond_json(200, build_scraper_response($orderNumber, $orderCode, $scraperAttempt), $jsonOptions); } $missingGuideReason = detect_shalom_missing_order_reason($scraperAttempt); if ($missingGuideReason !== null) { respond_json(200, build_invalid_guide_response($orderNumber, $orderCode, $missingGuideReason), $jsonOptions); } if (!empty($scraperAttempt['error'])) { $attemptErrors[] = 'Rastreo web: ' . $scraperAttempt['error']; } if (!empty($scraperAttempt['details'])) { $details = is_string($scraperAttempt['details']) ? $scraperAttempt['details'] : json_encode($scraperAttempt['details'], $jsonOptions); if ($details) { $attemptErrors[] = 'Detalle web: ' . $details; } } load_dotenv_if_needed(['SHALOM_API_KEY']); // API key fallback from DB (so the app can work even if you can't edit .env) $apiKey = getenv('SHALOM_API_KEY'); if (!$apiKey) { try { require_once __DIR__ . '/db/config.php'; $pdo = db(); $stmt = $pdo->prepare('SELECT valor FROM configuracion WHERE clave = ? LIMIT 1'); $stmt->execute(['SHALOM_API_KEY']); $apiKey = $stmt->fetchColumn(); $apiKey = is_string($apiKey) ? trim($apiKey) : null; } catch (Throwable $e) { $apiKey = null; } } // Fallback 2: API oficial antigua. $officialAttempt = call_official_shalom_api($orderNumber, $orderCode, $apiKey ?: null); if (!empty($officialAttempt['success']) && !empty($officialAttempt['data']) && is_array($officialAttempt['data'])) { respond_json(200, $officialAttempt['data'], $jsonOptions); } $missingGuideReason = detect_shalom_missing_order_reason($officialAttempt); if ($missingGuideReason !== null) { respond_json(200, build_invalid_guide_response($orderNumber, $orderCode, $missingGuideReason), $jsonOptions); } if (!empty($officialAttempt['error'])) { $attemptErrors[] = 'API oficial: ' . $officialAttempt['error']; } if (!empty($officialAttempt['details'])) { $details = is_string($officialAttempt['details']) ? $officialAttempt['details'] : json_encode($officialAttempt['details'], $jsonOptions); if ($details) { $attemptErrors[] = 'Detalle API: ' . $details; } } respond_json(502, [ 'error' => 'No se pudo consultar el estado en Shalom.', 'details' => !empty($attemptErrors) ? implode(' | ', $attemptErrors) : 'No hubo respuesta útil de la búsqueda oficial, del rastreo web ni de la API.', 'tracking_url' => build_public_tracking_url($orderNumber, $orderCode), ], $jsonOptions);