From f1d4b7c98f89db9e3c3b858599e540ed9ac9982c Mon Sep 17 00:00:00 2001 From: Alexeer Date: Mon, 9 Jun 2025 12:15:33 -0600 Subject: [PATCH] Notificaciones Cambio de Estado de Solicitud --- app/controllers/solicitud_importacion.php | 326 +++++++++++++++++++++- views/configuracion/editar.php | 1 + views/solicitud_importacion/lista.php | 208 +++++++++++--- 3 files changed, 488 insertions(+), 47 deletions(-) diff --git a/app/controllers/solicitud_importacion.php b/app/controllers/solicitud_importacion.php index 64c2eab..99253d9 100644 --- a/app/controllers/solicitud_importacion.php +++ b/app/controllers/solicitud_importacion.php @@ -8,7 +8,6 @@ require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/../helpers/env.php'; loadEnv(); -require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/../helpers/crypto.php'; /** Obtiene (y cachea en sesión) el JWT de la API usando las credenciales de $_ENV **/ @@ -814,9 +813,130 @@ function update_status() { exit; } - // 3) Si el status actualizado es 2, hacer POST a /pedimentos/crearPedimento + // 3) Obtener datos del usuario y solicitud para notificación + try { + $sqlNotif = " + SELECT + u.nombre, + u.email, + u.notificaciones, + u.notificaciones_extra, + COALESCE(p.cambio_estado, 0) as cambio_estado, + ce.correo as correo_extra, + s.numero_factura, + s.fecha_factura, + s.valor_factura, + s.tipo_moneda + FROM usuarios_sistema u + LEFT JOIN preferencias_notificaciones_usuario p ON u.id_usuario = p.id_usuario + LEFT JOIN correo_extra ce ON u.id_usuario = ce.id_usuario + INNER JOIN solicitud_importacion_factura s ON s.id_importador = u.id_usuario + WHERE u.id_usuario = ? AND s.id_solicitud = ?"; + + $stmtNotif = sqlsrv_prepare($conn, $sqlNotif, [$_SESSION['usuario_id'], $id]); + + if ($stmtNotif && sqlsrv_execute($stmtNotif)) { + $notifConfig = sqlsrv_fetch_array($stmtNotif, SQLSRV_FETCH_ASSOC); + sqlsrv_free_stmt($stmtNotif); + + // 🔐 Desencriptar datos + if ($notifConfig) { + $notifConfig['email'] = decrypt($notifConfig['email']); + $notifConfig['nombre'] = decrypt($notifConfig['nombre']); + } + + // Verificar si debe enviar notificaciones de cambio de status + if ($notifConfig && + $notifConfig['notificaciones'] == 1 && + $notifConfig['cambio_estado'] == 1) { + + // Formatear fecha si es DateTime + $fechaFactura = ($notifConfig['fecha_factura'] instanceof DateTime) + ? $notifConfig['fecha_factura']->format('Y-m-d') + : $notifConfig['fecha_factura']; + + // Preparar datos para la notificación + $datosSolicitud = [ + 'id_solicitud' => $id, + 'numero_factura' => $notifConfig['numero_factura'], + 'fecha_factura' => $fechaFactura, + 'valor_factura' => $notifConfig['valor_factura'], + 'tipo_moneda' => $notifConfig['tipo_moneda'], + 'nuevo_status' => $status + ]; + + $notificacionesEnviadas = 0; + $erroresNotificacion = []; + + // Enviar al correo principal + if (!empty($notifConfig['email']) && filter_var($notifConfig['email'], FILTER_VALIDATE_EMAIL)) { + $resultadoPrincipal = enviarNotificacionCambioStatus( + $notifConfig['email'], + $notifConfig['nombre'], + $datosSolicitud, + false // No es correo adicional + ); + + if ($resultadoPrincipal) { + $notificacionesEnviadas++; + } else { + $erroresNotificacion[] = 'correo principal'; + } + } + + // Enviar al correo adicional si está configurado + if ($notifConfig['notificaciones_extra'] == 1 && + !empty($notifConfig['correo_extra']) && + filter_var($notifConfig['correo_extra'], FILTER_VALIDATE_EMAIL)) { + + $resultadoExtra = enviarNotificacionCambioStatus( + $notifConfig['correo_extra'], + $notifConfig['nombre'], + $datosSolicitud, + true // Es correo adicional + ); + + if ($resultadoExtra) { + $notificacionesEnviadas++; + } else { + $erroresNotificacion[] = 'correo adicional'; + } + } + + // Log consolidado del resultado + if ($notificacionesEnviadas > 0) { + error_log("✅ Notificaciones de cambio de status enviadas ($notificacionesEnviadas) para solicitud ID: $id → Status: $status"); + } + + if (!empty($erroresNotificacion)) { + error_log("⚠️ Errores al enviar notificaciones (" . implode(', ', $erroresNotificacion) . ") para solicitud ID: $id"); + } + + } else { + // Log informativo cuando las notificaciones están deshabilitadas + $razon = []; + if (!$notifConfig) { + $razon[] = 'configuración no encontrada'; + } else { + if ($notifConfig['notificaciones'] != 1) $razon[] = 'notificaciones generales deshabilitadas'; + if ($notifConfig['cambio_estado'] != 1) $razon[] = 'notificaciones de cambio de estado deshabilitadas'; + } + + error_log("ℹ️ Usuario ID: {$_SESSION['usuario_id']} no recibirá notificación de cambio de status. Razón: " . implode(', ', $razon)); + } + } else { + error_log("⚠️ No se pudo consultar configuración de notificaciones para usuario ID: {$_SESSION['usuario_id']} - Error SQL: " . print_r(sqlsrv_errors(), true)); + } + + } catch (Exception $e) { + // No fallar el proceso principal por errores de notificación + error_log("❌ Error en sistema de notificaciones de cambio de status para solicitud ID: $id - " . $e->getMessage()); + error_log("Stack trace: " . $e->getTraceAsString()); + } + + // 4) Si el status actualizado es 2, hacer POST a /pedimentos/crearPedimento if ($status === 2) { - // 3.1) Obtener o renovar el token de la API + // 4.1) Obtener o renovar el token de la API $token = getApiToken(); if (!$token) { error_log("[update_status] No se pudo obtener token para crearPedimento"); @@ -824,7 +944,7 @@ function update_status() { exit; } - // 3.2) Leer de la BD todos los campos de la cabecera de la solicitud + // 4.2) Leer de la BD todos los campos de la cabecera de la solicitud $sqlSel = " SELECT s.id_solicitud, @@ -864,7 +984,7 @@ function update_status() { exit; } - // 3.3) Formatear fechas (si vienen como DateTime) antes de construir JSON + // 4.3) Formatear fechas (si vienen como DateTime) antes de construir JSON $fechaFactura = ($solicitud['fecha_factura'] instanceof DateTime) ? $solicitud['fecha_factura']->format('Y-m-d') : $solicitud['fecha_factura']; @@ -877,7 +997,7 @@ function update_status() { ? $solicitud['updated_at']->format('Y-m-d\TH:i:s') : $solicitud['updated_at']; - // 3.4) Obtener todas las partidas asociadas a esta solicitud + // 4.4) Obtener todas las partidas asociadas a esta solicitud $sqlPart = " SELECT p.id_partida, @@ -914,7 +1034,7 @@ function update_status() { } sqlsrv_free_stmt($stmtPart); - // 3.5) Construir el arreglo PHP con la misma estructura JSON que envías + // 4.5) Construir el arreglo PHP con la misma estructura JSON que envías $payload = [ "id_solicitud" => intval($solicitud['id_solicitud']), "id_importador" => intval($solicitud['id_importador']), @@ -961,7 +1081,7 @@ function update_status() { $curlErr = curl_error($ch); curl_close($ch); - // 3.7) Verificar respuesta del endpoint pedimentos + // 4.7) Verificar respuesta del endpoint pedimentos if ($httpPed === 201 || $httpPed === 200) { $decoded = json_decode($respPed, true); // Si existe PEDIMENTO dentro de la clave 'pedimento' @@ -1002,11 +1122,199 @@ function update_status() { } } - // 4) Si el status no es 2, devolvemos normal + // 5) Si el status no es 2, devolvemos normal echo json_encode(['success' => true]); exit; } +/** Envía notificación de cambio de estado de solicitud de importación **/ +function enviarNotificacionCambioStatus($email, $nombreCompleto, $datosSolicitud, $esCorreoExtra = false) { + // Validar datos de entrada + if (empty($email) || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + error_log("❌ Email inválido para notificación: $email"); + return false; + } + + if (empty($datosSolicitud) || !isset($datosSolicitud['nuevo_status'])) { + error_log("❌ Datos de solicitud incompletos para notificación"); + return false; + } + + $mail = new PHPMailer(true); + + try { + // Configuración SMTP + $mail->isSMTP(); + $mail->Host = 'secure.emailsrvr.com'; + $mail->SMTPAuth = true; + $mail->Username = 'noreply@aduanasoft.com.mx'; + $mail->Password = $_ENV['SMTP_PASS'] ?? ''; + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + $mail->Port = 587; + $mail->CharSet = 'UTF-8'; + + // Configuración del mensaje + $mail->setFrom('noreply@aduanasoft.com.mx', 'SIIH | AduanaSoft'); + $mail->addAddress($email); + $mail->isHTML(true); + + // Obtener descripción y emoji del status + $statusInfo = obtenerInfoStatus($datosSolicitud['nuevo_status']); + + // Personalizar subject si es correo adicional + $subjectPrefix = $esCorreoExtra ? '[COPIA] ' : ''; + $mail->Subject = $subjectPrefix . '📊 Cambio de Estado en Solicitud de Importación'; + + // Preparar datos para el template + $datosTemplate = prepararDatosTemplate($datosSolicitud, $nombreCompleto, $statusInfo, $esCorreoExtra); + + // Generar el HTML del email + $mail->Body = generarHtmlNotificacion($datosTemplate); + + // Enviar el email + $envioExitoso = $mail->send(); + + if ($envioExitoso) { + $tipoCorreo = $esCorreoExtra ? 'correo adicional' : 'correo principal'; + error_log("✅ Notificación de cambio de status enviada al $tipoCorreo: $email para solicitud ID: {$datosSolicitud['id_solicitud']}"); + return true; + } + + return false; + + } catch (Exception $e) { + $tipoCorreo = $esCorreoExtra ? 'correo adicional' : 'correo principal'; + error_log("❌ Error al enviar notificación de cambio de status al $tipoCorreo ($email): {$mail->ErrorInfo} | Exception: {$e->getMessage()}"); + return false; + } +} + +/** Obtiene información del status (descripción y emoji) **/ +function obtenerInfoStatus($status) { + $statusMap = [ + 1 => ['emoji' => '🔄', 'descripcion' => 'En proceso', 'color' => '#17a2b8'], + 2 => ['emoji' => '📋', 'descripcion' => 'Solicitar importación', 'color' => '#ffc107'], + 3 => ['emoji' => '🏢', 'descripcion' => 'Con agencia aduana', 'color' => '#6f42c1'], + 4 => ['emoji' => '💳', 'descripcion' => 'En proceso de pago', 'color' => '#fd7e14'], + 5 => ['emoji' => '✅', 'descripcion' => 'Pedimento generado', 'color' => '#28a745'], + 6 => ['emoji' => '📦', 'descripcion' => 'En tránsito', 'color' => '#007bff'], + 7 => ['emoji' => '🏁', 'descripcion' => 'Entregado', 'color' => '#28a745'], + 8 => ['emoji' => '❌', 'descripcion' => 'Cancelado', 'color' => '#dc3545'], + 9 => ['emoji' => '⏸️', 'descripcion' => 'Suspendido', 'color' => '#6c757d'] + ]; + + return $statusMap[$status] ?? [ + 'emoji' => '📝', + 'descripcion' => 'Estado actualizado', + 'color' => '#6c757d' + ]; +} + +/** Prepara los datos para el template del email **/ +function prepararDatosTemplate($datosSolicitud, $nombreCompleto, $statusInfo, $esCorreoExtra) { + return [ + 'nombreCompleto' => htmlspecialchars($nombreCompleto ?? 'Usuario'), + 'idSolicitud' => intval($datosSolicitud['id_solicitud'] ?? 0), + 'numeroFactura' => htmlspecialchars($datosSolicitud['numero_factura'] ?? 'N/A'), + 'fechaFactura' => htmlspecialchars($datosSolicitud['fecha_factura'] ?? 'N/A'), + 'valorFactura' => number_format(floatval($datosSolicitud['valor_factura'] ?? 0), 2), + 'tipoMoneda' => htmlspecialchars($datosSolicitud['tipo_moneda'] ?? 'USD'), + 'statusEmoji' => $statusInfo['emoji'], + 'statusDescripcion' => $statusInfo['descripcion'], + 'statusColor' => $statusInfo['color'], + 'esCorreoExtra' => $esCorreoExtra, + 'fechaActual' => date('Y-m-d H:i:s'), + 'anioActual' => date('Y') + ]; +} + +/** Genera el HTML para la notificación **/ +function generarHtmlNotificacion($datos) { + $tipoNotificacion = $datos['esCorreoExtra'] ? + '
+ 📧 Copia enviada a correo adicional +
' : ''; + + return " +
+
+ + +
+

