Feat: Implementa auditoría de envíos masivos y mejora de procesos

Se introduce un sistema completo para auditar los envíos masivos de correos durante el cierre mensual y se refactoriza la interfaz de usuario de procesos para una mayor claridad y escalabilidad. Además, se mejora la lógica de negocio para la gestión de bajas de suscripciones.

###  Nuevas Características

- **Auditoría de Envíos Masivos (Cierre Mensual):**
    - Se crea una nueva tabla `com_LotesDeEnvio` para registrar cada ejecución del proceso de facturación mensual.
    - El `FacturacionService` ahora crea un "lote" al iniciar el cierre, registra el resultado de cada envío de email individual asociándolo a dicho lote, y actualiza las estadísticas finales (enviados, fallidos) al terminar.
    - Se implementa un nuevo `LotesEnvioController` con un endpoint para consultar los detalles de cualquier lote de envío histórico.

### 🔄 Refactorización y Mejoras

- **Rediseño de la Página de Procesos:**
    - La antigua página "Facturación" se renombra a `CierreYProcesosPage` y se rediseña completamente utilizando una interfaz de Pestañas (Tabs).
    - **Pestaña "Procesos Mensuales":** Aisla las acciones principales (Generar Cierre, Archivo de Débito, Procesar Respuesta), mostrando un resumen del resultado del último envío.
    - **Pestaña "Historial de Cierres":** Muestra una tabla con todos los lotes de envío pasados y permite al usuario ver los detalles de cada uno en un modal.

- **Filtros para el Historial de Cierres:**
    - Se añaden filtros por Mes y Año a la pestaña de "Historial de Cierres", permitiendo al usuario buscar y auditar procesos pasados de manera eficiente. El filtrado se realiza en el backend para un rendimiento óptimo.

- **Lógica de `FechaFin` Obligatoria para Bajas:**
    - Se implementa una regla de negocio crucial: al cambiar el estado de una suscripción a "Pausada" o "Cancelada", ahora es obligatorio establecer una `FechaFin`.
    - **Frontend:** El modal de suscripciones ahora gestiona esto automáticamente, haciendo el campo `FechaFin` requerido y visible según el estado seleccionado.
    - **Backend:** Se añade una validación en `SuscripcionService` como segunda capa de seguridad para garantizar la integridad de los datos.

### 🐛 Corrección de Errores

- **Reporte de Distribución:** Se corrigió un bug en la generación del PDF donde la columna de fecha no mostraba la "Fecha de Baja" para las suscripciones finalizadas. Ahora se muestra la fecha correcta según la sección (Altas o Bajas).
- **Errores de Compilación y Dependencias:** Se solucionaron varios errores de compilación en el backend, principalmente relacionados con la falta de registro de los nuevos repositorios (`ILoteDeEnvioRepository`, `IEmailLogService`, etc.) en el contenedor de inyección de dependencias (`Program.cs`).
- **Errores de Tipado en Frontend:** Se corrigieron múltiples errores de TypeScript en `CierreYProcesosPage` debidos a la inconsistencia entre `PascalCase` (C#) y `camelCase` (JSON/TypeScript), asegurando un mapeo correcto de los datos de la API.
This commit is contained in:
2025-08-11 11:14:03 -03:00
parent 7dc0940001
commit 1a288fcfa5
32 changed files with 1175 additions and 512 deletions

View File

@@ -49,5 +49,39 @@ namespace GestionIntegral.Api.Services.Comunicaciones
: "Sistema"
});
}
public async Task<IEnumerable<EmailLogDto>> ObtenerDetallesPorLoteId(int idLoteDeEnvio)
{
var logs = await _emailLogRepository.GetByLoteIdAsync(idLoteDeEnvio);
if (!logs.Any())
{
return Enumerable.Empty<EmailLogDto>();
}
// Reutilizamos la misma lógica de optimización N+1 que ya teníamos
var idsUsuarios = logs
.Where(l => l.IdUsuarioDisparo.HasValue)
.Select(l => l.IdUsuarioDisparo!.Value)
.Distinct();
var usuariosDict = new Dictionary<int, string>();
if (idsUsuarios.Any())
{
var usuarios = await _usuarioRepository.GetByIdsAsync(idsUsuarios);
usuariosDict = usuarios.ToDictionary(u => u.Id, u => $"{u.Nombre} {u.Apellido}");
}
return logs.Select(log => new EmailLogDto
{
FechaEnvio = log.FechaEnvio,
Estado = log.Estado,
Asunto = log.Asunto,
DestinatarioEmail = log.DestinatarioEmail,
Error = log.Error,
NombreUsuarioDisparo = log.IdUsuarioDisparo.HasValue
? usuariosDict.GetValueOrDefault(log.IdUsuarioDisparo.Value, "Usuario Desconocido")
: "Sistema"
});
}
}
}

View File