+ {$datos['statusEmoji']} Estado Actualizado +

+
+ + +
+ $tipoNotificacion + +

+ Hola {$datos['nombreCompleto']}, +

+ +

+ El estado de tu solicitud de importación ha sido actualizado: +

+ + +
+ + + + + + + + + + + + + + + + + + + + + +
ID Solicitud:{$datos['idSolicitud']}
Número de Factura:{$datos['numeroFactura']}
Fecha de Factura:{$datos['fechaFactura']}
+ {$datos['statusEmoji']} Nuevo Estado: + + {$datos['statusDescripcion']} +
Valor: + {$datos['valorFactura']} {$datos['tipoMoneda']} +
+
+ +

+ Puedes consultar todos los detalles y el seguimiento completo accediendo a tu panel de control. +

+ + +
+

+ 📧 Notificación automática
+ Este correo se envía automáticamente cuando se actualiza el estado de tu solicitud. +
Fecha de envío: {$datos['fechaActual']} +

+
+
+ + +
+ © {$datos['anioActual']} SIIH · Desarrollado por + AduanaSoft +
+ Sistema Integral de Importación y Herramientas +
+
+
"; +} + // Función para generar el PDF use Dompdf\Dompdf; use Dompdf\Options; diff --git a/views/configuracion/editar.php b/views/configuracion/editar.php index 054b581..b2a540c 100644 --- a/views/configuracion/editar.php +++ b/views/configuracion/editar.php @@ -172,6 +172,7 @@
+ Cancelar
diff --git a/views/solicitud_importacion/lista.php b/views/solicitud_importacion/lista.php index d9cc413..42018a0 100644 --- a/views/solicitud_importacion/lista.php +++ b/views/solicitud_importacion/lista.php @@ -147,51 +147,183 @@ function updateRowStatus($row, status) { const disable = status === '2'; // 2 = Solicitar importación $row.find('a.btn, button.btn').prop('disabled', disable); - // Si quieres bloquear también el propio select: - // $row.find('.status-select').prop('disabled', disable); + + // Opcional: cambiar estilo visual para indicar estado bloqueado + if (disable) { + $row.addClass('table-warning').find('.status-select').addClass('border-warning'); + } else { + $row.removeClass('table-warning').find('.status-select').removeClass('border-warning'); + } } -// Al cambiar el select de status -$('#tabla-solicitudes').on('change', '.status-select', function () { - const $sel = $(this); - const id = $sel.data('id'); - const status = $sel.val(); - const $row = $sel.closest('tr'); - // Bloquear botones localmente - updateRowStatus($row, status); + // Función para mostrar loading en el select + function setSelectLoading($select, loading) { + if (loading) { + $select.prop('disabled', true).addClass('border-primary'); + $select.closest('tr').find('td').addClass('opacity-75'); + } else { + $select.prop('disabled', false).removeClass('border-primary'); + $select.closest('tr').find('td').removeClass('opacity-75'); + } + } - // Llamada AJAX - $.post( - '/IMPORTADORES/solicitud_importacion/update_status', - { id: id, status: status }, - null, - 'json' - ) - .done(function(res) { - if (!res.success) { - Swal.fire('Error', 'No se pudo actualizar status', 'error'); - } else if (res.numeroPedimentoLocal) { - // Si vino numeroPedimentoLocal, mostramos alerta específica - Swal.fire( - 'Importación solicitada', - 'Se generó el número de pedimento: ' + res.numeroPedimentoLocal, - 'success' - ); - // Además podrías actualizar en la tabla la celda de "Pedimento" si quieres: - $row.find('td:nth-child(4)').text(res.numeroPedimentoLocal); - } - // Si success pero no viene numeroPedimentoLocal, no hacemos nada especial. - }) - .fail(function() { - Swal.fire('Error', 'No se pudo conectar al servidor', 'error'); - }); -}); + // Al cambiar el select de status + $('#tabla-solicitudes').on('change', '.status-select', function () { + const $sel = $(this); + const id = $sel.data('id'); + const status = $sel.val(); + const $row = $sel.closest('tr'); + const oldStatus = $sel.data('old-status') || $sel.find('option:first').val(); + + // Guardar el estado anterior para poder revertir si falla + $sel.data('old-status', oldStatus); + + // Mostrar loading + setSelectLoading($sel, true); + + // Mostrar toast de procesando + Swal.fire({ + title: 'Actualizando...', + text: 'Procesando cambio de estado', + icon: 'info', + allowOutsideClick: false, + showConfirmButton: false, + timer: 1000, + timerProgressBar: true + }); + // Llamada AJAX con configuración mejorada + $.ajax({ + url: '/IMPORTADORES/solicitud_importacion/update_status', + method: 'POST', + data: { + id: id, + status: status + }, + dataType: 'json', + timeout: 30000, // 30 segundos timeout + success: function(res) { + console.log('Respuesta del servidor:', res); + + if (!res.success) { + // Error del servidor pero respuesta válida + handleUpdateError('Error del servidor: ' + (res.error || 'Error desconocido'), $sel, oldStatus); + return; + } + + // Éxito - actualizar UI + updateRowStatus($row, status); + $sel.data('old-status', status); // Actualizar estado guardado + + // Mostrar mensaje específico según el resultado + if (res.numeroPedimentoLocal) { + Swal.fire({ + title: '¡Importación solicitada!', + text: `Se generó el número de pedimento: ${res.numeroPedimentoLocal}`, + icon: 'success', + confirmButtonText: 'Entendido' + }); + + // Actualizar la celda del pedimento si existe + const $pedimentoCell = $row.find('td[data-pedimento]'); + if ($pedimentoCell.length) { + $pedimentoCell.text(res.numeroPedimentoLocal).addClass('text-success fw-bold'); + } + } else if (res.warning) { + // Mostrar advertencia si hay alguna + Swal.fire({ + title: 'Estado actualizado', + text: res.warning, + icon: 'warning', + confirmButtonText: 'Entendido' + }); + } else { + // Éxito normal + Swal.fire({ + title: '¡Actualizado!', + text: 'Estado cambiado correctamente', + icon: 'success', + timer: 2000, + showConfirmButton: false + }); + } + }, + error: function(xhr, status, error) { + console.error('Error AJAX:', { xhr, status, error }); + + let errorMessage = 'No se pudo conectar al servidor'; + + // Mejorar el mensaje de error según el tipo + if (status === 'timeout') { + errorMessage = 'La operación tardó demasiado tiempo. Inténtalo de nuevo.'; + } else if (status === 'parsererror') { + errorMessage = 'Error al procesar la respuesta del servidor'; + } else if (xhr.status) { + switch(xhr.status) { + case 401: + errorMessage = 'Tu sesión ha expirado. Recarga la página e inicia sesión nuevamente.'; + break; + case 403: + errorMessage = 'No tienes permisos para realizar esta acción'; + break; + case 404: + errorMessage = 'El servicio no está disponible temporalmente'; + break; + case 500: + errorMessage = 'Error interno del servidor. Contacta al administrador.'; + break; + default: + errorMessage = `Error del servidor (${xhr.status}): ${xhr.statusText}`; + } + } + + handleUpdateError(errorMessage, $sel, oldStatus); + }, + complete: function() { + // Quitar loading siempre + setSelectLoading($sel, false); + } + }); + }); - // Al cargar, aplica bloqueo si ya está en “Solicitar importación” + // Función para manejar errores de actualización + function handleUpdateError(message, $select, oldStatus) { + // Revertir el select al estado anterior + $select.val(oldStatus); + + // Mostrar error + Swal.fire({ + title: 'Error', + text: message, + icon: 'error', + confirmButtonText: 'Entendido' + }); + + // Log para debugging + console.error('Error al actualizar estado:', message); + } + + // Al cargar, aplica bloqueo si ya está en "Solicitar importación" $('#tabla-solicitudes tbody tr').each(function () { const $sel = $(this).find('.status-select'); - if ($sel.length) updateRowStatus($(this), $sel.val()); + if ($sel.length) { + updateRowStatus($(this), $sel.val()); + // Guardar estado inicial + $sel.data('old-status', $sel.val()); + } + }); + + // Interceptar errores globales de AJAX para debugging + $(document).ajaxError(function(event, xhr, settings, thrownError) { + if (settings.url.includes('update_status')) { + console.error('AJAX Error Details:', { + url: settings.url, + status: xhr.status, + statusText: xhr.statusText, + responseText: xhr.responseText, + thrownError: thrownError + }); + } }); // Mensajes SweetAlert tras acciones