@@ -16,7 +16,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
public EmailService(
IOptions<MailSettings> mailSettings,
ILogger<EmailService> logger,
IEmailLogRepository emailLogRepository) // Inyectar el nuevo repositorio
IEmailLogRepository emailLogRepository)
{
_mailSettings = mailSettings.Value;
_logger = logger;
@@ -26,7 +26,8 @@ namespace GestionIntegral.Api.Services.Comunicaciones
public async Task EnviarEmailAsync(
string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml,
byte[]? attachment = null, string? attachmentName = null,
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null)
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null,
int? idLoteDeEnvio = null)
{
var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
@@ -41,14 +42,14 @@ namespace GestionIntegral.Api.Services.Comunicaciones
}
email.Body = builder.ToMessageBody();
// Llamar al método centralizado de envío y logging
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo);
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo, idLoteDeEnvio);
}
public async Task EnviarEmailConsolidadoAsync(
string destinatarioEmail, string destinatarioNombre, string asunto, string cuerpoHtml,
List<(byte[] content, string name)> adjuntos,
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null)
string? origen = null, string? referenciaId = null, int? idUsuarioDisparo = null,
int? idLoteDeEnvio = null)
{
var email = new MimeMessage();
email.Sender = new MailboxAddress(_mailSettings.SenderName, _mailSettings.SenderEmail);
@@ -66,14 +67,10 @@ namespace GestionIntegral.Api.Services.Comunicaciones
}
email.Body = builder.ToMessageBody();
// Llamar al método centralizado de envío y logging
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo);
await SendAndLogEmailAsync(email, origen, referenciaId, idUsuarioDisparo, idLoteDeEnvio);
}
/// <summary>
/// Método privado que centraliza el envío de correo y el registro de logs.
/// </summary>
private async Task SendAndLogEmailAsync(MimeMessage emailMessage, string? origen, string? referenciaId, int? idUsuarioDisparo)
private async Task SendAndLogEmailAsync(MimeMessage emailMessage, string? origen, string? referenciaId, int? idUsuarioDisparo, int? idLoteDeEnvio)
{
var destinatario = emailMessage.To.Mailboxes.FirstOrDefault()?.Address ?? "desconocido";
@@ -84,7 +81,8 @@ namespace GestionIntegral.Api.Services.Comunicaciones
Asunto = emailMessage.Subject,
Origen = origen,
ReferenciaId = referenciaId,
IdUsuarioDisparo = idUsuarioDisparo
IdUsuarioDisparo = idUsuarioDisparo,
IdLoteDeEnvio = idLoteDeEnvio
};
using var smtp = new SmtpClient();
@@ -97,11 +95,8 @@ namespace GestionIntegral.Api.Services.Comunicaciones
log.Estado = "Enviado";
_logger.LogInformation("Email enviado exitosamente a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
}
// Capturamos excepciones específicas de MailKit para obtener errores más detallados.
catch (SmtpCommandException scEx)
{
// Este error ocurre cuando el servidor SMTP rechaza un comando.
// Es el caso más común para direcciones de email inválidas que son rechazadas inmediatamente.
_logger.LogError(scEx, "Error de comando SMTP al enviar a {Destinatario}. StatusCode: {StatusCode}", destinatario, scEx.StatusCode);
log.Estado = "Fallido";
log.Error = $"Error del servidor: ({scEx.StatusCode}) {scEx.Message}";
@@ -109,13 +104,12 @@ namespace GestionIntegral.Api.Services.Comunicaciones
}
catch (AuthenticationException authEx)
{
// Error específico de autenticación.
_logger.LogError(authEx, "Error de autenticación con el servidor SMTP.");
log.Estado = "Fallido";
log.Error = "Error de autenticación. Revise las credenciales de correo.";
throw;
}
catch (Exception ex) // Captura genérica para cualquier otro problema (conexión, etc.)
catch (Exception ex)
{
_logger.LogError(ex, "Error general al enviar email a {Destinatario}. Asunto: {Asunto}", destinatario, emailMessage.Subject);
log.Estado = "Fallido";
@@ -129,7 +123,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones
await smtp.DisconnectAsync(true);
}
// Guardar el log en la base de datos, sin importar el resultado del envío
try
{
await _emailLogRepository.CreateAsync(log);

View File

@@ -5,5 +5,6 @@ namespace GestionIntegral.Api.Services.Comunicaciones
public interface IEmailLogService
{
Task<IEnumerable<EmailLogDto>> ObtenerHistorialPorReferencia(string referenciaId);
Task<IEnumerable<EmailLogDto>> ObtenerDetallesPorLoteId(int idLoteDeEnvio);
}
}

View File

@@ -15,6 +15,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
/// <param name="origen">Identificador del proceso que dispara el email (ej. "EnvioManualPDF"). Para logging.</param>
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Factura-59"). Para logging.</param>
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
/// <param name="idLoteDeEnvio">ID del lote de envío masivo al que pertenece este correo (si aplica). Para logging.</param>
Task EnviarEmailAsync(
string destinatarioEmail,
string destinatarioNombre,
@@ -24,7 +25,8 @@ namespace GestionIntegral.Api.Services.Comunicaciones
string? attachmentName = null,
string? origen = null,
string? referenciaId = null,
int? idUsuarioDisparo = null);
int? idUsuarioDisparo = null,
int? idLoteDeEnvio = null);
/// <summary>
/// Envía un correo electrónico a un único destinatario, con la posibilidad de adjuntar múltiples archivos.
@@ -38,6 +40,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
/// <param name="origen">Identificador del proceso que dispara el email (ej. "FacturacionMensual"). Para logging.</param>
/// <param name="referenciaId">ID de la entidad relacionada (ej. "Suscriptor-3"). Para logging.</param>
/// <param name="idUsuarioDisparo">ID del usuario que inició la acción (si aplica). Para logging.</param>
/// <param name="idLoteDeEnvio">ID del lote de envío masivo al que pertenece este correo (si aplica). Para logging.</param>
Task EnviarEmailConsolidadoAsync(
string destinatarioEmail,
string destinatarioNombre,
@@ -46,6 +49,7 @@ namespace GestionIntegral.Api.Services.Comunicaciones
List<(byte[] content, string name)> adjuntos,
string? origen = null,
string? referenciaId = null,
int? idUsuarioDisparo = null);
int? idUsuarioDisparo = null,
int? idLoteDeEnvio = null);
}
}